Merge remote-tracking branch 'origin/main' into brennanb2025/codex-strip-producer

This commit is contained in:
Merge Sim
2026-09-07 13:12:59 -07:00
69 changed files with 3682 additions and 256 deletions
@@ -38,6 +38,7 @@ export type ClaudeStructuredSdkOptions = Pick<
| 'sessionId'
| 'resume'
| 'resumeSessionAt'
| 'resumeDropsTurn'
>
/**
@@ -0,0 +1,207 @@
import { describe, expect, it, vi } from 'vitest'
import {
adapterFor,
fakeClaude,
identityFor,
PROVIDER_SESSION_ID
} from './claude-structured-session-test-support'
import { ClaudeRewindAttempt } from './claude-structured-rewind'
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
const intent = { targetUuid: 'kept', previousLeafUuid: 'tip', dropsTurn: 'drop' }
const proofLaunch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
describe('Claude rewind acquisition', () => {
it('executes a cursor resume in place and proves the exact target before publication', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
try {
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: intent
})
expect(acquired.link.handle).toMatchObject({
provider: 'claude',
sessionId: PROVIDER_SESSION_ID,
leafUuid: 'kept'
})
expect(fake.connections[0]!.launch.options).toMatchObject({
resume: PROVIDER_SESSION_ID,
resumeSessionAt: 'kept',
resumeDropsTurn: 'drop'
})
expect(fake.connections[0]!.launch.options).not.toHaveProperty('forkSession')
expect(proof).toHaveBeenCalledWith(
expect.objectContaining({ previousLeafUuid: 'tip', intentionalRewindUuid: 'kept' })
)
await adapter.closeSession('session-1')
await adapter.acquire({ identity: identityFor(), fence: 8, spawnToken: 'spawn-next' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(
proof.mock.calls.filter(([input]) => input.intentionalRewindUuid !== undefined)
).toHaveLength(1)
} finally {
await adapter.closeAll()
}
})
it('recognizes the documented refusal and closes the failed child without retry', async () => {
const fake = fakeClaude()
const openConnection = fake.openConnection
fake.openConnection = async (launch, handlers) => {
const connection = await openConnection(launch, handlers)
const initialize = connection.initializationResult
connection.initializationResult = async (...args) => {
const result = await initialize(...args)
handlers?.onMessage?.({
type: 'result',
subtype: 'error_during_execution',
session_id: PROVIDER_SESSION_ID,
errors: ['Resume rejected by --resume-drops-turn: additional prompt observed']
})
return result
}
return connection
}
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const adapter = adapterFor(fake, { resumed: true }, [], [], undefined, proof)
await expect(
adapter.acquire({ identity: identityFor(), fence: 7, spawnToken: 'spawn', rewind: intent })
).rejects.toMatchObject({ rewindReason: 'provider-refused' })
expect(fake.connections).toHaveLength(1)
expect(fake.connections[0]?.closed).toBe(true)
expect(proof).not.toHaveBeenCalled()
await adapter.closeAll()
})
it('consumes proof authorization even if its first read fails', async () => {
const proof = vi.fn(async () => {
throw new Error('torn transcript')
})
const attempt = new ClaudeRewindAttempt(intent)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBeInstanceOf(
AgentSessionRewindRefusal
)
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('never persists success for a mismatching leaf', async () => {
const onProved = vi.fn(async () => {})
const attempt = new ClaudeRewindAttempt(intent, onProved)
await expect(
attempt.prove(proofLaunch, { readTranscriptLeaf: async () => 'other' })
).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(onProved).not.toHaveBeenCalled()
})
it('preserves commit failure as unknown and consumes the override before persisting', async () => {
const diskError = new Error('record write failed')
const onProved = vi.fn(async () => {
throw diskError
})
const proof = vi.fn(async () => 'kept')
const attempt = new ClaudeRewindAttempt(intent, onProved)
const launch = {
providerSessionId: PROVIDER_SESSION_ID,
claudeConfigDir: '/claude',
options: {},
resumed: true,
resumeLeafUuid: 'tip',
cwd: '/workspace',
pathToClaudeCodeExecutable: 'claude'
}
await expect(attempt.prove(launch, { readTranscriptLeaf: proof })).rejects.toBe(diskError)
expect(onProved).toHaveBeenCalledWith('kept')
expect(await attempt.prove(launch, { readTranscriptLeaf: proof })).toBeNull()
expect(proof).toHaveBeenCalledTimes(1)
})
it('checkpoints the proved target before late acquisition failure without persisting a stale cursor', async () => {
const fake = fakeClaude()
const launch = { resumed: true, resumeLeafUuid: 'tip' }
const persisted: unknown[] = []
const proof = vi.fn(async () => 'kept')
const adapter = adapterFor(fake, launch, [], persisted, undefined, proof)
const onProved = vi.fn(async (leafUuid: string) => {
launch.resumeLeafUuid = leafUuid
fake.connections[0]!.closed = true
})
try {
await expect(
adapter.acquire({
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewind: { ...intent, onProved }
})
).rejects.toThrow('exited while being acquired')
expect(onProved).toHaveBeenCalledWith('kept')
expect(persisted).toEqual([])
const acquired = await adapter.acquire({
identity: identityFor(),
fence: 8,
spawnToken: 'retry'
})
expect(acquired.link.handle).toMatchObject({ leafUuid: 'kept' })
expect(fake.connections[1]!.launch.options).not.toHaveProperty('resumeDropsTurn')
expect(proof).toHaveBeenCalledTimes(1)
} finally {
await adapter.closeAll()
}
})
it('restores an interrupted unproved rewind only after exact ordinary branch proof', async () => {
const fake = fakeClaude()
const proof = vi.fn(async (_input: { intentionalRewindUuid?: string }) => 'kept')
const restored = vi.fn(async () => {})
const adapter = adapterFor(
fake,
{ resumed: true, resumeLeafUuid: 'tip' },
[],
[],
undefined,
proof
)
const input = {
identity: identityFor(),
fence: 7,
spawnToken: 'spawn',
rewindRecovery: { leafUuid: 'tip', onProved: restored }
}
try {
await expect(adapter.acquire(input)).rejects.toMatchObject({ rewindReason: 'proof-mismatch' })
expect(restored).not.toHaveBeenCalled()
proof.mockResolvedValue('tip')
await adapter.acquire({ ...input, fence: 8, spawnToken: 'retry' })
expect(restored).toHaveBeenCalledOnce()
expect(proof).toHaveBeenCalledWith(expect.objectContaining({ previousLeafUuid: 'tip' }))
for (const [request] of proof.mock.calls) {
expect(request).not.toHaveProperty('intentionalRewindUuid')
}
} finally {
await adapter.closeAll()
}
})
})
+118
View File
@@ -0,0 +1,118 @@
import { AgentSessionRewindRefusal } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
export function claudeRewindRefusalFromMessage(
message: Record<string, unknown>
): AgentSessionRewindRefusal | null {
return message.type === 'result' &&
message.subtype === 'error_during_execution' &&
Array.isArray(message.errors) &&
message.errors.some(
(error) =>
typeof error === 'string' && error.startsWith('Resume rejected by --resume-drops-turn:')
)
? new AgentSessionRewindRefusal('provider-refused')
: null
}
import type { StructuredAgentSessionAcquireInput } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { ClaudeStructuredLaunch } from './claude-structured-launch-resolution'
import type { ClaudeStructuredSessionAdapterDeps } from './claude-structured-session-state'
type Intent = NonNullable<StructuredAgentSessionAcquireInput['rewind']>
/** The proof authorization exists only for this acquisition's first proof attempt. */
export class ClaudeRewindAttempt {
private refusal: AgentSessionRewindRefusal | null = null
constructor(
private intent: Intent | undefined,
private readonly onProved?: (leafUuid: string) => Promise<void>
) {}
observe(message: Record<string, unknown>): AgentSessionRewindRefusal | null {
if (!this.intent) {
return null
}
this.refusal ??= claudeRewindRefusalFromMessage(message)
return this.refusal
}
applyLaunch(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): void {
if (!this.intent) {
return
}
if (!launch.resumed || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('unsupported')
}
launch.options = {
...launch.options,
resume: launch.providerSessionId,
resumeSessionAt: this.intent.targetUuid,
...(this.intent.dropsTurn ? { resumeDropsTurn: this.intent.dropsTurn } : {})
}
launch.resumeLeafUuid = this.intent.targetUuid
}
async prove(
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
const intent = this.intent
this.clear()
if (this.refusal) {
throw this.refusal
}
if (!intent) {
return null
}
let leaf: string | null
try {
leaf = await deps.readTranscriptLeaf!({
providerSessionId: launch.providerSessionId,
previousLeafUuid: intent.previousLeafUuid,
intentionalRewindUuid: intent.targetUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== intent.targetUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
} catch (error) {
throw error instanceof AgentSessionRewindRefusal
? error
: new AgentSessionRewindRefusal('proof-mismatch')
}
// Persistence failure is an unknown outcome, never evidence that the provider refused.
await this.onProved?.(leaf)
return leaf
}
clear(): void {
this.intent = undefined
}
}
/** An interrupted, unproved rewind restores its original cursor without ancestor authorization. */
export async function proveClaudeRewindRecovery(
recovery: StructuredAgentSessionAcquireInput['rewindRecovery'],
launch: ClaudeStructuredLaunch,
deps: Pick<ClaudeStructuredSessionAdapterDeps, 'readTranscriptLeaf'>
): Promise<string | null> {
if (!recovery) {
return null
}
if (!launch.resumed || launch.resumeLeafUuid !== recovery.leafUuid || !deps.readTranscriptLeaf) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
const leaf = await deps.readTranscriptLeaf({
providerSessionId: launch.providerSessionId,
previousLeafUuid: recovery.leafUuid,
claudeConfigDir: launch.claudeConfigDir
})
if (leaf !== recovery.leafUuid) {
throw new AgentSessionRewindRefusal('proof-mismatch')
}
await recovery.onProved()
return leaf
}
@@ -1,3 +1,4 @@
import { ClaudeRewindAttempt, proveClaudeRewindRecovery } from './claude-structured-rewind'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionPreSpawnError
@@ -85,6 +86,7 @@ export async function acquireClaudeSession({
const initTimeoutMs = deps.initTimeoutMs ?? CLAUDE_STRUCTURED_INIT_TIMEOUT_MS
const initDeadline = createClaudeInitDeadline(sessionId, initTimeoutMs)
const rewind = new ClaudeRewindAttempt(input.rewind, input.rewind?.onProved)
const onMessage = (message: Record<string, unknown>): void => {
const init = readClaudeInit(message)
if (readClaudeFrameString(message, 'session_id') !== expectedProviderSessionId) {
@@ -95,6 +97,11 @@ export async function acquireClaudeSession({
}
return
}
const refusal = rewind.observe(message)
if (refusal) {
initDeadline.reject(refusal)
return
}
if (init) {
initDeadline.resolve(init)
// Every turn opens with an init frame naming the model the CLI is actually
@@ -178,6 +185,7 @@ export async function acquireClaudeSession({
? error
: new AgentSessionPreSpawnError(error)
})
rewind.applyLaunch(launch, deps)
expectedProviderSessionId = launch.providerSessionId
observedLeafUuid = launch.resumeLeafUuid
acquisitions.assertCurrent(sessionId, attempt)
@@ -241,6 +249,9 @@ export async function acquireClaudeSession({
diagnostic: claudeAuthDiagnostic(init, settings)
})
)
observedLeafUuid = (await rewind.prove(launch, deps)) ?? observedLeafUuid
observedLeafUuid =
(await proveClaudeRewindRecovery(input.rewindRecovery, launch, deps)) ?? observedLeafUuid
const process = await claudeProcessIdentity(
{ ...input, pid: connection.pid },
deps.readProcessStartTime
@@ -298,6 +309,7 @@ export async function acquireClaudeSession({
acquisitions.deleteIfCurrent(sessionId, attempt)
throw acquisitionError
} finally {
rewind.clear()
attempt.finish()
}
}
@@ -4,7 +4,6 @@ import type {
StructuredAgentSessionAcquireInput,
StructuredAgentSessionAdapter
} from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from '../native-chat/agent-session-wire/structured-agent-session-event-sink'
import {
answerClaudePrompt,
cancelClaudeTurn,
@@ -58,6 +57,9 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
supportsLocation = supportsClaudeStructuredLocation
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = () =>
this.deps.readTranscriptLeaf ? { supported: true } : { supported: false, reason: 'unsupported' }
acquire = (input: StructuredAgentSessionAcquireInput): Promise<AgentSessionAcquisition> =>
acquireClaudeSession({
input,
@@ -67,7 +69,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
exits: this.exits,
callbacks: {
deliver: (attempt, sessionId, event) => this.deliver(attempt, sessionId, event),
emit: (session, events, event) => this.emit(session, events, event),
emit: (session, _events, event) => this.emit(session, event),
handleExit: (sessionId, attempt, error) => this.handleExit(sessionId, attempt, error),
settleExit: (sessionId, exit) => this.settleUnexpectedExit(sessionId, exit)
}
@@ -155,7 +157,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
acquisitionGeneration: exit.session.acquisitionGeneration
}
try {
this.emit(exit.session, exit.session.events, ended)
this.emit(exit.session, ended)
} finally {
settleClaudeExitedSession(exit.session)
}
@@ -187,11 +189,7 @@ export class ClaudeStructuredSessionAdapter implements StructuredAgentSessionAda
})
}
private emit(
session: ClaudeSession | null,
_events: StructuredAgentSessionEventSink | undefined,
event: ClaudeStructuredSessionEvent
): void {
private emit(session: ClaudeSession | null, event: ClaudeStructuredSessionEvent): void {
const backgroundTasksChanged =
event.type === 'ended'
? (session?.backgroundTasks.clear() ?? false)
@@ -88,6 +88,7 @@ export type ClaudeStructuredSessionAdapterDeps = {
readTranscriptLeaf?: (input: {
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
/** Account-scoped Claude config root that owns this provider session. */
claudeConfigDir: string
}) => Promise<string | null>
@@ -13,7 +13,7 @@ type TranscriptNode = {
export type ClaudeTranscriptBranchProof = {
leafUuid: string
relation: 'initial' | 'same' | 'descendant'
relation: 'initial' | 'same' | 'descendant' | 'intentional-rewind'
}
function nonEmptyString(value: unknown): string | null {
@@ -83,6 +83,7 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
contents: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): ClaudeTranscriptBranchProof {
const nodes = new Map<string, TranscriptNode>()
let leafUuid: string | null = null
@@ -156,6 +157,21 @@ export function proveClaudeTranscriptBranchFromJsonl(input: {
throw transcriptError('marker precedes its leaf record')
}
const previousLeafUuid = input.previousLeafUuid
if (input.intentionalRewindUuid !== undefined) {
if (leafUuid !== input.intentionalRewindUuid || !input.previousLeafUuid) {
throw transcriptError('rewind target does not match the observed leaf')
}
proveMainLineAncestry(nodes, input.previousLeafUuid, input.providerSessionId)
proveAppendOrder(nodes)
let ancestor = nodes.get(input.previousLeafUuid)?.parentUuid ?? null
for (let depth = 0; ancestor !== null && depth < MAX_CLAUDE_TRANSCRIPT_ANCESTRY; depth += 1) {
if (ancestor === leafUuid) {
return { leafUuid, relation: 'intentional-rewind' }
}
ancestor = nodes.get(ancestor)?.parentUuid ?? null
}
throw transcriptError('rewind target is not an ancestor of the previous cursor')
}
if (!previousLeafUuid) {
proveMainLineAncestry(nodes, leafUuid, input.providerSessionId)
// A branch proof is based on an append-only snapshot. A child that appears
@@ -210,11 +226,13 @@ export async function proveClaudeTranscriptBranch(input: {
transcriptPath: string
providerSessionId: string
previousLeafUuid: string | null
intentionalRewindUuid?: string
}): Promise<ClaudeTranscriptBranchProof> {
return proveClaudeTranscriptBranchFromJsonl({
contents: await readFile(input.transcriptPath, 'utf8'),
providerSessionId: input.providerSessionId,
previousLeafUuid: input.previousLeafUuid
previousLeafUuid: input.previousLeafUuid,
intentionalRewindUuid: input.intentionalRewindUuid
})
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { proveClaudeTranscriptBranchFromJsonl } from './claude-transcript-branch-proof'
const row = (uuid: string, parentUuid: string | null, extra = {}) =>
JSON.stringify({ type: 'assistant', sessionId: 'provider', uuid, parentUuid, ...extra })
const marker = (leafUuid: string) =>
JSON.stringify({ type: 'last-prompt', sessionId: 'provider', leafUuid })
const graph = [row('root', null), row('kept', 'root'), row('old', 'kept')]
const prove = (rows: string[], leaf: string, intentionalRewindUuid?: string) =>
proveClaudeTranscriptBranchFromJsonl({
contents: `${[...rows, marker(leaf)].join('\n')}\n`,
providerSessionId: 'provider',
previousLeafUuid: 'old',
intentionalRewindUuid
})
describe('explicit Claude rewind ancestry', () => {
it('admits only the exact requested main-chain ancestor', () => {
expect(prove(graph, 'kept', 'kept')).toEqual({
leafUuid: 'kept',
relation: 'intentional-rewind'
})
expect(() => prove(graph, 'kept')).toThrow('sibling')
expect(() => prove(graph, 'kept', 'root')).toThrow('target')
expect(() => prove(graph, 'old', 'old')).toThrow('not an ancestor')
})
it('keeps sibling and sidechain rejection even with explicit intent', () => {
expect(() => prove([...graph, row('sibling', 'root')], 'sibling', 'sibling')).toThrow(
'not an ancestor'
)
expect(() =>
prove(
[row('root', null), row('kept', 'root', { isSidechain: true }), row('old', 'kept')],
'kept',
'kept'
)
).toThrow()
})
it('refuses missing, reordered, or cyclic ancestry', () => {
expect(() => prove(graph.slice(1), 'kept', 'kept')).toThrow('missing ancestor')
expect(() => prove([graph[1]!, graph[0]!, graph[2]!], 'kept', 'kept')).toThrow(
'parent row follows'
)
expect(() => prove([row('root', 'old'), ...graph.slice(1)], 'kept', 'kept')).toThrow('cycle')
})
})
@@ -0,0 +1,334 @@
import { describe, expect, it, vi } from 'vitest'
import { CodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
import { recoverCodexRewind, rewindCodexSession } from './codex-structured-rewind'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
import { openCodexThread } from './codex-structured-thread-open'
function fixture(reverted = true) {
const request = vi.fn(async (method: string): Promise<unknown> => {
if (method === 'thread/read') {
return { thread: { id: 'thread', historyMode: 'paginated', status: { type: 'idle' } } }
}
if (method === 'thread/revert') {
reverted = true
return {
thread: { id: 'thread', turns: [] },
turnsBackwardsCursor: 'turn-cursor',
itemsBackwardsCursor: 'item-cursor'
}
}
if (method === 'thread/turns/list') {
return { data: [...(reverted ? [] : [{ id: 'drop' }]), { id: 'kept' }], nextCursor: null }
}
return {
data: [
{
turnId: 'kept',
item: {
id: 'item-1',
type: 'userMessage',
content: [{ type: 'text', text: 'kept prompt' }]
}
}
],
nextCursor: null
}
})
const session = {
connection: { request },
threadId: 'thread',
fence: 2,
ended: false,
historyMode: 'paginated',
activeTurnIds: new Set()
} as unknown as CodexSession
return { request, session }
}
describe('Codex rewind', () => {
it('recovers verified history from fresh cursors without repeating revert', async () => {
const { session, request } = fixture()
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
ok: true,
items: [{ body: { kind: 'message', blocks: [{ type: 'text', text: 'kept prompt' }] } }]
})
expect(request.mock.calls.map(([method]) => method)).toEqual([
'thread/read',
'thread/turns/list',
'thread/items/list'
])
for (const method of ['thread/turns/list', 'thread/items/list']) {
expect(request).toHaveBeenCalledWith(
method,
expect.objectContaining({ cursor: null, sortDirection: 'desc' }),
expect.anything()
)
}
})
it('recognizes an unapplied rewind from the still-present target', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) =>
method === 'thread/turns/list'
? { data: [{ id: 'drop' }, { id: 'kept' }], nextCursor: null }
: original(method)
)
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'provider-refused'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it.each(['cycle', 'pages', 'entries', 'bytes'] as const)(
'bounds recovery by %s and never returns partial history',
async (limit) => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
let pages = 0
request.mockImplementation(async (method) => {
if (method !== 'thread/turns/list') {
return original(method)
}
pages++
if (limit === 'entries') {
return {
data: Array.from({ length: 1025 }, (_, i) => ({ id: String(i) })),
nextCursor: null
}
}
if (limit === 'bytes') {
return {
data: [],
padding: 'x'.repeat(AGENT_SESSION_HISTORY_MAX_PAGE_BYTES),
nextCursor: null
}
}
return { data: [], nextCursor: limit === 'cycle' ? 'repeated' : String(pages) }
})
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'history-limit'
)
expect(pages).toBeLessThanOrEqual(100)
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
}
)
it('keeps an interrupted recovery retryable with read-only requests', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/items/list') {
throw new Error('offline')
}
return original(method)
})
await expect(recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'offline'
)
request.mockImplementation(original)
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toMatchObject({
ok: true
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('refuses activity arriving during recovery hydration', async () => {
const { session, request } = fixture()
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/items/list') {
session.activeTurnIds!.add('racing-turn')
}
return original(method)
})
expect(await recoverCodexRewind(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
})
it('uses native revert and reads both retained indexes despite empty response turns', async () => {
const { session, request } = fixture(false)
const onPrepared = vi.fn<NonNullable<Parameters<typeof rewindCodexSession>[1]['onPrepared']>>(
async (items) => {
expect(items).toMatchObject([{ identity: { turnId: 'kept' } }])
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
}
)
expect(
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onPrepared })
).toMatchObject({
ok: true,
items: [{ body: { kind: 'message' } }]
})
expect(onPrepared).toHaveBeenCalledTimes(1)
expect(request).toHaveBeenCalledWith(
'thread/revert',
{ threadId: 'thread', beforeTurnId: 'drop' },
{ timeoutMs: undefined }
)
expect(request).toHaveBeenCalledWith(
'thread/turns/list',
expect.objectContaining({ cursor: 'turn-cursor', sortDirection: 'desc' }),
expect.anything()
)
expect(request).toHaveBeenCalledWith(
'thread/items/list',
expect.objectContaining({ cursor: 'item-cursor', sortDirection: 'desc' }),
expect.anything()
)
})
it('refuses a known legacy thread before making a request', async () => {
const { session, request } = fixture()
session.historyMode = 'legacy'
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'history-not-paginated'
})
expect(request).not.toHaveBeenCalled()
})
it('refuses history exceeding hydration capacity before mutating the provider', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
const turns = Array.from({ length: 600 }, (_, i) => String(i))
request.mockImplementation(async (method) => {
if (method === 'thread/turns/list') {
return { data: [{ id: 'drop' }, ...turns.map((id) => ({ id }))], nextCursor: null }
}
if (method === 'thread/items/list') {
return {
data: turns.map((turnId) => ({
turnId,
item: { id: turnId, type: 'userMessage', content: [{ type: 'text', text: 'x' }] }
})),
nextCursor: null
}
}
return original(method)
})
const onReverted = vi.fn()
expect(
await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop', onReverted })
).toEqual({ ok: false, reason: 'history-limit' })
expect(onReverted).not.toHaveBeenCalled()
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('refuses a missing target before mutation', async () => {
const { session, request } = fixture()
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'missing' })).toEqual({
ok: false,
reason: 'invalid-target'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('rechecks provider idleness after preflight hydration', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reads = 0
request.mockImplementation(async (method) => {
if (method === 'thread/read' && ++reads === 2) {
return { thread: { id: 'thread', status: { type: 'active' } } }
}
return original(method)
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
expect(request.mock.calls.some(([method]) => method === 'thread/revert')).toBe(false)
})
it('maps native legacy refusal without exposing provider text or falling back', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
request.mockImplementation(async (method) => {
if (method === 'thread/read') {
return { thread: { id: 'thread', status: { type: 'idle' } } }
}
if (method !== 'thread/revert') {
return original(method)
}
throw new CodexAppServerRequestError(
'thread/revert',
-32600,
'thread/revert only supports paginated threads'
)
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'history-not-paginated'
})
expect(request.mock.calls.map(([method]) => method)).toEqual([
'thread/read',
'thread/turns/list',
'thread/items/list',
'thread/read',
'thread/revert'
])
})
it('refuses activity arriving during the preflight await', async () => {
const { session, request } = fixture()
request.mockImplementationOnce(async () => {
session.activeTurnIds!.add('racing-turn')
return { thread: { id: 'thread', status: { type: 'idle' } } }
})
expect(await rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).toEqual({
ok: false,
reason: 'busy'
})
expect(request).toHaveBeenCalledTimes(1)
})
it('treats hydration failure after revert as unknown and never retries revert', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reverted = false
request.mockImplementation(async (method) => {
if (method === 'thread/revert') {
reverted = true
}
if (method === 'thread/items/list' && reverted) {
throw new Error('offline')
}
return original(method)
})
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'offline'
)
expect(request.mock.calls.filter(([method]) => method === 'thread/revert')).toHaveLength(1)
})
it('captures history mode at both start and resume without changing defaults', async () => {
for (const resumeThreadId of [null, 'thread']) {
const request = vi.fn(async (_method: string, _params?: unknown) => ({
thread: { id: 'thread', historyMode: 'legacy' }
}))
expect(
await openCodexThread({ request }, { cwd: '/workspace', resumeThreadId }, 10)
).toMatchObject({ historyMode: 'legacy' })
expect(request.mock.calls[0]?.[1]).not.toHaveProperty('historyMode')
}
})
it('rejects post-revert history missing an item within a retained turn', async () => {
const { session, request } = fixture(false)
const original = request.getMockImplementation()!
let reverted = false
request.mockImplementation(async (method) => {
if (method === 'thread/revert') {
reverted = true
}
if (method === 'thread/items/list' && !reverted) {
return {
data: [2, 1].map((i) => ({
turnId: 'kept',
item: {
id: `item-${i}`,
type: 'userMessage',
content: [{ type: 'text', text: `prompt ${i}` }]
}
})),
nextCursor: null
}
}
return original(method)
})
await expect(rewindCodexSession(session, { fence: 2, beforeTurnId: 'drop' })).rejects.toThrow(
'proof-mismatch'
)
})
})
+309
View File
@@ -0,0 +1,309 @@
import { readCodexThreadId, readCodexTurnId } from './codex-structured-thread-facts'
import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key'
import type { StructuredAgentSessionAdapter } from '../native-chat/agent-session-wire/structured-agent-session-adapter'
import { createCodexJournalTranslator } from './codex-structured-journal-translation'
import { CODEX_RESTORE_MAX_OPERATIONS } from './codex-structured-journal-translation-restore'
import type {
AgentJournalItemBody,
AgentJournalItemIdentity
} from '../../shared/agent-session-journal-types'
import { AGENT_SESSION_HISTORY_MAX_LIMIT } from '../../shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from '../native-chat/agent-session-wire/agent-session-history-page-bounds'
import { isCodexAppServerRequestError } from './codex-app-server-connection'
import type { CodexSession } from './codex-structured-session-state'
const MAX_PAGES = 100
const MAX_ENTRIES = CODEX_RESTORE_MAX_OPERATIONS
class CodexRewindTargetRetainedError extends Error {}
class CodexRewindTargetMissingError extends Error {}
function record(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('agent_session_rewind:invalid-provider-response')
}
return value as Record<string, unknown>
}
function cursor(value: unknown): string | null {
if (value === null || (typeof value === 'string' && value.length > 0)) {
return value
}
throw new Error('agent_session_rewind:invalid-provider-cursor')
}
/** Read both indexes to completion before accepting the retained history. */
export async function verifyCodexRevertedHistory(
session: Pick<CodexSession, 'connection' | 'threadId'>,
reply: Record<string, unknown>,
beforeTurnId: string,
timeoutMs?: number,
targetPresence: 'absent' | 'present' = 'absent'
): Promise<{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]> {
let bytes = 0
let entries = 0
const turns = new Map<string, { id: string; items: unknown[] }>()
for (const [method, firstCursor] of [
['thread/turns/list', cursor(reply.turnsBackwardsCursor)],
['thread/items/list', cursor(reply.itemsBackwardsCursor)]
] as const) {
let next = firstCursor
const seen = new Set<string>()
for (let page = 0; ; page += 1) {
if (page >= MAX_PAGES || (next !== null && seen.has(next))) {
throw new Error('agent_session_rewind:history-limit')
}
if (next !== null) {
seen.add(next)
}
const result = record(
await session.connection.request(
method,
{
threadId: session.threadId,
cursor: next,
sortDirection: 'desc',
limit: AGENT_SESSION_HISTORY_MAX_LIMIT
},
{ timeoutMs }
)
)
if (!Array.isArray(result.data)) {
throw new Error('agent_session_rewind:invalid-provider-page')
}
bytes += Buffer.byteLength(JSON.stringify(result), 'utf8')
entries += result.data.length
if (bytes > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES || entries > MAX_ENTRIES) {
throw new Error('agent_session_rewind:history-limit')
}
for (const raw of result.data) {
const item = record(raw)
const turnId = method === 'thread/turns/list' ? item.id : item.turnId
if (turnId === beforeTurnId && targetPresence === 'absent') {
throw new CodexRewindTargetRetainedError('agent_session_rewind:target-retained')
}
if (typeof turnId !== 'string' || !turnId) {
throw new Error('agent_session_rewind:invalid-retained-turn')
}
if (method === 'thread/turns/list') {
if (turns.has(turnId)) {
throw new Error('agent_session_rewind:duplicate-retained-turn')
}
turns.set(turnId, { id: turnId, items: [] })
} else {
const turn = turns.get(turnId)
if (!turn) {
throw new Error('agent_session_rewind:foreign-retained-item')
}
turn.items.push(record(item.item))
}
}
next = cursor(result.nextCursor)
if (next === null) {
break
}
}
}
if (targetPresence === 'present' && !turns.has(beforeTurnId)) {
throw new CodexRewindTargetMissingError('agent_session_rewind:target-missing')
}
const items = new Map<
string,
{ identity: AgentJournalItemIdentity; body: AgentJournalItemBody }
>()
const translator = createCodexJournalTranslator({
sink: {
appendItem: (identity, body) => {
items.set(agentJournalItemKey(identity), { identity, body })
},
appendTombstone: (identity) => {
items.delete(agentJournalItemKey(identity))
},
publish: () => {}
},
primaryThreadId: () => session.threadId
})
try {
const chronological = [...turns.values()].toReversed()
const retained =
targetPresence === 'present'
? chronological.slice(
0,
chronological.findIndex((turn) => turn.id === beforeTurnId)
)
: chronological
const admission = translator.restoreThread(session.threadId, {
turns: retained.map((turn) => ({ ...turn, items: turn.items.toReversed() }))
})
if (!admission.accepted) {
throw new Error('agent_session_rewind:history-unreadable')
}
return [...items.values()]
} finally {
translator.dispose()
}
}
async function preflightCodexRewind(
session: CodexSession,
fence: number,
timeoutMs?: number
): Promise<
{ ok: true } | { ok: false; reason: 'invalid-target' | 'history-not-paginated' | 'busy' }
> {
if (session.fence !== fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
if (session.historyMode === 'legacy') {
return { ok: false, reason: 'history-not-paginated' }
}
if (session.activeTurnIds?.size || session.dispatchPending) {
return { ok: false, reason: 'busy' }
}
const metadata = record(
await session.connection.request(
'thread/read',
{ threadId: session.threadId, includeTurns: false },
{ timeoutMs }
)
)
const thread = record(metadata.thread)
if (thread.id !== session.threadId) {
return { ok: false, reason: 'invalid-target' }
}
if (thread.historyMode === 'legacy') {
session.historyMode = 'legacy'
return { ok: false, reason: 'history-not-paginated' }
}
if (
record(thread.status).type !== 'idle' ||
session.activeTurnIds?.size ||
session.dispatchPending
) {
return { ok: false, reason: 'busy' }
}
if (session.fence !== fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
return { ok: true }
}
export async function recoverCodexRewind(
session: CodexSession,
input: { fence: number; beforeTurnId: string },
timeoutMs?: number
): ReturnType<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>> {
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!admission.ok) {
return admission
}
try {
const items = await verifyCodexRevertedHistory(
session,
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
input.beforeTurnId,
timeoutMs
)
if (session.fence !== input.fence || session.ended) {
return { ok: false, reason: 'invalid-target' }
}
if (session.activeTurnIds?.size || session.dispatchPending) {
return { ok: false, reason: 'busy' }
}
return { ok: true, items }
} catch (error) {
if (error instanceof CodexRewindTargetRetainedError) {
return { ok: false, reason: 'provider-refused' }
}
throw error
}
}
export async function rewindCodexSession(
session: CodexSession,
input: Omit<Parameters<NonNullable<StructuredAgentSessionAdapter['rewind']>>[0], 'sessionId'>,
timeoutMs?: number
): ReturnType<NonNullable<StructuredAgentSessionAdapter['rewind']>> {
const admission = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!admission.ok) {
return admission
}
let expectedItems: Set<string>
try {
const retained = await verifyCodexRevertedHistory(
session,
{ turnsBackwardsCursor: null, itemsBackwardsCursor: null },
input.beforeTurnId,
timeoutMs,
'present'
)
expectedItems = new Set(retained.map(({ identity }) => agentJournalItemKey(identity)))
await input.onPrepared?.(retained)
} catch (error) {
return {
ok: false,
reason:
error instanceof CodexRewindTargetMissingError
? 'invalid-target'
: error instanceof Error && error.message === 'agent_session_rewind:history-limit'
? 'history-limit'
: 'provider-refused'
}
}
const current = await preflightCodexRewind(session, input.fence, timeoutMs)
if (!current.ok) {
return current
}
let result: unknown
try {
result = await session.connection.request(
'thread/revert',
{
threadId: session.threadId,
beforeTurnId: input.beforeTurnId
},
{ timeoutMs }
)
} catch (error) {
if (isCodexAppServerRequestError(error)) {
if (error.message === 'thread/revert only supports paginated threads') {
session.historyMode = 'legacy'
return { ok: false, reason: 'history-not-paginated' }
}
if (error.code === -32601) {
return { ok: false, reason: 'unsupported' }
}
}
throw error
}
const reply = record(result)
if (record(reply.thread).id !== session.threadId) {
throw new Error('agent_session_rewind:foreign-thread')
}
await input.onReverted?.()
const items = await verifyCodexRevertedHistory(session, reply, input.beforeTurnId, timeoutMs)
if (
items.length !== expectedItems.size ||
items.some(({ identity }) => !expectedItems.has(agentJournalItemKey(identity)))
) {
throw new Error('agent_session_rewind:proof-mismatch')
}
return { ok: true, items }
}
export function observeCodexRewindActivity(
session: CodexSession,
method: string,
params: unknown
): void {
if ((readCodexThreadId(params) ?? session.threadId) !== session.threadId) {
return
}
const turnId = readCodexTurnId(params)
if (turnId && method === 'turn/started') {
session.activeTurnIds?.add(turnId)
}
if (turnId && method === 'turn/completed') {
session.activeTurnIds?.delete(turnId)
}
}
@@ -193,6 +193,8 @@ export async function acquireCodexStructuredSession(input: {
...codexSessionLifecycle(acquireInput.fence, acquired.acquisitionGeneration as string),
threadId: opened.threadId,
historyPath: opened.historyPath,
historyMode: opened.historyMode,
activeTurnIds: new Set(),
prompts: acquisition.prompts,
options: restoredCodexSessionOptions(acquireInput.options),
reportedOptions: reportedCodexThreadOptions(opened),
@@ -1,3 +1,4 @@
import * as codexRewind from './codex-structured-rewind'
import type {
AgentJournalMessageItem,
AgentSessionJournalIdentity
@@ -119,6 +120,7 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
method: string,
params: unknown
): CodexJournalTranslationAdmission {
codexRewind.observeCodexRewindActivity(session, method, params)
if (this.turnCancellation.handleNotification(sessionId, session, method, params)) {
return { accepted: true }
}
@@ -196,8 +198,13 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
fence: number
}): Promise<AgentSessionDispatchOutcome> {
const session = this.session(input.sessionId)
await this.turnCancellation.captureBaseline(session)
return dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
session.dispatchPending = true
try {
await this.turnCancellation.captureBaseline(session)
return await dispatchCodexTurn(session, input, this.deps.requestTimeoutMs)
} finally {
session.dispatchPending = false
}
}
async cancelTurn(input: {
@@ -210,6 +217,17 @@ export class CodexStructuredSessionAdapter implements StructuredAgentSessionAdap
return turnId ? this.turnCancellation.cancel(session, turnId) : { cancelled: false }
}
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
this.sessions.get(sessionId)?.historyMode === 'legacy'
? { supported: false, reason: 'history-not-paginated' }
: { supported: true }
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
codexRewind.rewindCodexSession(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
codexRewind.recoverCodexRewind(this.session(input.sessionId), input, this.deps.requestTimeoutMs)
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
const session = this.session(input.sessionId)
return this.compactions.run(
@@ -71,6 +71,9 @@ export type CodexSession = {
acquisitionGeneration: string
threadId: string
historyPath: string | null
historyMode?: 'legacy' | 'paginated'
activeTurnIds?: Set<string>
dispatchPending?: boolean
prompts: CodexAcquisitionWindow['prompts']
options: Map<string, string>
reportedOptions: { model?: string; effort?: string }
@@ -16,6 +16,7 @@ export type CodexOpenedThread = {
thread?: Record<string, unknown>
/** Rollout file Codex named, when it named one. */
historyPath: string | null
historyMode?: 'legacy' | 'paginated'
model?: string
effort?: string
}
@@ -92,6 +93,9 @@ export async function openCodexThread(
threadId,
thread,
historyPath: readCodexThreadPath(opened),
...(thread.historyMode === 'legacy' || thread.historyMode === 'paginated'
? { historyMode: thread.historyMode }
: {}),
...(model ? { model } : {}),
...(effort ? { effort } : {})
}
@@ -1,4 +1,5 @@
import { isDeepStrictEqual } from 'node:util'
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
AgentSessionPreSpawnError,
@@ -15,6 +16,7 @@ export async function acquireOwner(
input: AttachFlowInput,
record: AgentSessionRecord
): Promise<{ record: AgentSessionRecord; acquisitionGeneration: string | null }> {
const { store, rewind, now } = input
const fence = record.lease.runtimeFence
const spawnToken = record.lease.reservedSpawnToken
if (!spawnToken) {
@@ -36,6 +38,7 @@ export async function acquireOwner(
}
const acquired = await input.adapter.acquire({
identity: journalIdentityFor(record, input.params),
...claudeRewindAcquisitionProofs({ store, record, rewind, now }),
fence,
// Retries must recover the original reservation, not mint a second child.
spawnToken,
@@ -46,6 +46,20 @@ export class StructuredAgentSessionAdapterRouter implements StructuredAgentSessi
dispatch: StructuredAgentSessionAdapter['dispatch'] = (input) =>
this.owner(input.sessionId).dispatch(input)
rewindSupport: NonNullable<StructuredAgentSessionAdapter['rewindSupport']> = (sessionId) =>
this.owners.get(sessionId)?.rewindSupport?.(sessionId) ?? {
supported: false,
reason: 'unsupported'
}
rewind: NonNullable<StructuredAgentSessionAdapter['rewind']> = (input) =>
this.owner(input.sessionId).rewind?.(input) ??
Promise.resolve({ ok: false, reason: 'unsupported' })
recoverRewind: NonNullable<StructuredAgentSessionAdapter['recoverRewind']> = (input) =>
this.owner(input.sessionId).recoverRewind?.(input) ??
Promise.resolve({ ok: false, reason: 'unsupported' })
compact: NonNullable<StructuredAgentSessionAdapter['compact']> = (input) => {
const compact = this.owner(input.sessionId).compact
if (!compact) {
@@ -1,3 +1,7 @@
import type {
AgentSessionRewindReason,
AgentSessionRewindSupport
} from '../../../shared/agent-session-rewind'
// What the wire needs from a provider adapter.
//
// Phase 2 implements this over the Codex app-server and the Claude Agent SDK;
@@ -8,6 +12,7 @@
import type {
AgentJournalItemIdentity,
AgentJournalItemBody,
AgentJournalMessageItem,
AgentSessionJournalIdentity
} from '../../../shared/agent-session-journal-types'
@@ -34,6 +39,12 @@ export class AgentSessionAcquisitionRefusal extends Error {
}
}
export class AgentSessionRewindRefusal extends AgentSessionAcquisitionRefusal {
constructor(readonly rewindReason: AgentSessionRewindReason) {
super(`agent_session_rewind:${rewindReason}`)
}
}
/**
* The provider's own root process was observed to exit, but its descendant tree
* could not be verified. The lease keys on the root's pid and start time, so its
@@ -97,6 +108,14 @@ export type StructuredAgentSessionLifecycleEvent = {
export type StructuredAgentSessionAcquireInput = {
identity: AgentSessionJournalIdentity
rewind?: {
targetUuid: string
previousLeafUuid: string
dropsTurn?: string
onProved?: (leafUuid: string) => Promise<void>
}
/** Recovery restores an unproved rewind's original cursor with ordinary branch proof. */
rewindRecovery?: { leafUuid: string; onProved: () => Promise<void> }
fence: number
spawnToken: string
options?: Readonly<Record<string, string>>
@@ -131,6 +150,27 @@ export type StructuredAgentSessionAdapter = {
body: AgentJournalMessageItem
fence: number
}): Promise<AgentSessionDispatchOutcome>
rewindSupport?(sessionId: string): AgentSessionRewindSupport
recoverRewind?(input: {
sessionId: string
fence: number
beforeTurnId: string
}): Promise<
| { ok: true; items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
| { ok: false; reason: AgentSessionRewindReason }
>
rewind?(input: {
sessionId: string
fence: number
beforeTurnId: string
onPrepared?: (
items: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[]
) => Promise<void>
onReverted?: () => Promise<void>
}): Promise<
| { ok: true; items?: { identity: AgentJournalItemIdentity; body: AgentJournalItemBody }[] }
| { ok: false; reason: AgentSessionRewindReason }
>
compact?(input: {
turnId: string
sessionId: string
@@ -0,0 +1,51 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AttachFlowInput } from './structured-agent-session-attach-flow'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
rethrowAfterAgentSessionAcquisitionCleanup
} from './structured-agent-session-adapter'
export async function settlePostAcquisitionAttachFailure(
input: AttachFlowInput,
record: AgentSessionRecord,
cause: unknown
): Promise<never> {
let cleanupError: unknown = cause
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
try {
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
} catch (error) {
cleanupError = error
exitProof =
error instanceof AgentSessionAcquisitionExitUnprovenError
? 'unproven'
: error instanceof AgentSessionAcquisitionRootExitObservedError
? 'root-exit-observed'
: 'exit-proven'
}
// A failed close must not prevent durable failure settlement.
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
try {
await input.store.settleFailedPostAcquisitionAttachment({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
spawnToken: record.lease.reservedSpawnToken ?? '',
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: {
status: 'failed',
code: 'agent_session_operation_invalid',
message: cause instanceof Error ? cause.message : String(cause)
},
exitProof,
now: input.now()
})
} catch (settlementError) {
throw new AggregateError(
[cleanupError, settlementError],
'agent session post-acquisition attachment failure settlement failed'
)
}
throw cleanupError
}
@@ -1,9 +1,15 @@
// The attach transition end to end: reserve the lease, make the reservation
// real, open the journal.
//
// Split out of the host so the sequence reads in one place. The host still owns
// the decisions that must not be client-supplied — the spawn token, the claim
// key, the owner probe — and passes them in.
import { settlePostAcquisitionAttachFailure } from './structured-agent-session-attach-failure'
import { rewindRefusal } from './structured-rewind-refusal'
import {
AgentSessionRewindRefusal,
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionRefusal,
isAgentSessionPreSpawnError,
type StructuredAgentSessionAcquireInput,
type StructuredAgentSessionAdapter
} from './structured-agent-session-adapter'
// The host supplies owner authority; this flow reserves, proves, and publishes the session.
import type {
AgentSessionAttachResult,
@@ -21,15 +27,7 @@ import {
type AttachedJournal
} from './structured-agent-session-attach'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { adapterSupportsCreateIfDeclared } from './structured-agent-session-provider-support'
import {
AgentSessionAcquisitionExitUnprovenError,
AgentSessionAcquisitionRootExitObservedError,
AgentSessionAcquisitionRefusal,
isAgentSessionPreSpawnError,
rethrowAfterAgentSessionAcquisitionCleanup
} from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
import { readAgentSessionHydrationPage } from './agent-session-history-page'
@@ -40,6 +38,7 @@ import {
} from './structured-agent-session-adopted-import'
export type AttachFlowInput = {
rewind?: StructuredAgentSessionAcquireInput['rewind']
store: AgentSessionRecordStore
adapter: StructuredAgentSessionAdapter
journalRoot: string
@@ -47,22 +46,18 @@ export type AttachFlowInput = {
callerKey: string
params: AgentSessionAttachParams
now: () => number
/** Registers the opened journal and fans out to subscribers before the caller
* sees the result, so no client can send against a session the host has not
* finished publishing. */
/** Publishes the journal before clients can send against the new owner. */
onAttached: (
attached: AttachedJournal,
acquisitionGeneration: string | null
) => Promise<void> | void
/** Handed to the adapter so it can journal what the provider streams. The
* host owns it and binds it to the journal inside `onAttached`. */
/** Host-owned provider sink, bound to the journal inside `onAttached`. */
eventSink?: StructuredAgentSessionEventSink
/** Stops acquisition-window events targeting the superseded journal. */
onAcquiring?: () => Promise<void> | void
/** Settles writes already captured by the superseded journal before opening another. */
beforeJournalOpen?: () => Promise<void> | void
/** Removes any partial host publication after journal attachment fails, and
* closes the journal handle of the map entry it drops. Awaited: see eviction. */
/** Closes and removes partial publication after journal attachment fails. */
onAttachFailed?: () => Promise<void>
}
@@ -148,8 +143,7 @@ export async function performAttach(
} catch (error) {
const spawnToken = reservedRecord?.lease.reservedSpawnToken
if (reservedRecord && spawnToken && !unsupportedReservationSettlementAttempted) {
// A pre-spawn failure is its own processless proof; the settlement records the
// evidence and the failed operation in one durable transaction.
// Settle processless proof and failed operation atomically.
const exitProof = isAgentSessionPreSpawnError(error)
? 'processless'
: error instanceof AgentSessionAcquisitionExitUnprovenError
@@ -193,6 +187,9 @@ export async function performAttach(
)
}
}
if (error instanceof AgentSessionRewindRefusal) {
return rewindRefusal(error.rewindReason)
}
if (error instanceof AgentSessionAcquisitionRefusal) {
return { ok: false, refusal: { code: error.code, message: error.message } }
}
@@ -268,48 +265,3 @@ async function settleUnsupportedReservation(
throw new AggregateError([error], 'agent session unsupported reservation settlement failed')
}
}
async function settlePostAcquisitionAttachFailure(
input: AttachFlowInput,
record: AgentSessionRecord,
cause: unknown
): Promise<never> {
let cleanupError: unknown = cause
let exitProof: 'exit-proven' | 'root-exit-observed' | 'unproven' = 'unproven'
try {
await rethrowAfterAgentSessionAcquisitionCleanup(input.adapter, record.sessionId, cause)
} catch (error) {
cleanupError = error
exitProof =
error instanceof AgentSessionAcquisitionExitUnprovenError
? 'unproven'
: error instanceof AgentSessionAcquisitionRootExitObservedError
? 'root-exit-observed'
: 'exit-proven'
}
// Why: the close is awaited so the map entry is gone only once its handle is
// released, but a failed close must not also cost the store settlement below.
await Promise.resolve(input.onAttachFailed?.()).catch(() => undefined)
try {
await input.store.settleFailedPostAcquisitionAttachment({
sessionId: record.sessionId,
fence: record.lease.runtimeFence,
spawnToken: record.lease.reservedSpawnToken ?? '',
callerKey: input.callerKey,
operationId: input.params.envelope.clientOperationId,
outcome: {
status: 'failed',
code: 'agent_session_operation_invalid',
message: cause instanceof Error ? cause.message : String(cause)
},
exitProof,
now: input.now()
})
} catch (settlementError) {
throw new AggregateError(
[cleanupError, settlementError],
'agent session post-acquisition attachment failure settlement failed'
)
}
throw cleanupError
}
@@ -1,3 +1,5 @@
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
import { recoverStructuredRewind } from './structured-rewind-recovery'
import { recoverInterruptedCompaction } from './structured-compaction-recovery'
// The host's attach, lifted out of the host class.
//
@@ -29,7 +31,8 @@ export function attachStructuredAgentSession(
context: StructuredAgentSessionAttachContext,
callerKey: string,
params: AgentSessionAttachParams,
admitRecoveryTicket?: () => boolean
admitRecoveryTicket?: () => boolean,
rewind?: StructuredAgentSessionAcquireInput['rewind']
): Promise<AgentSessionMutationResult<AgentSessionAttachResult>> {
const sessionId = params.envelope.sessionId
const attaching = context.serialize(sessionId, async () => {
@@ -61,6 +64,7 @@ export function attachStructuredAgentSession(
}
const eventSink = context.runtimeState.eventSinkFor(sessionId)
const attached = await performAttach({
rewind,
store: context.deps.store,
adapter: context.deps.adapter,
journalRoot: context.deps.journalRoot,
@@ -124,6 +128,16 @@ export function attachStructuredAgentSession(
hasProviderChild: true,
acquisitionGeneration: acquisitionGeneration ?? previous?.acquisitionGeneration ?? null
})
if (!rewind) {
await recoverStructuredRewind(
context.deps.store,
sessionId,
attached.journal,
fence,
context.deps.adapter,
context.now
)
}
await recoverInterruptedCompaction(context.deps.store, sessionId, attached.journal, fence)
if (attached.recovery) {
context.subscribers.reset(sessionId, attached.journal, attached.recovery.reset, fence)
@@ -1,3 +1,4 @@
import { rewindRefusal } from './structured-rewind-refusal'
// Everything a client can ask an ALREADY-ATTACHED session to do: send a turn, cancel one, answer a
// prompt, change an option, read the options back.
//
@@ -75,6 +76,10 @@ export function sendStructuredAgentSessionTurn(
return mutate(context, caller, params.envelope, {
...plan,
run: (ctx) => {
const rewind = context.deps.store.getRecord(ctx.sessionId)?.rewind
if (rewind?.phase === 'prepared' || rewind?.phase === 'provider-succeeded') {
return Promise.resolve(rewindRefusal('outcome-unknown'))
}
const command = context.deps.store.getRecord(ctx.sessionId)?.conversationCommand
if (
command &&
@@ -153,6 +158,14 @@ export function readStructuredAgentSessionOptions(
const options = await context.deps.adapter.readOptions({ sessionId, fence: session.fence })
return {
...options,
rewind:
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'prepared' ||
context.deps.store.getRecord(sessionId)?.rewind?.phase === 'provider-succeeded'
? { supported: false, reason: 'outcome-unknown' }
: (context.deps.adapter.rewindSupport?.(sessionId) ?? {
supported: false,
reason: 'unsupported'
}),
conversationCommands: context.deps.adapter.compact ? ['clear', 'compact'] : ['clear']
}
})
@@ -1,3 +1,5 @@
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
import { rewindStructuredAgentSession } from './structured-agent-session-rewind'
import { StructuredConversationCommandController } from './structured-conversation-command-controller'
// Structured agent-session host: where the lease, journal, and provider adapter meet.
// Mutations share one durable admission path and serialize per session.
@@ -211,13 +213,11 @@ export class StructuredAgentSessionHost {
listSessionTabs = () => listStructuredAgentSessionTabs(this.sessions)
getPersistedVisibleSessionTabIndex(): { present: boolean; sessionIds: string[] } {
return this.deps.store.getVisibleSessionTabIndex()
}
getPersistedVisibleSessionTabIndex = (): { present: boolean; sessionIds: string[] } =>
this.deps.store.getVisibleSessionTabIndex()
setSessionTabVisibility(sessionId: string, visible: boolean): Promise<void> {
return this.deps.store.setSessionTabVisibility(sessionId, visible)
}
setSessionTabVisibility = (sessionId: string, visible: boolean): Promise<void> =>
this.deps.store.setSessionTabVisibility(sessionId, visible)
reconcileRestartLeases = async (): Promise<void> => {
const refusal = await this.reconcileLeases('startup')
@@ -233,8 +233,7 @@ export class StructuredAgentSessionHost {
revealSession = (sessionId: string): Promise<StructuredAgentSessionReveal> =>
this.restore.revealSession(sessionId)
private serialize = <T>(sessionId: string, task: () => Promise<T>): Promise<T> =>
this.tasks.serialize(sessionId, task)
private serialize = this.tasks.serialize.bind(this.tasks)
private restoreRenewedHandoff(sessionId: string): Promise<void> {
return this.serialize(sessionId, async () => {
@@ -307,6 +306,9 @@ export class StructuredAgentSessionHost {
readOptions = (sessionId: string): Promise<SessionWire.AgentSessionOptionsResult> =>
readStructuredAgentSessionOptions(this.mutationContext(), sessionId)
rewind = (caller: StructuredAgentSessionCaller, params: AgentSessionRewindParams) =>
rewindStructuredAgentSession(this.mutationContext(), this.attachContext(), caller, params)
conversationCommand = (...args: Parameters<StructuredConversationCommandController['run']>) =>
this.conversationCommands.run(...args)
conversationReplacements = () => this.conversationCommands.replacements()
@@ -26,7 +26,11 @@ export async function runSettledAgentSessionMutation<TValue>(input: {
status: 'succeeded',
sessionId: input.envelope.sessionId
})
: { status: 'failed', code: outcome.refusal.code }
: {
status: 'failed',
code: outcome.refusal.code,
...(outcome.refusal.rewindReason ? { rewindReason: outcome.refusal.rewindReason } : {})
}
)
return outcome
} catch (error) {
@@ -1,3 +1,4 @@
import { rewindRefusal } from './structured-rewind-refusal'
import type { AgentSessionOperationOutcome } from '../../../shared/agent-session-operation-ledger'
import {
AGENT_SESSION_WIRE_REFUSAL_CODES,
@@ -19,6 +20,9 @@ export function resolveAgentSessionReplayOutcome<TValue>(input: {
}): AgentSessionReplayOutcomeDecision<TValue> {
const { operationId, outcome } = input
if (outcome.status === 'failed') {
if (outcome.rewindReason) {
return { decision: 'refuse', refusal: rewindRefusal(outcome.rewindReason).refusal }
}
const code = (AGENT_SESSION_WIRE_REFUSAL_CODES as readonly string[]).includes(outcome.code)
? (outcome.code as AgentSessionWireRefusalCode)
: 'agent_session_operation_invalid'
@@ -0,0 +1,514 @@
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
agentJournalItemKey,
agentJournalSubmissionKey
} from '../../../shared/agent-session-journal-item-key'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { AgentSessionRewindRefusal } from './structured-agent-session-adapter'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type {
StructuredAgentSessionAdapter,
StructuredAgentSessionAcquireInput,
AgentSessionDispatchOutcome
} from './structured-agent-session-adapter'
import type { StructuredAgentSessionEventSink } from './structured-agent-session-event-sink'
import {
HOST_TEST_NOW,
HOST_TEST_SESSION,
HOST_TEST_THREAD,
hostTestAttachParams,
hostTestMessage,
hostTestOperationId,
resetHostTestOperationIds
} from './structured-agent-session-host-test-data'
const caller = { callerKey: 'desktop' }
let directory: string
let store: AgentSessionRecordStore
let host: StructuredAgentSessionHost
let sink: StructuredAgentSessionEventSink
let adapter: StructuredAgentSessionAdapter
let acquires: StructuredAgentSessionAcquireInput[]
const rewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['rewind']>>()
const recoverRewind = vi.fn<NonNullable<StructuredAgentSessionAdapter['recoverRewind']>>()
let failClaude = false
beforeEach(async () => {
resetHostTestOperationIds()
rewind.mockReset().mockResolvedValue({ ok: true })
recoverRewind.mockReset().mockResolvedValue({
ok: true,
items: [
{
identity: { provider: 'codex', threadId: HOST_TEST_THREAD, turnId: 'kept', ordinal: 0 },
body: hostTestMessage('verified history')
}
]
})
failClaude = false
acquires = []
directory = await mkdtemp(join(tmpdir(), 'orca-rewind-'))
store = await AgentSessionRecordStore.open({
directory: join(directory, 'store'),
hostId: 'local'
})
adapter = {
supportsCreate: (_location, agent) => agent === 'codex' || agent === 'claude',
supportsLocation: () => true,
acquire: async (input) => {
acquires.push(input)
if (input.rewind && failClaude) {
throw new AgentSessionRewindRefusal('provider-refused')
}
if (input.rewind) {
await input.rewind.onProved?.(input.rewind.targetUuid)
}
await input.rewindRecovery?.onProved()
sink = input.events!
const handle = input.identity.providerHandle
return {
process: {
hostId: 'local',
pid: 4000 + acquires.length,
processStartTimeMs: HOST_TEST_NOW,
spawnToken: input.spawnToken
},
acquisitionGeneration: `generation-${acquires.length}`,
link: {
linkId: `link-${acquires.length}`,
mintedAtFence: input.fence,
observedAt: HOST_TEST_NOW,
origin: acquires.length === 1 ? 'created' : 'resumed',
handle:
handle.kind === 'claude'
? {
provider: 'claude',
sessionId: handle.sessionId,
leafUuid: input.rewind?.targetUuid ?? 'tip'
}
: { provider: 'codex', threadId: HOST_TEST_THREAD }
}
}
},
dispatch: vi.fn(async (): Promise<AgentSessionDispatchOutcome> => ({
state: 'unknown',
reason: 'test'
})),
cancelTurn: async () => ({ cancelled: false }),
answerPrompt: async () => {},
setOption: async () => {},
rewindSupport: () => ({ supported: true }),
rewind,
recoverRewind,
releaseAcquisition: async () => true,
closeSession: async () => true
}
host = new StructuredAgentSessionHost({
store,
adapter,
journalRoot: directory,
claimKeyId: 'key',
now: () => HOST_TEST_NOW,
probeOwner: async () => ({ outcome: 'exit-observed' })
})
})
afterEach(async () => {
await host.flushAllStreamedEvents()
await rm(directory, { recursive: true, force: true })
})
async function seed(provider: 'codex' | 'claude' = 'codex', acceptedSubmissions = false) {
const params =
provider === 'codex'
? hostTestAttachParams(null)
: hostTestAttachParams(null, {
provider,
agent: provider,
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/claude' },
providerHandle: { kind: 'claude', sessionId: 'claude-session', leafUuid: 'tip' }
})
expect(await host.attach(caller, params)).toMatchObject({ ok: true })
const keys = ['kept', 'drop', 'tip'].map((uuid) =>
provider === 'codex'
? { provider, threadId: HOST_TEST_THREAD, turnId: uuid, ordinal: 0 }
: { provider, sessionId: 'claude-session', uuid }
)
let selectedItemId = agentJournalItemKey(keys[1]!)
for (const [i, identity] of keys.entries()) {
const body = {
...hostTestMessage(String(i)),
role: i === 2 ? ('assistant' as const) : ('user' as const)
}
if (acceptedSubmissions && i !== 2) {
const clientOperationId = hostTestOperationId()
vi.mocked(adapter.dispatch).mockResolvedValueOnce({
state: 'accepted',
providerIdentity: identity
})
expect(
await host.send(caller, {
body,
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId,
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: HOST_TEST_SESSION,
fields: { body }
})
}
})
).toMatchObject({ ok: true })
if (i === 1) {
selectedItemId = agentJournalSubmissionKey(clientOperationId)
}
} else {
sink.appendItem(identity, body)
}
}
await host.flushStreamedEvents(HOST_TEST_SESSION)
return selectedItemId
}
function params(
itemId: string,
expectedEpoch = host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch
) {
return {
itemId,
expectedEpoch,
envelope: {
sessionId: HOST_TEST_SESSION,
clientOperationId: hostTestOperationId(),
expectedRuntimeFence: store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.rewind',
sessionId: HOST_TEST_SESSION,
fields: { itemId, expectedEpoch }
})
}
}
}
describe('host rewind', () => {
it.each(['codex', 'claude'] as const)(
'resolves accepted %s user submissions to provider targets',
async (provider) => {
const target = await seed(provider, true)
expect(target.startsWith('orca:')).toBe(true)
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
if (provider === 'codex') {
expect(rewind).toHaveBeenCalledWith(expect.objectContaining({ beforeTurnId: 'drop' }))
} else {
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'kept', dropsTurn: 'drop' })
}
}
)
it('retains the preceding accepted Claude prompt when rewinding its assistant response', async () => {
await seed('claude', true)
const target = agentJournalItemKey({
provider: 'claude',
sessionId: 'claude-session',
uuid: 'tip'
})
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
expect(acquires[1]?.rewind).toMatchObject({ targetUuid: 'drop' })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
})
it('finishes a durable provider success on reattach without repeating the provider mutation', async () => {
const target = await seed()
const request = params(target)
const replace = vi
.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
.mockRejectedValueOnce(new Error('disk failed'))
await expect(host.rewind(caller, request)).rejects.toThrow('disk failed')
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('provider-succeeded')
replace.mockRestore()
const fence = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
expect(await host.attach(caller, hostTestAttachParams(fence))).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('retries complete hydration after native acknowledgement without committing partial history', async () => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockImplementation(async (input) => {
await input.onReverted?.()
throw new Error('history unavailable')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('history unavailable')
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind).toMatchObject({
phase: 'prepared',
providerApplied: true
})
recoverRewind.mockRejectedValueOnce(new Error('history still unavailable'))
await expect(
host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).rejects.toThrow('history still unavailable')
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(host.journalSnapshot(HOST_TEST_SESSION).items[0]?.body).toEqual(
hostTestMessage('verified history')
)
expect(recoverRewind).toHaveBeenCalledTimes(2)
expect(rewind).toHaveBeenCalledTimes(1)
})
it('fences stale owners and the second of two concurrent rewinds', async () => {
const target = await seed()
const stale = params(target)
stale.envelope.expectedRuntimeFence++
expect(await host.rewind(caller, stale)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_checkpoint_stale' }
})
let finish!: () => void
rewind.mockImplementation(
() =>
new Promise((resolve) => {
finish = () => resolve({ ok: true })
})
)
const first = host.rewind(caller, params(target))
const second = host.rewind(caller, params(target))
await vi.waitFor(() => expect(finish).toBeTypeOf('function'))
finish()
expect(await first).toMatchObject({ ok: true })
expect(await second).toMatchObject({ ok: false, refusal: { rewindReason: 'stale-epoch' } })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('replaces the epoch with the retained prefix and replays without another provider call', async () => {
const target = await seed()
const request = params(target)
const result = await host.rewind(caller, request)
expect(result).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(1)
expect(host.journalSnapshot(HOST_TEST_SESSION).cursor.epoch).not.toBe(request.expectedEpoch)
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('reacquires Claude at the retained cursor with the same session and a new lease fence', async () => {
const target = await seed('claude')
const before = store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
const emit = vi.fn()
const unsubscribe = host.subscribe({ id: 'after-rewind', sessionId: HOST_TEST_SESSION, emit })
emit.mockClear()
sink.appendItem(
{ provider: 'claude', sessionId: 'claude-session', uuid: 'next' },
hostTestMessage('next')
)
sink.publish()
await host.flushStreamedEvents(HOST_TEST_SESSION)
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'batch' }))
unsubscribe()
expect(acquires[1]?.rewind).toMatchObject({
targetUuid: 'kept',
previousLeafUuid: 'tip',
dropsTurn: 'drop'
})
expect(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence).toBeGreaterThan(before)
expect(store.getRecord(HOST_TEST_SESSION)!.lease.ownerProcess?.pid).toBe(4002)
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
})
it('recovers a Claude refusal with one plain resume and preserves the journal', async () => {
const target = await seed('claude')
failClaude = true
const before = host.journalSnapshot(HOST_TEST_SESSION)
expect(await host.rewind(caller, params(target))).toMatchObject({
ok: false,
refusal: { rewindReason: 'provider-refused' }
})
expect(acquires).toHaveLength(3)
expect(acquires[2]?.rewind).toBeUndefined()
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)!.lease.claimStatus).toBe('live')
})
it('refuses a rewind racing an active turn before provider execution', async () => {
const target = await seed()
sink.appendItem(
{ provider: 'orca', clientMessageId: 'active' },
{ kind: 'status', text: 'working', turnLifecycle: { turnId: 'active', state: 'running' } }
)
expect(await host.rewind(caller, params(target))).toMatchObject({
ok: false,
refusal: { rewindReason: 'busy' }
})
expect(rewind).not.toHaveBeenCalled()
})
it('refuses stale epochs and targets from another provider', async () => {
const target = await seed()
expect(await host.rewind(caller, params(target, 'old-epoch'))).toMatchObject({
ok: false,
refusal: { rewindReason: 'stale-epoch' }
})
expect(await host.rewind(caller, params('claude:foreign'))).toMatchObject({
ok: false,
refusal: { rewindReason: 'invalid-target' }
})
expect(rewind).not.toHaveBeenCalled()
})
it('keeps a failed hydration epoch intact and blocks sends and duplicate rewind', async () => {
const target = await seed()
const request = params(target)
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockRejectedValue(new Error('hydration failed'))
await expect(host.rewind(caller, request)).rejects.toThrow('hydration failed')
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(await host.rewind(caller, request)).toMatchObject({
ok: false,
refusal: { code: 'agent_session_operation_unknown' }
})
const body = hostTestMessage('new prompt')
const envelope = {
...params(target).envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.send',
sessionId: HOST_TEST_SESSION,
fields: { body }
})
}
expect(await host.send(caller, { envelope, body })).toMatchObject({
ok: false,
refusal: { rewindReason: 'outcome-unknown' }
})
expect(adapter.dispatch).not.toHaveBeenCalled()
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
expect(rewind).toHaveBeenCalledTimes(1)
})
it('clears an unapplied prepared rewind after observing the target still present', async () => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
rewind.mockRejectedValueOnce(new Error('read failed before revert'))
await expect(host.rewind(caller, params(target))).rejects.toThrow('read failed')
recoverRewind.mockResolvedValueOnce({ ok: false, reason: 'provider-refused' })
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(before)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('refused')
expect(await host.rewind(caller, params(target))).toMatchObject({ ok: true })
})
it('recovers against the complete provider preflight when the local journal omitted an older turn', async () => {
const target = await seed()
const items = ['older', 'kept'].map((turnId) => ({
identity: { provider: 'codex' as const, threadId: HOST_TEST_THREAD, turnId, ordinal: 0 },
body: hostTestMessage(turnId)
}))
rewind.mockImplementationOnce(async (input) => {
await input.onPrepared?.(items)
await input.onReverted?.()
throw new Error('lost after revert')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('lost after revert')
recoverRewind.mockResolvedValueOnce({ ok: true, items })
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION).items).toHaveLength(2)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('completed')
})
it.each(['turn', 'item'] as const)(
'never commits a recovered prefix that omits an expected retained %s',
async (missing) => {
const target = await seed()
const before = host.journalSnapshot(HOST_TEST_SESSION)
const items = [0, 1].map((ordinal) => ({
identity: {
provider: 'codex' as const,
threadId: HOST_TEST_THREAD,
turnId: 'kept',
ordinal
},
body: hostTestMessage(String(ordinal))
}))
rewind.mockImplementationOnce(async (input) => {
await input.onPrepared?.(items)
throw new Error('reply lost')
})
await expect(host.rewind(caller, params(target))).rejects.toThrow('reply lost')
recoverRewind.mockResolvedValueOnce({
ok: true,
items: missing === 'turn' ? [] : items.slice(0, 1)
})
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
await expect(
host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).rejects.toThrow('proof-mismatch')
expect(replace).not.toHaveBeenCalled()
replace.mockRestore()
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.expectedEpoch).toBe(before.cursor.epoch)
expect(store.getRecord(HOST_TEST_SESSION)?.rewind?.phase).toBe('prepared')
}
)
it('settles the existing epoch after a crash between journal commit and record completion', async () => {
const target = await seed()
const request = params(target)
const transition = store.transitionHandoff.bind(store)
const checkpoint = vi
.spyOn(store, 'transitionHandoff')
.mockImplementation((sessionId, update) =>
transition(sessionId, (record) => {
const next = update(record)
if (next.rewind?.phase === 'completed') {
throw new Error('completion write failed')
}
return next
})
)
await expect(host.rewind(caller, request)).rejects.toThrow('completion write failed')
const committed = host.journalSnapshot(HOST_TEST_SESSION)
expect(committed.cursor.epoch).not.toBe(request.expectedEpoch)
checkpoint.mockRestore()
const replace = vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems')
expect(
await host.attach(
caller,
hostTestAttachParams(store.getRecord(HOST_TEST_SESSION)!.lease.runtimeFence)
)
).toMatchObject({ ok: true })
expect(host.journalSnapshot(HOST_TEST_SESSION)).toEqual(committed)
expect(replace).not.toHaveBeenCalled()
replace.mockRestore()
expect(await host.rewind(caller, request)).toMatchObject({ ok: true, replayed: true })
})
})
@@ -0,0 +1,252 @@
import {
agentJournalItemKey,
agentJournalSubmissionKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionRewindParams,
AgentSessionRewindRecord,
AgentSessionRewindResult
} from '../../../shared/agent-session-rewind'
import type { AgentSessionMutationResult } from '../../../shared/agent-session-wire'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
import type { StructuredAgentSessionMutationContext } from './structured-agent-session-host-mutations'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import type { StructuredAgentSessionCaller } from './structured-agent-session-host-types'
import { admitAndRunAgentSessionMutation } from './structured-agent-session-mutation-admission'
import { conversationCommandBlocked } from './structured-conversation-command-admission'
import { rewindRefusal } from './structured-rewind-refusal'
import { persistRewindRecord, recoverStructuredRewind } from './structured-rewind-recovery'
import { replaceClaudeRewindOwner } from './structured-rewind-claude-owner'
export async function rewindStructuredAgentSession(
context: StructuredAgentSessionMutationContext,
attachContext: StructuredAgentSessionAttachContext,
caller: StructuredAgentSessionCaller,
params: AgentSessionRewindParams
): Promise<AgentSessionMutationResult<AgentSessionRewindResult>> {
const { sessionId, clientOperationId } = params.envelope
const store = context.deps.store
return context.serialize(sessionId, async () => {
const result = await admitAndRunAgentSessionMutation<AgentSessionRewindResult>({
store,
adapter: context.deps.adapter,
callerKey: caller.callerKey,
envelope: params.envelope,
journal: context.sessions.get(sessionId)?.journal,
publish: (journal) => context.publish(sessionId, journal),
now: context.now,
plan: {
method: 'agentSession.rewind',
fields: { itemId: params.itemId, expectedEpoch: params.expectedEpoch },
recoverUnknownFromDurableState: true,
settledOutcome: (rewind) => ({ status: 'succeeded', sessionId, rewind }),
replay: (_ctx, outcome) => {
if (outcome.status === 'succeeded' && outcome.rewind) {
return outcome.rewind
}
const prior = store.getRecord(sessionId)?.rewind
return prior?.operationId === clientOperationId &&
prior.callerKey === caller.callerKey &&
prior.phase === 'completed' &&
prior.epoch
? { itemId: prior.itemId, epoch: prior.epoch }
: null
},
run: async (ctx) => {
await attachContext.runtimeState.flushEventSink(sessionId)
const record = store.getRecord(sessionId)!
const support = ctx.adapter.rewindSupport?.(sessionId)
if (!support?.supported) {
return rewindRefusal(support?.reason ?? 'unsupported')
}
if (
record.rewind?.phase === 'prepared' ||
record.rewind?.phase === 'provider-succeeded'
) {
return rewindRefusal('outcome-unknown')
}
if (conversationCommandBlocked(ctx, record)) {
return rewindRefusal('busy')
}
if (ctx.journal.isReadOnly) {
return rewindRefusal('unsupported')
}
const snapshot = ctx.journal.snapshot()
const providerKeys = new Map(
snapshot.submissions.flatMap((submission) =>
submission.dispatchState === 'accepted' && submission.providerItemId
? [
[
agentJournalSubmissionKey(submission.clientMessageId),
submission.providerItemId
] as const
]
: []
)
)
const providerKey = (itemId: string) => providerKeys.get(itemId) ?? itemId
if (ctx.journal.cursor().epoch !== params.expectedEpoch) {
return rewindRefusal('stale-epoch')
}
const selected = snapshot.items.findIndex((item) => item.itemId === params.itemId)
const key = selected === -1 ? null : parseAgentJournalItemKey(providerKey(params.itemId))
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
if (!key || !head || key.provider !== head.provider) {
return rewindRefusal('invalid-target')
}
let boundary = selected
let claude: Parameters<typeof replaceClaudeRewindOwner>[3] | undefined
if (key.provider === 'codex' && head.provider === 'codex') {
if (key.threadId !== head.threadId) {
return rewindRefusal('invalid-target')
}
boundary = snapshot.items.findIndex((item) => {
const identity = parseAgentJournalItemKey(providerKey(item.itemId))
return (
(identity?.provider === 'codex' &&
identity.threadId === key.threadId &&
identity.turnId === key.turnId) ||
(item.body.kind === 'status' && item.body.turnLifecycle?.turnId === key.turnId)
)
})
} else if (key.provider === 'claude' && head.provider === 'claude') {
if (key.sessionId !== head.sessionId) {
return rewindRefusal('invalid-target')
}
const previous = snapshot.items
.slice(0, boundary)
.map((item) => parseAgentJournalItemKey(providerKey(item.itemId)))
.findLast(
(identity) =>
identity?.provider === 'claude' && identity.sessionId === key.sessionId
)
if (previous?.provider !== 'claude') {
return rewindRefusal('invalid-target')
}
const prompts = snapshot.items
.slice(boundary)
.filter((item) => item.body.kind === 'message' && item.body.role === 'user')
const prompt =
prompts.length === 1
? parseAgentJournalItemKey(providerKey(prompts[0]!.itemId))
: null
claude = {
targetUuid: previous.uuid,
previousLeafUuid: head.leafUuid ?? '',
...(prompt?.provider === 'claude' ? { dropsTurn: prompt.uuid } : {})
}
} else {
return rewindRefusal('invalid-target')
}
const retained = snapshot.items
.slice(0, boundary)
.map(({ itemId, body, observedAt }) => ({
itemId: providerKey(itemId),
body,
observedAt
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
return rewindRefusal('history-limit')
}
let prepared: AgentSessionRewindRecord = {
operationId: clientOperationId,
callerKey: caller.callerKey,
itemId: params.itemId,
providerItemId: providerKey(params.itemId),
expectedEpoch: params.expectedEpoch,
phase: 'prepared',
retained
}
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
ctx.publish()
const provider = claude
? await replaceClaudeRewindOwner(attachContext, caller.callerKey, params, claude)
: await ctx.adapter.rewind!({
sessionId,
fence: ctx.fence,
beforeTurnId: key.provider === 'codex' ? key.turnId : '',
onPrepared: async (items) => {
const retained = items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: ctx.now()
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
prepared = { ...prepared, retained }
await persistRewindRecord(store, sessionId, ctx.fence, prepared)
},
onReverted: async () => {
await persistRewindRecord(store, sessionId, ctx.fence, {
...prepared,
providerApplied: true
})
}
})
const fence = store.getRecord(sessionId)!.lease.runtimeFence
if (!provider.ok) {
const reason =
'reason' in provider
? provider.reason
: (provider.refusal.rewindReason ?? 'outcome-unknown')
if (reason !== 'outcome-unknown') {
await persistRewindRecord(store, sessionId, fence, {
...prepared,
phase: 'refused',
reason,
retained: []
})
const currentJournal = context.sessions.get(sessionId)?.journal
if (currentJournal) {
context.publish(sessionId, currentJournal)
}
}
return rewindRefusal(reason)
}
const confirmed = provider.items
? provider.items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: ctx.now()
}))
: prepared.retained
if (
Buffer.byteLength(JSON.stringify(confirmed), 'utf8') >
AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
await persistRewindRecord(store, sessionId, fence, {
...prepared,
retained: confirmed,
phase: 'provider-succeeded',
hydrationVerified: true
})
const journal = context.sessions.get(sessionId)!.journal
await attachContext.runtimeState.flushEventSink(sessionId)
await recoverStructuredRewind(store, sessionId, journal, fence)
context.publish(sessionId, journal)
return { ok: true, value: { itemId: params.itemId, epoch: journal.cursor().epoch } }
}
}
})
return result.ok
? {
...result,
fence: store.getRecord(sessionId)!.lease.runtimeFence,
cursor: context.sessions.get(sessionId)!.journal.cursor()
}
: result
})
}
@@ -46,6 +46,7 @@ function summariesEqual(a: AgentSessionStatusSummary, b: AgentSessionStatusSumma
a.workspaceId === b.workspaceId &&
a.agent === b.agent &&
a.status === b.status &&
a.rewindBlockedReason === b.rewindBlockedReason &&
// Settled activity changes ranking; streaming active turns must stay quiet.
(a.status !== 'idle' || a.updatedAt === b.updatedAt) &&
a.latestPrompt === b.latestPrompt &&
@@ -126,6 +127,9 @@ export class StructuredAgentSessionStatusFeed {
workspaceId: session.params.location.workspaceId,
agent: session.params.provider,
...projectStructuredAgentSessionStatusSummary(items),
...(record?.rewind?.phase === 'prepared' || record?.rewind?.phase === 'provider-succeeded'
? { rewindBlockedReason: 'outcome-unknown' as const }
: {}),
...(model ? { model } : {}),
...(providerSession ? { providerSession } : {}),
updatedAt: journal.lastActivityAt() || this.deps.now()
@@ -7,6 +7,9 @@ export function conversationCommandBlocked(
record: AgentSessionRecord
): string | null {
const items = ctx.journal.snapshot().items
if (record.rewind?.phase === 'prepared' || record.rewind?.phase === 'provider-succeeded') {
return 'agent_session_rewind:outcome-unknown'
}
if (
record.conversationCommand?.command === 'clear' &&
record.conversationCommand.phase === 'committed' &&
@@ -0,0 +1,77 @@
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import { createHash } from 'node:crypto'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import type { AgentSessionRewindParams } from '../../../shared/agent-session-rewind'
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
import { attachFingerprintFields } from './structured-agent-session-attach'
import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context'
import { attachStructuredAgentSession } from './structured-agent-session-attach-orchestration'
import { rewindRefusal } from './structured-rewind-refusal'
/** Runs within the rewind's session queue; acquisition still uses the normal reservation CAS. */
export async function replaceClaudeRewindOwner(
context: StructuredAgentSessionAttachContext,
callerKey: string,
params: AgentSessionRewindParams,
rewind: NonNullable<StructuredAgentSessionAcquireInput['rewind']>
): Promise<{ ok: true; items?: never } | ReturnType<typeof rewindRefusal>> {
const sessionId = params.envelope.sessionId
const session = context.sessions.get(sessionId)!
if (!(await context.deps.adapter.closeSession?.(sessionId))) {
return rewindRefusal('outcome-unknown')
}
session.hasProviderChild = false
const head = agentSessionProviderHandleChainHead(
context.deps.store.getRecord(sessionId)!.providerHandleChain
)?.handle
if (head?.provider !== 'claude' || !head.leafUuid) {
return rewindRefusal('invalid-target')
}
rewind = { ...rewind, previousLeafUuid: head.leafUuid }
const attach = async (intent: typeof rewind | undefined, stage: string) => {
const current = context.deps.store.getRecord(sessionId)!
const operationId = `${params.envelope.clientOperationId.split('-')[0]}-${createHash('sha256')
.update(JSON.stringify([callerKey, params.envelope.clientOperationId, stage]))
.digest('hex')
.slice(0, 32)}`
const attachParams = {
...session.params,
envelope: {
sessionId,
clientOperationId: operationId,
expectedRuntimeFence: current.lease.runtimeFence,
payloadFingerprint: ''
}
}
attachParams.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId,
fields: attachFingerprintFields(attachParams)
})
return attachStructuredAgentSession(
{
...context,
serialize: (_id, run) => run()
},
callerKey,
attachParams,
undefined,
intent
)
}
const result = await attach(rewind, 'rewind')
if (result.ok) {
return { ok: true } as const
}
if (
result.refusal.rewindReason === 'provider-refused' ||
result.refusal.rewindReason === 'proof-mismatch'
) {
const recovered = await attach(undefined, 'resume')
if (!recovered.ok) {
return rewindRefusal('outcome-unknown')
}
return rewindRefusal(result.refusal.rewindReason)
}
return rewindRefusal(result.refusal.rewindReason ?? 'outcome-unknown')
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { agentSessionRecordFixture } from '../../../shared/agent-session-record.test-fixture'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { claudeRewindAcquisitionProofs } from './structured-rewind-claude-proof'
function setup() {
let current = agentSessionRecordFixture()
current.providerHandleChain = current.providerHandleChain.map((link) => ({
...link,
handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: 'tip' }
}))
current.rewind = {
operationId: 'rewind-operation',
callerKey: 'desktop',
itemId: 'selected',
expectedEpoch: 'old-epoch',
phase: 'prepared',
retained: []
}
const store: Pick<AgentSessionRecordStore, 'transitionHandoff'> = {
transitionHandoff: async (_sessionId, transition) => {
current = transition(current)
return current
}
}
return {
store,
record: () => current,
setFence: () => {
current = { ...current, lease: { ...current.lease, runtimeFence: 8 } }
}
}
}
describe('Claude rewind durable proof checkpoints', () => {
it('atomically checkpoints the exact target and resumable head before owner publication', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
})
await expect(proofs.rewind!.onProved!('wrong')).rejects.toThrow('proof-mismatch')
expect(state.record().rewind?.phase).toBe('prepared')
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
await proofs.rewind!.onProved!('kept')
expect(state.record().rewind).toMatchObject({
phase: 'provider-succeeded',
hydrationVerified: true
})
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'kept' })
expect(
claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_001,
rewind: undefined
})
).toEqual({})
})
it('restores prepared recovery through ordinary proof without carrying rewind authorization', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: undefined
})
expect(proofs.rewind).toBeUndefined()
expect(proofs.rewindRecovery?.leafUuid).toBe('tip')
expect(state.record().rewind?.phase).toBe('prepared')
await proofs.rewindRecovery!.onProved()
expect(state.record().rewind).toMatchObject({ phase: 'refused', retained: [] })
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
})
it('refuses a proof checkpoint from a superseded acquisition', async () => {
const state = setup()
const proofs = claudeRewindAcquisitionProofs({
store: state.store,
record: state.record(),
now: () => 3_000,
rewind: { previousLeafUuid: 'tip', targetUuid: 'kept' }
})
state.setFence()
await expect(proofs.rewind!.onProved!('kept')).rejects.toThrow('checkpoint_stale')
expect(state.record().rewind?.phase).toBe('prepared')
expect(state.record().providerHandleChain.at(-1)?.handle).toMatchObject({ leafUuid: 'tip' })
})
})
@@ -0,0 +1,69 @@
import { agentSessionProviderHandleChainHead } from '../../../shared/agent-session-provider-handle'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity'
import { recordAgentSessionProviderHandle } from '../../runtime/agent-session-provider-handle-transition'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAcquireInput } from './structured-agent-session-adapter'
/** Proof checkpoints survive failures later in acquisition, before an owner can be published. */
export function claudeRewindAcquisitionProofs(input: {
store: Pick<AgentSessionRecordStore, 'transitionHandoff'>
record: AgentSessionRecord
rewind: StructuredAgentSessionAcquireInput['rewind']
now: () => number
}): Pick<StructuredAgentSessionAcquireInput, 'rewind' | 'rewindRecovery'> {
const { record, store } = input
const pending = record.rewind
const head = agentSessionProviderHandleChainHead(record.providerHandleChain)?.handle
if (
record.provider !== 'claude' ||
pending?.phase !== 'prepared' ||
head?.provider !== 'claude'
) {
return input.rewind ? { rewind: input.rewind } : {}
}
const checkpoint = async (leafUuid?: string): Promise<void> => {
await store.transitionHandoff(record.sessionId, (current) => {
if (
current.lease.runtimeFence !== record.lease.runtimeFence ||
current.rewind?.operationId !== pending.operationId ||
current.rewind.callerKey !== pending.callerKey ||
current.rewind.phase !== 'prepared'
) {
throw new Error('agent_session_checkpoint_stale')
}
if (leafUuid === undefined) {
return {
...current,
rewind: { ...pending, phase: 'refused', reason: 'outcome-unknown', retained: [] }
}
}
if (leafUuid !== input.rewind?.targetUuid) {
throw new Error('agent_session_rewind:proof-mismatch')
}
const observedAt = input.now()
return {
...recordAgentSessionProviderHandle({
record: current,
fence: record.lease.runtimeFence,
link: claudeProviderHandleLink({
sessionId: head.sessionId,
leafUuid,
resumed: true,
fence: record.lease.runtimeFence,
observedAt
}),
now: observedAt
}),
rewind: { ...pending, phase: 'provider-succeeded', hydrationVerified: true }
}
})
}
if (input.rewind) {
return { rewind: { ...input.rewind, onProved: checkpoint } }
}
if (!head.leafUuid) {
throw new Error('agent_session_rewind:invalid-target')
}
return { rewindRecovery: { leafUuid: head.leafUuid, onProved: () => checkpoint() } }
}
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import { AgentSessionRewindRecordSchema } from '../../../shared/agent-session-rewind'
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
describe('rewind recovery of newer durable records', () => {
it('keeps an unknown message role and block readable without discarding the row', () => {
expect(
restoreRewindJournalBody({
kind: 'message',
role: 'future-role',
blocks: [{ type: 'future-block' }]
})
).toEqual({
kind: 'message',
role: 'system',
blocks: [{ type: 'text', text: '{"type":"future-block"}' }]
})
})
it('preserves unknown state as evidence rather than inventing success or pending work', () => {
const body = {
kind: 'tool-call' as const,
name: 'future-tool',
input: { path: 'file' },
state: 'paused-by-provider'
}
expect(restoreRewindJournalBody(body)).toEqual({ kind: 'status', text: JSON.stringify(body) })
const status = {
kind: 'status' as const,
text: 'state',
turnLifecycle: { turnId: 'turn', state: 'future-state' }
}
expect(restoreRewindJournalBody(status)).toEqual({
kind: 'status',
text: JSON.stringify(status)
})
})
it('does not reject a saved recovery prefix over a newer refusal reason', () => {
expect(
AgentSessionRewindRecordSchema.safeParse({
operationId: 'operation',
callerKey: 'caller',
itemId: 'selected',
expectedEpoch: 'old',
phase: 'provider-succeeded',
reason: 'future-reason',
retained: []
}).success
).toBe(true)
})
})
@@ -0,0 +1,61 @@
import { isAdmissibleAgentJournalItemBody } from '../../../shared/agent-session-journal-schemas'
import type { AgentJournalItemBody } from '../../../shared/agent-session-journal-types'
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
import { NATIVE_CHAT_ROLES } from '../../../shared/native-chat-types'
type StoredBody = AgentSessionRewindRecord['retained'][number]['body']
/** Unknown future values remain visible evidence, never invented turn or prompt state. */
export function restoreRewindJournalBody(body: StoredBody): AgentJournalItemBody {
let normalized: unknown = body
const fallback = () => ({ kind: 'status', text: JSON.stringify(body) })
if (body.kind === 'message') {
normalized = {
...body,
role: NATIVE_CHAT_ROLES.find((role) => role === body.role) ?? 'system',
blocks: body.blocks.map((block) => {
if (
(block.type === 'text' && 'text' in block) ||
(block.type === 'tool-call' && 'name' in block && !('state' in block)) ||
(block.type === 'tool-result' && 'output' in block) ||
block.type === 'image-ref'
) {
return block
}
if (
block.type === 'tool-call' &&
'state' in block &&
(block.state === 'running' || block.state === 'completed' || block.state === 'failed')
) {
return block
}
return { type: 'text', text: JSON.stringify(block) }
})
}
} else if (
body.kind === 'tool-call' &&
body.state !== 'running' &&
body.state !== 'completed' &&
body.state !== 'failed'
) {
normalized = fallback()
} else if (
(body.kind === 'approval' || body.kind === 'question') &&
body.resolution.state !== 'pending' &&
body.resolution.state !== 'resolved' &&
body.resolution.state !== 'cancelled'
) {
normalized = fallback()
} else if (
body.kind === 'status' &&
body.turnLifecycle &&
body.turnLifecycle.state !== 'running' &&
body.turnLifecycle.state !== 'completed'
) {
normalized = fallback()
}
if (!isAdmissibleAgentJournalItemBody(normalized)) {
throw new Error('agent_session_rewind:invalid-retained-body')
}
return normalized
}
@@ -0,0 +1,132 @@
import { restoreRewindJournalBody } from './structured-rewind-journal-body'
import { isDeepStrictEqual } from 'node:util'
import {
agentJournalItemKey,
parseAgentJournalItemKey
} from '../../../shared/agent-session-journal-item-key'
import type { AgentSessionRewindRecord } from '../../../shared/agent-session-rewind'
import type { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { AGENT_SESSION_HISTORY_MAX_PAGE_BYTES } from './agent-session-history-page-bounds'
export function persistRewindRecord(
store: AgentSessionRecordStore,
sessionId: string,
fence: number,
rewind: AgentSessionRewindRecord
): Promise<unknown> {
return store.transitionHandoff(sessionId, (record) => {
if (record.lease.runtimeFence !== fence) {
throw new Error('agent_session_checkpoint_stale')
}
return { ...record, rewind }
})
}
/** Recovery observes provider state; it never repeats an ambiguous native mutation. */
export async function recoverStructuredRewind(
store: AgentSessionRecordStore,
sessionId: string,
journal: AgentSessionJournal,
fence: number,
adapter?: StructuredAgentSessionAdapter,
now: () => number = Date.now
): Promise<void> {
let rewind = store.getRecord(sessionId)?.rewind
if (rewind?.phase !== 'provider-succeeded' && rewind?.phase !== 'prepared') {
return
}
const target = parseAgentJournalItemKey(rewind.providerItemId ?? rewind.itemId)
if (target?.provider === 'codex' && !rewind.hydrationVerified) {
const recovered = await adapter?.recoverRewind?.({
sessionId,
fence,
beforeTurnId: target.turnId
})
if (!recovered?.ok) {
if (
recovered?.reason === 'provider-refused' &&
rewind.phase === 'prepared' &&
!rewind.providerApplied
) {
await persistRewindRecord(store, sessionId, fence, {
...rewind,
phase: 'refused',
reason: recovered.reason,
retained: []
})
return
}
throw new Error(`agent_session_rewind:${recovered?.reason ?? 'outcome-unknown'}`)
}
const expectedItems = new Set(rewind.retained.map((item) => item.itemId))
const observedItems = new Set<string>()
for (const { identity } of recovered.items) {
const itemId = agentJournalItemKey(identity)
if (
identity.provider !== 'codex' ||
identity.threadId !== target.threadId ||
!expectedItems.has(itemId)
) {
throw new Error('agent_session_rewind:proof-mismatch')
}
observedItems.add(itemId)
}
if (observedItems.size !== expectedItems.size) {
throw new Error('agent_session_rewind:proof-mismatch')
}
const retained = recovered.items.map(({ identity, body }) => ({
itemId: agentJournalItemKey(identity),
body,
observedAt: now()
}))
if (
retained.length > 10_000 ||
Buffer.byteLength(JSON.stringify(retained), 'utf8') > AGENT_SESSION_HISTORY_MAX_PAGE_BYTES
) {
throw new Error('agent_session_rewind:history-limit')
}
rewind = { ...rewind, retained, phase: 'provider-succeeded', hydrationVerified: true }
await persistRewindRecord(store, sessionId, fence, rewind)
}
if (rewind.phase !== 'provider-succeeded') {
return
}
const replacement = rewind.retained.map((item) => {
const identity = parseAgentJournalItemKey(item.itemId)
if (!identity) {
throw new Error('agent_session_rewind:invalid-retained-identity')
}
return { identity, body: restoreRewindJournalBody(item.body), observedAt: item.observedAt }
})
// A crash after the journal transaction must settle its existing epoch, not replace it twice.
const alreadyReplaced = journal.cursor().epoch !== rewind.expectedEpoch
if (
alreadyReplaced &&
!isDeepStrictEqual(
journal.snapshot().items.map(({ itemId, body }) => ({ itemId, body })),
replacement.map(({ identity, body }) => ({ itemId: agentJournalItemKey(identity), body }))
)
) {
throw new Error('agent_session_rewind:stale-epoch')
}
const cursor = alreadyReplaced
? journal.cursor()
: await journal.replaceEpochItems('handle_forked', fence, replacement)
await persistRewindRecord(store, sessionId, fence, {
...rewind,
phase: 'completed',
epoch: cursor.epoch,
retained: []
})
await store.recordOperationOutcome({
callerKey: rewind.callerKey,
operationId: rewind.operationId,
outcome: {
status: 'succeeded',
sessionId,
rewind: { itemId: rewind.itemId, epoch: cursor.epoch }
}
})
}
@@ -0,0 +1,24 @@
import {
AGENT_SESSION_REWIND_REASONS,
type AgentSessionRewindReason
} from '../../../shared/agent-session-rewind'
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
export function rewindRefusal(reason: AgentSessionRewindReason): {
ok: false
refusal: AgentSessionWireRefusal
} {
const knownReason =
AGENT_SESSION_REWIND_REASONS.find((value) => value === reason) ?? 'outcome-unknown'
return {
ok: false,
refusal: {
code:
knownReason === 'outcome-unknown'
? 'agent_session_operation_unknown'
: 'agent_session_operation_invalid',
message: `agent_session_rewind:${knownReason}`,
rewindReason: knownReason
}
}
}
@@ -53,6 +53,10 @@ export const ADMISSION_METHODS = [
},
{ method: 'agentSession.ensure', params: attachParams() },
{ method: 'agentSession.send', params: sendParams() },
{
method: 'agentSession.rewind',
params: { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'epoch' }
},
{
method: 'agentSession.respondToApproval',
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
@@ -139,6 +139,7 @@ export function hostStub(): StructuredAgentSessionHost {
unconfirmedClientMessageIds: []
}
})),
rewind: vi.fn(async () => ({ ok: true, value: { itemId: 'chosen', epoch: 'next' } })),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
@@ -237,3 +237,11 @@ export const UnsubscribeParams = z
/** Read-only owner classification retained for restart safety; mutation handoff is separate. */
export const HandoffStatusParams = z.object({ sessionId: SessionId }).strict()
export const RewindParams = z
.object({
envelope: MutationEnvelope,
itemId: Identifier('Invalid item id', 4096),
expectedEpoch: Identifier('Invalid journal epoch')
})
.strict()
@@ -160,7 +160,7 @@ describe('capability gating', () => {
}
// Bump deliberately: the whole agentSession.* surface is behind the structured capability,
// so an additive method is invisible to old clients and needs no protocol bump.
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(21)
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(22)
})
it('hides the surface from a declared client that did not advertise it', async () => {
@@ -668,3 +668,21 @@ describe('agentSession.subscribeStatus', () => {
expect(hostCalls.subscribeStatus).toHaveBeenCalledOnce()
})
})
describe('rewind wire boundary', () => {
it('routes the exact item and epoch through the structured capability gate', async () => {
const params = { envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current' }
const result = await call('agentSession.rewind', params, STRUCTURED_CLIENT)
expect(result).toMatchObject({ result: { ok: true } })
expect(hostCalls.rewind).toHaveBeenCalledWith(expect.anything(), params)
})
it('rejects absent epoch and caller-supplied provider keys', async () => {
for (const params of [
{ envelope: envelope(), itemId: 'chosen' },
{ envelope: envelope(), itemId: 'chosen', expectedEpoch: 'current', beforeTurnId: 'forged' }
]) {
expect(await call('agentSession.rewind', params, STRUCTURED_CLIENT)).toHaveProperty('error')
}
expect(hostCalls.rewind).not.toHaveBeenCalled()
})
})
@@ -46,6 +46,7 @@ import {
HandoffStatusParams,
OptionsParams,
RespondParams,
RewindParams,
SendParams,
SetOptionParams,
SubscribeParams,
@@ -82,6 +83,15 @@ async function attachClientSuppliedLocation(
}
export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
defineMethod({
name: 'agentSession.rewind',
params: RewindParams,
handler: async (params, ctx) => {
requireStructuredCapability(ctx)
await ensureHostInstalled(ctx)
return requireHost(ctx).rewind(callerFor(ctx), params)
}
}),
defineMethod({
name: 'agentSession.conversationCommand',
params: ConversationCommandParams,
@@ -1,3 +1,4 @@
import { proveClaudeTranscriptBranch } from '../claude/claude-transcript-branch-proof'
import type { AgentSessionRecord } from '../../shared/agent-session-record'
import type { AgentSessionBackgroundTaskState } from '../../shared/agent-session-wire'
import { join } from 'node:path'
@@ -70,10 +71,25 @@ export function createStructuredClaudeRuntimeAdapter(
})
)
},
readTranscriptLeaf: async ({ providerSessionId, previousLeafUuid, claudeConfigDir }) => {
readTranscriptLeaf: async ({
providerSessionId,
previousLeafUuid,
intentionalRewindUuid,
claudeConfigDir
}) => {
const transcriptPath = await resolveSessionFilePath('claude', providerSessionId, {
claudeProjectsDir: join(claudeConfigDir, 'projects')
})
if (transcriptPath && intentionalRewindUuid !== undefined) {
return (
await proveClaudeTranscriptBranch({
transcriptPath,
providerSessionId,
previousLeafUuid,
intentionalRewindUuid
})
).leafUuid
}
return transcriptPath
? await readClaudeTranscriptLeafUuid(transcriptPath, providerSessionId, previousLeafUuid)
: null
@@ -177,13 +177,13 @@ describe('BrowserPaneOverlayLayer', () => {
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(0)
})
it('keeps inactive browser panes mounted for a visible worktree', () => {
it('defers inactive browser panes while retaining their viewport slots', () => {
const markup = renderOverlay({ isWorktreeActive: true })
expect(markup).toContain('data-browser-pane-id="browser-a"')
expect(markup).toContain('data-browser-pane-active="true"')
expect(markup).toContain('data-browser-pane-id="browser-b"')
expect(markup).toContain('data-browser-pane-active="false"')
expect(markup).not.toContain('data-browser-pane-id="browser-b"')
expect(markup).toContain('data-browser-overlay-tab-id="browser-b"')
})
it('marks the active browser pane focused when its own group holds focus', () => {
@@ -195,6 +195,82 @@ describe('BrowserPaneOverlayLayer', () => {
)
})
it('restores 200 tabs on demand and preserves viewport roots across parking and selection', () => {
const browsers = Array.from({ length: 200 }, (_, index) =>
createBrowserTab(`browser-${index}`, [`page-${index}`])
)
const tabs = browsers.map((browser, index) =>
createUnifiedBrowserTab(`tab-${index}`, browser.id, index)
)
mocks.state!.browserTabsByWorktree['wt-1'] = browsers
mocks.state!.unifiedTabsByWorktree['wt-1'] = tabs
const group = mocks.state!.groupsByWorktree['wt-1'][0]
mocks.state!.groupsByWorktree['wt-1'] = [
{ ...group, activeTabId: tabs[0].id, tabOrder: tabs.map((tab) => tab.id) }
]
const view = render(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
const slot = view.container.querySelector('[data-browser-overlay-tab-id="browser-0"]')!
const viewport = slot.firstElementChild
const pane = slot.querySelector('[data-browser-pane-id]')
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(200)
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={false} />)
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
expect(pane!.isConnected).toBe(false)
expect((slot as HTMLElement).style.display).toBe('none')
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
expect(slot.querySelector('[data-browser-pane-id]')).not.toBe(pane)
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={false} />)
mocks.state!.groupsByWorktree['wt-1'] = [{ ...group, activeTabId: tabs[199].id }]
view.rerender(<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive />)
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(1)
expect(view.container.querySelector('[data-browser-pane-id="browser-199"]')).not.toBeNull()
expect(slot.firstElementChild).toBe(viewport)
expect(viewport!.isConnected).toBe(true)
})
it('retains zero unclaimed hidden panes after visiting 50 worktrees with 20 tabs each', () => {
const worktreeIds = Array.from({ length: 50 }, (_, index) => `wt-scale-${index}`)
for (const worktreeId of worktreeIds) {
const browsers = Array.from({ length: 20 }, (_, index) => ({
...createBrowserTab(`${worktreeId}-browser-${index}`, [`${worktreeId}-page-${index}`]),
worktreeId
}))
const tabs = browsers.map((browser, index) => ({
...createUnifiedBrowserTab(`${worktreeId}-tab-${index}`, browser.id, index),
worktreeId,
groupId: `${worktreeId}-group-${index}`
}))
mocks.state!.browserTabsByWorktree[worktreeId] = browsers
mocks.state!.unifiedTabsByWorktree[worktreeId] = tabs
mocks.state!.groupsByWorktree[worktreeId] = tabs.map((tab) => ({
id: tab.groupId,
worktreeId,
activeTabId: tab.id,
tabOrder: [tab.id]
}))
}
const surfaces = (activeId: string | null) =>
worktreeIds.map((worktreeId) => (
<RetainedBrowserPaneOverlayLayer
key={worktreeId}
worktreeId={worktreeId}
isWorktreeActive={worktreeId === activeId}
mountEligible={worktreeId === activeId}
/>
))
const view = render(surfaces(null))
for (const worktreeId of worktreeIds) {
view.rerender(surfaces(worktreeId))
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(20)
view.rerender(surfaces(null))
expect(view.container.querySelectorAll('[data-browser-pane-id]')).toHaveLength(0)
}
expect(view.container.querySelectorAll('[data-browser-overlay-tab-id]')).toHaveLength(1000)
})
it('keeps an active browser pane unfocused when another split holds focus (#11348)', () => {
mocks.state = createState()
mocks.state.groupsByWorktree = {
@@ -1,10 +1,11 @@
import { memo, useCallback, useLayoutEffect, useMemo, useState } from 'react'
import { memo, useCallback, useMemo } from 'react'
import { registerBrowserOverlaySlotViewport } from '../host-guest/browser-page-viewport'
import { useShallow } from 'zustand/react/shallow'
import { useAppStore } from '../../../store'
import type { BrowserTab as BrowserTabState } from '../../../../../shared/browser-workspace-types'
import type { Tab, TabGroup } from '../../../../../shared/tab-types'
import BrowserPane from './browser-workspace-pane'
import { DeferredBrowserContent } from './DeferredBrowserContent'
import type { BrowserChromeShortcutScope } from '../describe-page/browser-page-types'
import { tabGroupBodyAnchorName } from '../../tab-group/tab-group-body-anchor'
import { useBrowserGuestPaintRetention } from '../host-guest/browser-guest-paint-retention'
@@ -28,23 +29,23 @@ const EMPTY_GROUPS: readonly TabGroup[] = []
type BrowserOverlaySlotProps = {
browserTab: BrowserTabState
isWorktreeActive: boolean
// Why: undefined = orphan tab (in browserTabs but not referenced by any group's unified-tab list); the fallback branch keeps these hidden.
groupId: string | undefined
isActive: boolean
chromeShortcutScope: BrowserChromeShortcutScope
// Why: overlay is a sibling of the group layout, so pane focus doesn't bubble to TabGroupPanel; re-sync it here or split-view clicks leave activeGroupIdByWorktree stale.
onFocusOwningGroup: ((groupId: string) => void) | undefined
isWorktreeActive: boolean
}
// Why: memoize each slot so unrelated worktree mutations don't cascade a re-render into every BrowserPane subtree.
const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
browserTab,
isWorktreeActive,
groupId,
isActive,
chromeShortcutScope,
onFocusOwningGroup,
isWorktreeActive
onFocusOwningGroup
}: BrowserOverlaySlotProps): React.JSX.Element {
// Why: persistent page viewports (webview guests) live under this root so they survive BrowserPane chrome unmounts without reparenting.
const setSlotViewportRef = useCallback(
@@ -60,8 +61,6 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
: [browserTab.activePageId ?? browserTab.id]
const needsGuestPaint = useBrowserGuestPaintRetention(browserPageIds)
const isPaintable = isActive || needsGuestPaint
// Why: hidden worktrees keep lightweight overlay slots, but park their webviews unless a remote controller or viewer needs the guest.
const shouldMountPane = isWorktreeActive || needsGuestPaint
// Why: CSS anchor positioning pins the overlay to its owning group's body — a tab move only swaps positionAnchor, no measurement/state.
// Orphan branch (no anchorName) stays display:none until the tab is reassigned or destroyed.
const style: React.CSSProperties = useMemo(
@@ -104,14 +103,14 @@ const BrowserOverlaySlot = memo(function BrowserOverlaySlot({
onFocusCapture={handleFocus}
>
<div ref={setSlotViewportRef} className="absolute inset-0 flex min-h-0 flex-col" />
{/* Why: hidden worktrees park the heavy pane subtree; visible ones keep stable slots so reparenting can't destroy the webview guest. */}
{shouldMountPane ? (
<DeferredBrowserContent mountEligible={isPaintable} retainMounted={isWorktreeActive}>
<BrowserPane
browserTab={browserTab}
isWorktreeActive={isWorktreeActive}
isActive={isActive}
chromeShortcutScope={chromeShortcutScope}
/>
) : null}
</DeferredBrowserContent>
</div>
)
})
@@ -188,11 +187,11 @@ const BrowserPaneOverlayLayer = memo(function BrowserPaneOverlayLayer({
<BrowserOverlaySlot
key={browserTab.id}
browserTab={browserTab}
isWorktreeActive={isWorktreeActive}
groupId={assignment?.groupId}
isActive={isActive}
chromeShortcutScope={chromeShortcutScope}
onFocusOwningGroup={focusOwningGroup}
isWorktreeActive={isWorktreeActive}
/>
)
})}
@@ -265,17 +264,11 @@ export const RetainedBrowserPaneOverlayLayer = memo(function RetainedBrowserPane
isWorktreeActive: boolean
mountEligible: boolean
}): React.JSX.Element | null {
const [hasCommittedMount, setHasCommittedMount] = useState(false)
// Why: commit the latch with the persistent slot DOM so discarded renders cannot retain a guest host.
useLayoutEffect(() => {
if (mountEligible && !hasCommittedMount) {
setHasCommittedMount(true)
}
}, [hasCommittedMount, mountEligible])
if (!mountEligible && !hasCommittedMount) {
return null
}
return <BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isWorktreeActive} />
return (
<DeferredBrowserContent mountEligible={mountEligible}>
<BrowserPaneOverlayLayer worktreeId={worktreeId} isWorktreeActive={isWorktreeActive} />
</DeferredBrowserContent>
)
})
export default BrowserPaneOverlayLayer
@@ -0,0 +1,22 @@
import { useLayoutEffect, useState, type ReactNode } from 'react'
export function DeferredBrowserContent({
mountEligible,
retainMounted = true,
children
}: {
mountEligible: boolean
retainMounted?: boolean
children: ReactNode
}): React.JSX.Element | null {
const [hasCommittedMount, setHasCommittedMount] = useState(false)
// Only committed, retainable mounts may survive the loss of eligibility.
useLayoutEffect(() => {
if (!retainMounted) {
setHasCommittedMount(false)
} else if (mountEligible && !hasCommittedMount) {
setHasCommittedMount(true)
}
}, [hasCommittedMount, mountEligible, retainMounted])
return mountEligible || (retainMounted && hasCommittedMount) ? <>{children}</> : null
}
@@ -0,0 +1,299 @@
// @vitest-environment happy-dom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createStore, type StoreApi } from 'zustand'
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import type { Tab, TabGroup } from '../../../../../shared/tab-types'
type MockState = {
browserTabsByWorktree: Record<string, BrowserWorkspace[]>
browserPagesByWorkspace: Record<string, BrowserPage[]>
unifiedTabsByWorktree: Record<string, Tab[]>
groupsByWorktree: Record<string, TabGroup[]>
activeGroupIdByWorktree: Record<string, string>
remoteBrowserPageHandlesByPageId: Record<string, never>
focusGroup: () => void
updateBrowserPageState: () => void
setBrowserPageUrl: () => void
settings: { browserSshWorkspaceRoutingEnabled: boolean }
}
const mocks = vi.hoisted(() => ({
state: null as MockState | null,
store: null as StoreApi<MockState> | null,
executionHostId: 'local',
prepare: vi.fn(),
destroy: vi.fn()
}))
vi.mock('@/store', async () => {
const { useStore } = await import('zustand')
return {
useAppStore: (selector: (state: MockState) => unknown) => useStore(mocks.store!, selector)
}
})
vi.mock('@/lib/worktree-runtime-owner', () => ({
getRuntimeEnvironmentIdForWorktree: () => null,
getExecutionHostIdForWorktree: () => mocks.executionHostId
}))
vi.mock('@/components/contextual-tours/use-contextual-tour', () => ({
useContextualTour: () => {}
}))
vi.mock('../host-guest/webview-registry', () => ({ destroyPersistentWebview: mocks.destroy }))
vi.mock('./BrowserMobileDriverOverlay', () => ({ BrowserMobileDriverOverlay: () => null }))
vi.mock('./browser-page-pane', () => ({
BrowserPagePane: ({ browserTab, isActive }: { browserTab: BrowserPage; isActive: boolean }) => (
<input data-page-id={browserTab.id} data-active={isActive} />
)
}))
vi.mock('../workspace-doc/workspace-doc-page-pane', () => ({
WorkspaceDocPagePane: ({ page, isActive }: { page: BrowserPage; isActive: boolean }) => (
<input data-page-id={page.id} data-active={isActive} />
)
}))
import BrowserPaneOverlayLayer from './BrowserPaneOverlayLayer'
import {
acquireBrowserAutomationVisibility,
releaseBrowserAutomationVisibility
} from '../host-guest/browser-automation-visibility'
import { hydrateBrowserDrivers } from '@/lib/pane-manager/browser-mobile-driver-state'
import { hydrateBrowserRemoteViewerPages } from '@/lib/pane-manager/browser-remote-viewer-state'
function createState(): MockState {
const browsers: BrowserWorkspace[] = ['a', 'b'].map((id) => ({
id,
worktreeId: 'wt-1',
label: id,
sessionProfileId: null,
activePageId: `${id}-1`,
pageIds: [`${id}-1`, `${id}-2`],
url: 'about:blank',
title: id,
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1
}))
return {
browserTabsByWorktree: { 'wt-1': browsers },
browserPagesByWorkspace: Object.fromEntries(
browsers.map((browser) => [
browser.id,
(browser.pageIds ?? []).map((id) => ({ ...browser, id, workspaceId: browser.id }))
])
),
unifiedTabsByWorktree: {
'wt-1': browsers.map((browser, index) => ({
id: browser.id,
entityId: browser.id,
groupId: 'group-1',
worktreeId: 'wt-1',
contentType: 'browser',
label: browser.id,
customLabel: null,
color: null,
sortOrder: index,
createdAt: 1
}))
},
groupsByWorktree: {
'wt-1': [{ id: 'group-1', worktreeId: 'wt-1', activeTabId: 'a', tabOrder: ['a', 'b'] }]
},
activeGroupIdByWorktree: { 'wt-1': 'group-1' },
remoteBrowserPageHandlesByPageId: {},
focusGroup: () => {},
updateBrowserPageState: () => {},
setBrowserPageUrl: () => {},
settings: { browserSshWorkspaceRoutingEnabled: true }
}
}
function selectTab(id: string): void {
mocks.state!.groupsByWorktree['wt-1'] = [
{ ...mocks.state!.groupsByWorktree['wt-1'][0], activeTabId: id }
]
}
function selectPage(id: string): void {
mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map(
(browser) => (browser.id === 'a' ? { ...browser, activePageId: id } : browser)
)
}
const surface = (active = true) => (
<BrowserPaneOverlayLayer worktreeId="wt-1" isWorktreeActive={active} />
)
function redraw(view: ReturnType<typeof render>, active = true): void {
act(() => mocks.store!.setState({ ...mocks.state! }))
view.rerender(surface(active))
}
const settle = () => act(async () => {})
describe('deferred browser lifecycle through the overlay and SSH gate', () => {
beforeEach(() => {
mocks.state = createState()
mocks.store = createStore(() => mocks.state!)
mocks.executionHostId = 'local'
mocks.destroy.mockReset()
mocks.prepare.mockReset().mockResolvedValue({ partition: 'persist:orca-browser-v1-routed' })
Object.defineProperty(window, 'api', {
configurable: true,
value: { browser: { prepareSshWorkspacePartition: mocks.prepare } }
})
})
afterEach(() => {
cleanup()
hydrateBrowserDrivers([])
hydrateBrowserRemoteViewerPages([])
})
it.each(['automation', 'mobile', 'viewer'])(
'releases hidden sibling chrome without remounting the %s-claimed page',
(consumer) => {
const view = render(surface())
selectPage('a-2')
redraw(view)
const claimed = view.container.querySelector('[data-page-id="a-2"]')
selectTab('b')
redraw(view)
let token: string | null = null
act(() => {
if (consumer === 'automation') {
token = acquireBrowserAutomationVisibility('a-2')
}
if (consumer === 'mobile') {
hydrateBrowserDrivers([
{ browserPageId: 'a-2', driver: { kind: 'mobile', clientId: 'phone-1' } }
])
}
if (consumer === 'viewer') {
hydrateBrowserRemoteViewerPages(['a-2'])
}
})
try {
redraw(view, false)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed)
redraw(view)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(2)
expect(view.container.querySelector('[data-page-id="a-1"]')).toBeNull()
expect(view.container.querySelector('[data-page-id="a-2"]')).toBe(claimed)
redraw(view, false)
act(() => {
if (token) {
releaseBrowserAutomationVisibility(token)
}
hydrateBrowserDrivers([])
hydrateBrowserRemoteViewerPages([])
})
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
redraw(view)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
expect(view.container.querySelector('[data-page-id="b-1"]')).not.toBeNull()
} finally {
if (token) {
releaseBrowserAutomationVisibility(token)
}
}
}
)
it.each(['url', 'document'])(
'retains %s content across page and tab switches within a visible worktree',
(kind) => {
if (kind === 'document') {
mocks.state!.browserPagesByWorkspace.a[0].docLocation = {
kind: 'workspace-doc',
worktreeId: 'wt-1',
filePath: '/workspace/report.html'
}
}
const view = render(surface())
const page = view.container.querySelector<HTMLInputElement>('[data-page-id="a-1"]')!
page.value = 'unsaved state'
page.scrollTop = 80
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
selectPage('a-2')
redraw(view)
expect(page.isConnected).toBe(true)
expect(page.dataset.active).toBe('false')
selectTab('b')
redraw(view)
expect(page.isConnected).toBe(true)
selectPage('a-1')
selectTab('a')
redraw(view)
expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page)
expect(page.value).toBe('unsaved state')
expect(page.scrollTop).toBe(80)
expect(page.dataset.active).toBe('true')
expect(view.container.querySelector('[data-page-id="b-2"]')).toBeNull()
redraw(view, false)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
redraw(view)
const restored = view.container.querySelector('[data-page-id="a-1"]')!
expect(restored).not.toBe(page)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
mocks.state!.browserPagesByWorkspace.a = mocks.state!.browserPagesByWorkspace.a.slice(1)
mocks.state!.browserTabsByWorktree['wt-1'] = [...mocks.state!.browserTabsByWorktree['wt-1']]
redraw(view)
expect(page.isConnected).toBe(false)
expect(restored.isConnected).toBe(false)
}
)
it('keeps a prepared SSH gate and its opened pages alive when switching tabs', async () => {
mocks.executionHostId = 'ssh:target-a'
const view = render(surface())
await settle()
const page = view.container.querySelector('[data-page-id="a-1"]')
expect(page).not.toBeNull()
mocks.destroy.mockClear()
selectTab('b')
redraw(view)
await settle()
expect(mocks.destroy.mock.calls.flat()).not.toContain('a-1')
mocks.destroy.mockClear()
mocks.prepare.mockClear()
selectTab('a')
redraw(view)
await settle()
expect(mocks.destroy).not.toHaveBeenCalled()
expect(mocks.prepare).not.toHaveBeenCalled()
expect(view.container.querySelector('[data-page-id="a-1"]')).toBe(page)
redraw(view, false)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
expect(mocks.destroy).not.toHaveBeenCalled()
redraw(view)
await settle()
expect(mocks.prepare).toHaveBeenCalledOnce()
expect(view.container.querySelector('[data-page-id="a-1"]')).not.toBe(page)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(1)
})
it('guards all pages, including inactive tabs, when SSH routing is enabled', async () => {
mocks.executionHostId = 'ssh:target-a'
mocks.state!.settings.browserSshWorkspaceRoutingEnabled = false
const view = render(surface())
selectPage('a-2')
redraw(view)
selectTab('b')
redraw(view)
selectTab('a')
redraw(view)
mocks.destroy.mockClear()
mocks.prepare.mockImplementation(() => new Promise(() => {}))
mocks.state!.settings = { browserSshWorkspaceRoutingEnabled: true }
mocks.state!.browserTabsByWorktree['wt-1'] = mocks.state!.browserTabsByWorktree['wt-1'].map(
(browser) => ({ ...browser })
)
redraw(view)
expect(view.container.querySelectorAll('[data-page-id]')).toHaveLength(0)
expect(new Set(mocks.destroy.mock.calls.flat())).toEqual(new Set(['a-1', 'a-2', 'b-1', 'b-2']))
})
})
@@ -149,14 +149,49 @@ describe('browser workspace pane retention props', () => {
hydrateBrowserRemoteViewerPages([])
})
it('defers unopened pages out of 200 and retains opened pages until unmount', () => {
const pages = Array.from({ length: 200 }, (_, index) => createPage(`page-${index}`))
mocks.state!.browserPagesByWorkspace[WORKSPACE_ID] = pages
const workspace = { ...createWorkspace(), activePageId: pages[0].id }
const view = render(<BrowserPane browserTab={workspace} isActive />)
const renderedIds = (): (string | null)[] =>
[...view.container.querySelectorAll('[data-browser-page-id]')].map((node) =>
node.getAttribute('data-browser-page-id')
)
expect(renderedIds()).toEqual(['page-0'])
view.rerender(<BrowserPane browserTab={{ ...workspace, activePageId: 'page-199' }} isActive />)
expect(renderedIds()).toEqual(['page-0', 'page-199'])
view.rerender(<BrowserPane browserTab={workspace} isActive={false} />)
expect(renderedIds()).toEqual(['page-0', 'page-199'])
view.rerender(<BrowserPane key="restored" browserTab={workspace} isActive />)
expect(renderedIds()).toEqual(['page-0'])
})
it.each(['automation', 'mobile', 'viewer'])('loads an inactive page for %s only', (consumer) => {
const token = consumer === 'automation' ? acquireBrowserAutomationVisibility('page-b') : null
if (consumer === 'mobile') {
hydrateBrowserDrivers([
{ browserPageId: 'page-b', driver: { kind: 'mobile', clientId: 'phone-1' } }
])
}
if (consumer === 'viewer') {
hydrateBrowserRemoteViewerPages(['page-b'])
}
try {
const view = render(<BrowserPane browserTab={createWorkspace()} isActive={false} />)
expect(view.container.querySelector('[data-browser-page-id="page-a"]')).toBeNull()
expect(view.container.querySelector('[data-browser-page-id="page-b"]')).not.toBeNull()
} finally {
if (token) {
releaseBrowserAutomationVisibility(token)
}
}
})
it('threads all three retention terms to the page that owns them', () => {
renderWorkspacePane()
expect(propsFor('page-b')).toEqual({
id: 'page-b',
isAutomationVisible: false,
isMobileDriven: false,
isRemotelyViewed: false
})
expect(mocks.pageProps.some((props) => props.id === 'page-b')).toBe(false)
cleanup()
const token = acquireBrowserAutomationVisibility('page-b')
@@ -19,15 +19,19 @@ import { RemoteBrowserPagePane } from '../stream-remote/remote-browser-page-pane
import { ClientHostedBrowserPagePane } from '../ClientHostedBrowserPagePane'
import { BrowserPagePane } from './browser-page-pane'
import { WorkspaceDocPagePane } from '../workspace-doc/workspace-doc-page-pane'
import { DeferredBrowserContent } from './DeferredBrowserContent'
import { isBrowserPagePanePaintable } from '../host-guest/browser-page-paintability'
import { SshRoutedBrowserPageGate } from './ssh-routed-browser-page-gate'
export default function BrowserPane({
browserTab,
isActive,
isWorktreeActive = true,
chromeShortcutScope
}: {
browserTab: BrowserWorkspaceState
isActive: boolean
isWorktreeActive?: boolean
chromeShortcutScope?: BrowserChromeShortcutScope
}): React.JSX.Element {
const resolvedChromeShortcutScope = chromeShortcutScope ?? (isActive ? 'focused' : 'inactive')
@@ -55,17 +59,17 @@ export default function BrowserPane({
const automationVisiblePageIds = useBrowserAutomationVisiblePageIds(browserPageIds)
const mobileDrivenPageIds = useBrowserMobileDrivenPageIds(browserPageIds)
const remotelyViewedPageIds = useBrowserRemotelyViewedPageIds(browserPageIds)
// Why: inactive webviews must stay mounted in their original DOM parent; unmounting/reparenting loses form text and SPA state.
const renderedBrowserPages = useMemo(
const localBrowserPages = useMemo(
() =>
browserPages.filter(
(page) => !getBrowserPageRuntimeEnvironmentId(page, activeRuntimeEnvironmentId)
),
[browserPages, activeRuntimeEnvironmentId]
)
const renderedBrowserPageIds = useMemo(
() => renderedBrowserPages.map((page) => page.id),
[renderedBrowserPages]
// Routing guards every local guest, including pages hidden after their first activation.
const localBrowserPageIds = useMemo(
() => localBrowserPages.map((page) => page.id),
[localBrowserPages]
)
const pageDriver = useBrowserDriverForPage(activeBrowserPageId)
// Why: a runtime-backed page is streamed, never locally driven, so its driver must read idle.
@@ -149,42 +153,51 @@ export default function BrowserPane({
return (
<div className="relative flex h-full min-h-0 flex-1 flex-col">
{renderedBrowserPages.length > 0 ? (
{localBrowserPages.length > 0 ? (
<SshRoutedBrowserPageGate
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
pageIds={renderedBrowserPageIds}
pageIds={localBrowserPageIds}
>
{(routedPartition) => (
<div className="relative flex min-h-0 flex-1">
{renderedBrowserPages.map((page) =>
page.docLocation ? (
<WorkspaceDocPagePane
key={page.id}
page={page}
isActive={isActive && page.id === activeBrowserPage?.id}
/>
) : (
<BrowserPagePane
key={page.id}
browserTab={page}
workspaceId={browserTab.id}
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
isActive={isActive && page.id === activeBrowserPage?.id}
chromeShortcutScope={
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
}
isAutomationVisible={automationVisiblePageIds.has(page.id)}
isMobileDriven={mobileDrivenPageIds.has(page.id)}
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
inputLocked={activeBrowserDriver.kind === 'mobile'}
onUpdatePageState={updateBrowserPageState}
onSetUrl={setBrowserPageUrl}
/>
)
)}
{localBrowserPages.map((page) => (
<DeferredBrowserContent
key={page.id}
retainMounted={isWorktreeActive}
mountEligible={isBrowserPagePanePaintable({
isActive: isActive && page.id === activeBrowserPageId,
isAutomationVisible: automationVisiblePageIds.has(page.id),
isMobileDriven: mobileDrivenPageIds.has(page.id),
hasRemoteViewer: remotelyViewedPageIds.has(page.id)
})}
>
{page.docLocation ? (
<WorkspaceDocPagePane
page={page}
isActive={isActive && page.id === activeBrowserPage?.id}
/>
) : (
<BrowserPagePane
browserTab={page}
workspaceId={browserTab.id}
worktreeId={browserTab.worktreeId}
sessionProfileId={browserTab.sessionProfileId ?? null}
sessionPartition={routedPartition ?? browserTab.sessionPartition ?? null}
isActive={isActive && page.id === activeBrowserPage?.id}
chromeShortcutScope={
page.id === activeBrowserPage?.id ? resolvedChromeShortcutScope : 'inactive'
}
isAutomationVisible={automationVisiblePageIds.has(page.id)}
isMobileDriven={mobileDrivenPageIds.has(page.id)}
isRemotelyViewed={remotelyViewedPageIds.has(page.id)}
inputLocked={activeBrowserDriver.kind === 'mobile'}
onUpdatePageState={updateBrowserPageState}
onSetUrl={setBrowserPageUrl}
/>
)}
</DeferredBrowserContent>
))}
<BrowserMobileDriverOverlay
driver={activeBrowserDriver}
onTakeBack={reclaimActiveBrowserForDesktop}
@@ -0,0 +1,113 @@
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createBrowserPageWebviewGuestSession } from './browser-page-webview-guest-session'
const mocks = vi.hoisted(() => ({
replace: vi.fn(async () => {}),
registeredIds: new Map<string, number>(),
isRegistered: vi.fn(async () => true)
}))
vi.mock('./webview-registry', () => ({
registeredWebContentsIds: mocks.registeredIds,
replacePersistentWebview: mocks.replace
}))
vi.mock('../describe-page/browser-page-load-error', () => ({ browserPageExists: () => true }))
function createPage(id: string) {
const webview = document.createElement('webview') as Electron.WebviewTag
webview.getWebContentsId = vi.fn(() => {
if (!webview.isConnected) {
throw new Error('guest destroyed')
}
return 1
})
document.body.appendChild(webview)
const paintable = { current: false }
const setGeneration = vi.fn()
const ref = <T>(current: T) => ({ current })
const session = createBrowserPageWebviewGuestSession({
webview,
browserTabId: id,
workspaceId: 'browser-1',
worktreeId: 'wt-1',
sessionProfileId: null,
webviewRef: ref(webview),
isPaintableRef: paintable,
guestRecoveryPendingRef: ref(false),
browserTabUrlRef: ref('https://example.test'),
addressBarValueRef: ref('https://example.test'),
activeLoadFailureRef: ref(null),
recoveryNavigationValidationRef: ref(null),
keepAddressBarFocusRef: ref(false),
paneZoomLevelRef: ref(0),
viewportPresetIdRef: ref(null),
onUpdatePageStateRef: ref(vi.fn()),
setGuestRecoveryGeneration: setGeneration,
setBrowserZoomPercent: vi.fn(),
focusAddressBarNow: () => false,
syncNavigationState: vi.fn(),
syncBrowserAnnotationViewportBridge: vi.fn()
})
return { webview, paintable, setGeneration, recovery: session.guestRecovery }
}
describe('retained browser panes after guest eviction', () => {
const pages: ReturnType<typeof createPage>[] = []
beforeEach(() => {
mocks.replace.mockClear()
mocks.isRegistered.mockClear()
mocks.registeredIds.clear()
Object.defineProperty(window, 'api', {
configurable: true,
value: { browser: { isGuestRegistered: mocks.isRegistered } }
})
})
afterEach(() => {
for (const page of pages.splice(0)) {
page.recovery.dispose()
page.webview.remove()
}
})
it('rebuilds only the selected page after evicting 200 hidden guests', async () => {
for (let index = 0; index < 200; index++) {
const page = createPage(`page-${index}`)
pages.push(page)
page.webview.remove()
page.recovery.validateAfterResume()
}
expect(mocks.replace).not.toHaveBeenCalled()
pages[199].paintable.current = true
pages[199].recovery.validateAfterResume()
await vi.waitFor(() => expect(pages[199].setGeneration).toHaveBeenCalledOnce())
expect(mocks.replace).toHaveBeenCalledExactlyOnceWith('page-199')
expect(pages.slice(0, 199).every((page) => page.setGeneration.mock.calls.length === 0)).toBe(
true
)
expect(mocks.isRegistered).not.toHaveBeenCalled()
})
it('reuses a connected registered guest on reactivation', async () => {
const page = createPage('page-1')
pages.push(page)
mocks.registeredIds.set('page-1', 1)
page.paintable.current = true
page.recovery.validateAfterResume()
await vi.waitFor(() => expect(mocks.isRegistered).toHaveBeenCalledOnce())
expect(mocks.replace).not.toHaveBeenCalled()
expect(page.setGeneration).not.toHaveBeenCalled()
})
it('does not mistake a connected guest awaiting dom-ready for an evicted guest', async () => {
const page = createPage('page-1')
pages.push(page)
vi.mocked(page.webview.getWebContentsId).mockImplementation(() => {
throw new Error('not ready')
})
page.paintable.current = true
page.recovery.validateAfterResume()
await vi.waitFor(() => expect(page.webview.getWebContentsId).toHaveBeenCalledOnce())
expect(mocks.replace).not.toHaveBeenCalled()
expect(page.setGeneration).not.toHaveBeenCalled()
})
})
@@ -134,6 +134,10 @@ export function createBrowserPageWebviewGuestSession({
guestRecoveryPendingRef.current = pending
},
validateRegistration: async () => {
// Budget eviction can remove a hidden guest while its pane stays mounted.
if (!webview.isConnected) {
return false
}
let webContentsId: number
try {
webContentsId = webview.getWebContentsId()
@@ -1,32 +0,0 @@
import { useEffect, type MutableRefObject } from 'react'
import { useWebviewDragPassthroughActive } from './use-webview-drag-passthrough-active'
/**
* Enrols a single component-owned guest in the renderer's drag passthrough.
*
* Why it matters: a `<webview>` swallows the pointer stream the document never sees, so a
* dnd-kit drag stops receiving `pointermove` the instant the cursor crosses one — the dragged
* tab stops following the cursor and the drop it was aiming for cannot be made. The browser
* pane's guests are held click-through through their registry; a guest that belongs to one
* component instead (the document preview) has no registry to be walked by, so it enrols here.
*/
export function useGuestDragPassthrough(
webviewRef: MutableRefObject<Electron.WebviewTag | null>,
/** Changes when the ref is pointed at a new guest, so one attached mid-drag is settled too. */
guestKey: string | null
): void {
const passthroughActive = useWebviewDragPassthroughActive()
useEffect(() => {
const webview = webviewRef.current
if (!webview) {
return
}
webview.style.pointerEvents = passthroughActive ? 'none' : ''
return () => {
// Why reset rather than restore: the guest outlives this state, and leaving it transparent
// would cost the reader every click on the document.
webview.style.pointerEvents = ''
}
}, [guestKey, passthroughActive, webviewRef])
}
@@ -9,6 +9,7 @@
// read error or a revoked grant); 'unsupported-asset' comes from a subresource whose format the
// host declined to send — a font, say — and never from the document itself.
import { act } from 'react'
import type * as WebviewRegistryModule from '../host-guest/webview-registry'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -47,7 +48,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({
}
}))
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({
...(await importOriginal<typeof WebviewRegistryModule>()),
moveFocusToRendererBeforeWebviewDetach: () => undefined
}))
@@ -5,6 +5,8 @@
// showing the internal preview scheme, Back/Forward really drive the guest's history, and the chip
// hands over the path the owner spells rather than the one the grant was minted with.
import { act } from 'react'
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
import type * as WebviewRegistryModule from '../host-guest/webview-registry'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TooltipProvider } from '@/components/ui/tooltip'
@@ -39,7 +41,8 @@ vi.mock('@/lib/doc-preview-grants', () => ({
releaseDocPreviewGrant: () => undefined
}))
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
vi.mock('@/components/browser-pane/host-guest/webview-registry', async (importOriginal) => ({
...(await importOriginal<typeof WebviewRegistryModule>()),
moveFocusToRendererBeforeWebviewDetach: () => undefined
}))
@@ -126,13 +129,14 @@ type StubWebview = Element & {
async function renderPreview(
container: HTMLDivElement,
root: Root,
options: { holdsGuestFocus?: boolean } = {}
options: { holdsGuestFocus?: boolean; isActive?: boolean } = {}
): Promise<StubWebview> {
const { HtmlDocPreview } = await import('./HtmlDocPreview')
await act(async () => {
root.render(
<TooltipProvider>
<HtmlDocPreview
isActive={options.isActive ?? true}
previewId="preview-1"
filePath={ABSOLUTE_PATH}
relativePath={ENTRY_RELATIVE_PATH}
@@ -199,6 +203,7 @@ describe('HtmlDocPreview browser chrome', () => {
}
},
browser: {
unregisterGuest: () => Promise.resolve(),
setGrabMode: (args: { browserPageId: string; enabled: boolean }) => {
grabCalls.push(args)
return Promise.resolve({ ok: true })
@@ -222,6 +227,55 @@ describe('HtmlDocPreview browser chrome', () => {
container.remove()
})
it('counts document guests in the workspace budget and restores only on activation', async () => {
const { hasLiveBrowserGuest, webviewRegistry } = await import('../host-guest/webview-registry')
const { worktreeHoldsLiveBrowserGuests, selectBrowserGuestEvictionWorktreeIds } =
await import('../host-guest/browser-guest-worktree-retention')
const { destroyWorktreeBrowserGuests } = await import('@/store/slices/browser-webview-cleanup')
const guest = await renderPreview(container, root)
expect(hasLiveBrowserGuest('preview-1')).toBe(true)
expect(await renderPreview(container, root, { isActive: false })).toBe(guest)
const page: BrowserPage = {
id: 'preview-1',
workspaceId: 'browser-1',
worktreeId: 'wt-1',
url: 'about:blank',
title: 'Report',
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
loadError: null,
createdAt: 1,
docLocation: { kind: 'workspace-doc', worktreeId: 'wt-1', filePath: ABSOLUTE_PATH }
}
const browsers: BrowserWorkspace[] = [{ ...page, id: 'browser-1', pageIds: [page.id] }]
const pages: Record<string, BrowserPage[]> = { 'browser-1': [page] }
const evicted = selectBrowserGuestEvictionWorktreeIds({
orderedWorktreeIds: ['wt-1'],
activeWorktreeId: 'wt-2',
limit: 0,
isRetained: () => true,
isEvictable: () => true,
holdsLiveGuests: () => worktreeHoldsLiveBrowserGuests(browsers, pages, hasLiveBrowserGuest)
})
expect(evicted).toEqual(['wt-1'])
await act(async () => {
destroyWorktreeBrowserGuests({ 'wt-1': browsers }, pages, 'wt-1')
})
expect(guest.isConnected).toBe(false)
expect(hasLiveBrowserGuest('preview-1')).toBe(false)
expect(container.querySelector('webview')).toBeNull()
const restored = await renderPreview(container, root)
expect(restored).not.toBe(guest)
expect(webviewRegistry.get('preview-1')).toBe(restored)
expect(await renderPreview(container, root, { isActive: false })).toBe(restored)
expect(await renderPreview(container, root)).toBe(restored)
await act(async () => root.unmount())
mounted = false
expect(hasLiveBrowserGuest('preview-1')).toBe(false)
})
it('identifies the document by its workspace path and owning machine', async () => {
await renderPreview(container, root)
@@ -10,7 +10,6 @@ import {
returnAcrossBrowserPageConversion
} from '@/lib/browser-page-conversion-history'
import { BrowserGuestAnnotateOverlays } from '@/components/browser-pane/annotate/browser-guest-annotate-overlays'
import { useGuestDragPassthrough } from '@/components/browser-pane/host-guest/use-guest-drag-passthrough'
import { attachDocPreviewWebview } from './doc-preview-webview-attach'
import {
buildDocPreviewGrantRequest,
@@ -47,6 +46,7 @@ export function HtmlDocPreview({
relativePath,
worktreeId,
holdsGuestFocus = false,
isActive = true,
runtimeEnvironmentId = null,
externalSshTargetId = null,
convertedFrom = null,
@@ -58,6 +58,7 @@ export function HtmlDocPreview({
worktreeId: string
/** Whether this preview is the surface the reader is in, and so may hold the keyboard. */
holdsGuestFocus?: boolean
isActive?: boolean
runtimeEnvironmentId?: string | null
externalSshTargetId?: string | null
/** Set when the address bar converted this page; Back returns across it once guest history runs out. */
@@ -125,7 +126,6 @@ export function HtmlDocPreview({
[filePath, hostLabel, worktreeRoot]
)
const isUnavailable = state === 'unavailable' || failureReason !== null
useGuestDragPassthrough(webviewRef, grantId)
const { grab, markup, annotationSend, grabAnnotations, browserOverlayViewport, elementTools } =
useDocPreviewGuestTools({
previewId,
@@ -217,6 +217,7 @@ export function HtmlDocPreview({
return
}
const attached = attachDocPreviewWebview({
previewId,
container: containerRef.current,
url: handle.url,
ariaLabel: translate(
@@ -270,6 +271,13 @@ export function HtmlDocPreview({
worktreeId
])
useEffect(() => {
// Eviction removes the guest, not the retained pane; only the selected preview restores it.
if (isActive && webviewRef.current && !webviewRef.current.isConnected) {
setRemintCount((count) => count + 1)
}
}, [isActive, previewId])
// The dropdown's doc-history source: opening a document is a visit, once per document per mount
// (a hard reload re-mints the grant but is not a new visit).
useEffect(() => {
@@ -0,0 +1,40 @@
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { acquireWebviewsDragPassthrough } from '../host-guest/webview-drag-passthrough'
import { webviewRegistry } from '../host-guest/webview-registry'
import { attachDocPreviewWebview } from './doc-preview-webview-attach'
it('restores pointer input when a drag ends after attaching a document preview', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const append = vi.spyOn(container, 'appendChild')
append.mockImplementation((node) => {
expect((node as HTMLElement).style.pointerEvents).toBe('none')
return Node.prototype.appendChild.call(container, node)
})
const release = acquireWebviewsDragPassthrough()
const attached = attachDocPreviewWebview({
previewId: 'preview-drag',
container,
url: 'orca-preview://grant/index.html',
ariaLabel: 'HTML preview',
onLoadStarted: vi.fn(),
onLoadStopped: vi.fn(),
onLoadFailed: vi.fn(),
onNavigated: vi.fn(),
onTitleUpdated: vi.fn()
})
try {
expect(webviewRegistry.get('preview-drag')).toBe(attached.webview)
expect(attached.webview.style.pointerEvents).toBe('none')
release()
expect(attached.webview.style.pointerEvents).toBe('')
} finally {
release()
attached.detach()
container.remove()
append.mockRestore()
}
expect(webviewRegistry.has('preview-drag')).toBe(false)
})
@@ -1,9 +1,14 @@
import { DOC_PREVIEW_PARTITION } from '../../../../../shared/doc-preview-scheme'
import { ORCA_BROWSER_GUEST_WEB_PREFERENCES_ATTRIBUTE } from '../../../../../shared/browser-guest-web-preferences'
import { isWebviewDragPassthroughActive } from '@/components/browser-pane/host-guest/webview-drag-passthrough'
import { moveFocusToRendererBeforeWebviewDetach } from '@/components/browser-pane/host-guest/webview-registry'
import {
moveFocusToRendererBeforeWebviewDetach,
registerPersistentWebview,
unregisterPersistentWebview,
webviewRegistry
} from '@/components/browser-pane/host-guest/webview-registry'
export function attachDocPreviewWebview({
previewId,
container,
url,
ariaLabel,
@@ -13,6 +18,7 @@ export function attachDocPreviewWebview({
onNavigated,
onTitleUpdated
}: {
previewId: string
container: HTMLDivElement
url: string
ariaLabel: string
@@ -45,13 +51,8 @@ export function attachDocPreviewWebview({
// Why the document names its own tab: a preview is a browser tab, and this is how every other
// one is named. What the document cannot do is name it the grant it is served over.
webview.addEventListener('page-title-updated', onTitleUpdated)
// Why here and not in the enrolling hook: appending is what makes this guest hittable, and the
// registry's contract is that the path doing so settles it. Dragging the preview's own tab
// remounts this component mid-drag, and a hook effect lands a turn too late — for the rest of
// that turn the fresh guest eats the pointer stream and the drag freezes.
if (isWebviewDragPassthroughActive()) {
webview.style.pointerEvents = 'none'
}
// Register before append so a guest attached mid-drag cannot swallow the pointer stream.
registerPersistentWebview(previewId, webview)
container.appendChild(webview)
webview.setAttribute('src', url)
@@ -66,6 +67,9 @@ export function attachDocPreviewWebview({
webview.removeEventListener('page-title-updated', onTitleUpdated)
moveFocusToRendererBeforeWebviewDetach(webview)
webview.remove()
if (webviewRegistry.get(previewId) === webview) {
unregisterPersistentWebview(previewId)
}
},
// Why: the protocol handler answers with no-store, so a reload re-reads the workspace disk.
reload: () => {
@@ -48,6 +48,7 @@ export function WorkspaceDocPagePane({
// grab in flight, exactly as a URL page's pane does.
<div className="absolute inset-0 flex min-h-0 flex-col" hidden={!isActive}>
<HtmlDocPreview
isActive={isActive}
holdsGuestFocus={isActive && isReaderSurface}
previewId={page.id}
filePath={filePath}
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
destroyPersistentWebview: vi.fn(),
getState: vi.fn(),
profileListener: null as
| ((data: {
requestId: string
browserPageId: string
profileId: string | null
sessionPartition: string | null
}) => void)
| null,
replyTabSetProfile: vi.fn(),
switchBrowserTabProfile: vi.fn()
}))
vi.mock('@/components/browser-pane/host-guest/webview-registry', () => ({
destroyPersistentWebview: mocks.destroyPersistentWebview
}))
vi.mock('../../store', () => ({
useAppStore: { getState: mocks.getState }
}))
vi.mock('./browser-automation-bootstrap-lease', () => ({
acquireBrowserAutomationBootstrapLease: vi.fn()
}))
vi.mock('../../store/pinned-tab-close-guard', () => ({
guardPinnedTabClose: vi.fn(),
isUnifiedTabPinned: vi.fn(),
resolvePinnedTabLabel: vi.fn()
}))
import { registerBrowserRequestIpcBridge } from './browser-request-ipc-bridge'
describe('browser profile request teardown', () => {
beforeEach(() => {
mocks.destroyPersistentWebview.mockReset()
mocks.replyTabSetProfile.mockReset()
mocks.switchBrowserTabProfile.mockReset()
mocks.profileListener = null
mocks.getState.mockReturnValue({
browserTabsByWorktree: { 'wt-1': [{ id: 'workspace-1' }] },
browserPagesByWorkspace: {
'workspace-1': [
{ id: 'page-url', docLocation: null },
{
id: 'page-doc',
docLocation: {
kind: 'workspace-doc',
worktreeId: 'wt-1',
filePath: '/workspace/report.html'
}
}
]
},
switchBrowserTabProfile: mocks.switchBrowserTabProfile
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
ui: {
onRequestTabCreate: () => () => {},
replyTabCreate: vi.fn(),
onRequestTabSetProfile: (listener: typeof mocks.profileListener) => {
mocks.profileListener = listener
return () => {}
},
replyTabSetProfile: mocks.replyTabSetProfile,
onRequestTabClose: () => () => {},
replyTabClose: vi.fn()
}
}
})
})
it('keeps document-preview guests while rebuilding URL siblings for a profile change', () => {
registerBrowserRequestIpcBridge([], () => false)
mocks.profileListener?.({
requestId: 'request-1',
browserPageId: 'page-url',
profileId: 'profile-2',
sessionPartition: 'persist:profile-2'
})
expect(mocks.destroyPersistentWebview).toHaveBeenCalledExactlyOnceWith('page-url')
expect(mocks.switchBrowserTabProfile).toHaveBeenCalledWith(
'workspace-1',
'profile-2',
'persist:profile-2'
)
expect(mocks.replyTabSetProfile).toHaveBeenCalledWith({ requestId: 'request-1' })
})
})
@@ -107,7 +107,10 @@ export function registerBrowserRequestIpcBridge(
const workspacePages = store.browserPagesByWorkspace[owningWorkspace.id] ?? []
if (workspacePages.length > 0) {
for (const page of workspacePages) {
destroyPersistentWebview(page.id)
// Document previews use a fixed partition, so profile changes must preserve their guests.
if (!page.docLocation) {
destroyPersistentWebview(page.id)
}
}
} else {
destroyPersistentWebview(data.browserPageId)
@@ -1,9 +1,12 @@
// @vitest-environment happy-dom
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
const mocks = vi.hoisted(() => ({
subscribe: vi.fn()
subscribe: vi.fn(),
call: vi.fn(),
supportsCapability: vi.fn()
}))
vi.mock('./runtime-environment-revision', () => ({
@@ -11,10 +14,69 @@ vi.mock('./runtime-environment-revision', () => ({
}))
vi.mock('./runtime-rpc-client', () => ({
callRuntimeRpc: vi.fn()
callRuntimeRpc: mocks.call,
runtimeEnvironmentSupportsCapability: mocks.supportsCapability
}))
import { subscribeStructuredAgentSession } from './structured-agent-session-client'
import {
callStructuredAgentSession,
subscribeStructuredAgentSession
} from './structured-agent-session-client'
describe('callStructuredAgentSession rewind capability', () => {
const target = { kind: 'environment', environmentId: 'env-1' } as const
const params = { itemId: 'item-1', expectedEpoch: 'epoch-1' }
beforeEach(() => {
vi.resetAllMocks()
mocks.call.mockResolvedValue({ ok: true })
mocks.supportsCapability.mockResolvedValue(true)
})
it('refuses an older host before dispatching rewind', async () => {
mocks.supportsCapability.mockResolvedValue(false)
await expect(callStructuredAgentSession(target, 'agentSession.rewind', params)).rejects.toThrow(
'Rewinding requires a newer Orca server'
)
expect(mocks.supportsCapability).toHaveBeenCalledExactlyOnceWith(
'env-1',
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY
)
expect(mocks.call).not.toHaveBeenCalled()
})
it('dispatches rewind once the host advertises the method', async () => {
await expect(
callStructuredAgentSession(target, 'agentSession.rewind', params)
).resolves.toEqual({
ok: true
})
expect(mocks.supportsCapability).toHaveBeenCalledWith(
'env-1',
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY
)
expect(mocks.call).toHaveBeenCalledExactlyOnceWith(target, 'agentSession.rewind', params)
})
it('does not dispatch rewind when host capability cannot be verified', async () => {
mocks.supportsCapability.mockRejectedValue(new Error('Host unreachable'))
await expect(callStructuredAgentSession(target, 'agentSession.rewind', params)).rejects.toThrow(
'Host unreachable'
)
expect(mocks.call).not.toHaveBeenCalled()
})
it('uses the local build directly and leaves existing remote methods available', async () => {
await callStructuredAgentSession({ kind: 'local' }, 'agentSession.rewind', params)
await callStructuredAgentSession(target, 'agentSession.send', params)
expect(mocks.supportsCapability).not.toHaveBeenCalled()
expect(mocks.call).toHaveBeenCalledWith({ kind: 'local' }, 'agentSession.rewind', params)
expect(mocks.call).toHaveBeenCalledWith(target, 'agentSession.send', params)
})
})
describe('subscribeStructuredAgentSession', () => {
beforeEach(() => {
@@ -4,13 +4,28 @@ import type {
AgentSessionSubscribeEvent
} from '../../../shared/agent-session-wire'
import { getRuntimeEnvironmentRevision } from './runtime-environment-revision'
import { callRuntimeRpc, type RuntimeClientTarget } from './runtime-rpc-client'
import { AGENT_SESSION_REWIND_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import {
callRuntimeRpc,
runtimeEnvironmentSupportsCapability,
type RuntimeClientTarget
} from './runtime-rpc-client'
export function callStructuredAgentSession<TResult>(
export async function callStructuredAgentSession<TResult>(
target: RuntimeClientTarget,
method: string,
params?: unknown
): Promise<TResult> {
if (
method === 'agentSession.rewind' &&
target.kind === 'environment' &&
!(await runtimeEnvironmentSupportsCapability(
target.environmentId,
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY
))
) {
throw new Error('Rewinding requires a newer Orca server. Update the server and try again.')
}
return method === 'agentSession.conversationCommand'
? callRuntimeRpc<TResult>(target, method, params, { timeoutMs: 195_000 })
: callRuntimeRpc<TResult>(target, method, params)
@@ -68,8 +68,9 @@ export function buildBrowserPage(
worktreeId,
url: normalizedUrl,
title: normalizeBrowserTitle(title, normalizedUrl, docLocation),
// Why: blank pages mount an inert guest (no real navigation); marking them loading would flash the loading affordance.
loading: normalizedUrl !== 'about:blank' && normalizedUrl !== ORCA_BROWSER_BLANK_URL,
// Why cold: a page owns no guest until it is first shown, and only a live guest may report
// loading. A background-opened tab therefore sits idle, and navigates on first activation.
loading: false,
faviconUrl: null,
canGoBack: false,
canGoForward: false,
+17 -1
View File
@@ -252,6 +252,22 @@ describe('createBrowserSlice annotations', () => {
expect(store.getState().activeBrowserTabIdByWorktree['wt-1']).toBeNull()
})
it('creates pages cold so a deferred guest is never owed a navigation', () => {
const store = createTestStore()
const tab = store.getState().createBrowserTab('wt-1', 'https://example.com', {
activate: false
})
store.getState().createBrowserPage(tab.id, 'https://example.com/second', { activate: false })
// Why: only a live guest reports loading; a background page has none until first shown.
expect(store.getState().browserPagesByWorkspace[tab.id]?.map((page) => page.loading)).toEqual([
false,
false
])
expect(tab.loading).toBe(false)
})
it('uses local browser profile defaults for client-local fallback pages', () => {
const store = createTestStore()
store.setState({
@@ -405,7 +421,7 @@ describe('createBrowserSlice annotations', () => {
expect(repaired).toMatchObject({
title: 'Example',
url: 'https://example.com',
loading: true,
loading: false,
canGoBack: false,
canGoForward: false
})
+8 -1
View File
@@ -1,3 +1,8 @@
import {
isAgentSessionRewindResult,
type AgentSessionRewindReason,
type AgentSessionRewindResult
} from './agent-session-rewind'
/**
* Durable client-operation ledger.
*
@@ -27,8 +32,9 @@ export type AgentSessionOperationOutcome =
status: 'succeeded'
sessionId: string
conversationCommand?: AgentSessionConversationCommandResult
rewind?: AgentSessionRewindResult
}
| { status: 'failed'; code: string; message?: string }
| { status: 'failed'; code: string; message?: string; rewindReason?: AgentSessionRewindReason }
/** The effect may or may not have happened; replay this answer instead of spawning again. */
| { status: 'unknown' }
@@ -186,6 +192,7 @@ export function isAgentSessionOperationRow(value: unknown): value is AgentSessio
((outcome.status === 'pending' && true) ||
(outcome.status === 'succeeded' &&
typeof outcome.sessionId === 'string' &&
(outcome.rewind === undefined || isAgentSessionRewindResult(outcome.rewind)) &&
(outcome.conversationCommand === undefined ||
isAgentSessionConversationCommandResult(outcome.conversationCommand))) ||
(outcome.status === 'failed' && typeof outcome.code === 'string') ||
+3
View File
@@ -1,3 +1,4 @@
import { isAgentSessionRewindRecord, type AgentSessionRewindRecord } from './agent-session-rewind'
/**
* Durable agent-session record and its single-writer lease.
*
@@ -129,6 +130,7 @@ export type AgentSessionRecord = {
accountHome: AgentSessionAccountHome
/** Provider options acknowledged for the next turn, restored across owner replacement. */
options?: Record<string, string>
rewind?: AgentSessionRewindRecord
conversationCommand?: AgentSessionConversationCommandRecord
launchArgs?: AgentSessionLaunchArgs
lease: AgentSessionLease
@@ -340,6 +342,7 @@ export function isAgentSessionRecord(value: unknown): value is AgentSessionRecor
isAgentSessionProviderHandleChain(record.providerHandleChain) &&
isAgentSessionAccountHome(record.accountHome) &&
(record.options === undefined || isAgentSessionOptions(record.options)) &&
(record.rewind === undefined || isAgentSessionRewindRecord(record.rewind)) &&
(record.conversationCommand === undefined ||
isAgentSessionConversationCommandRecord(record.conversationCommand)) &&
(record.launchArgs === undefined || isAgentSessionLaunchArgs(record.launchArgs)) &&
+60
View File
@@ -0,0 +1,60 @@
import { z } from 'zod'
import { AgentJournalItemBodySchema } from './agent-session-journal-schemas'
import { parseAgentJournalItemKey } from './agent-session-journal-item-key'
import type { AgentSessionMutationEnvelope } from './agent-session-wire'
export const AGENT_SESSION_REWIND_REASONS = [
'unsupported',
'history-not-paginated',
'busy',
'stale-epoch',
'invalid-target',
'history-limit',
'provider-refused',
'proof-mismatch',
'outcome-unknown'
] as const
export type AgentSessionRewindReason = (typeof AGENT_SESSION_REWIND_REASONS)[number]
export type AgentSessionRewindSupport =
| { supported: true }
| { supported: false; reason: AgentSessionRewindReason }
export type AgentSessionRewindParams = {
envelope: AgentSessionMutationEnvelope
itemId: string
expectedEpoch: string
}
export type AgentSessionRewindResult = { itemId: string; epoch: string }
const Key = z.string().min(1).max(4096)
export const AgentSessionRewindRecordSchema = z.object({
operationId: Key,
callerKey: Key,
itemId: Key,
providerItemId: Key.optional(),
expectedEpoch: Key,
phase: z.enum(['prepared', 'provider-succeeded', 'completed', 'refused']),
epoch: Key.optional(),
hydrationVerified: z.boolean().optional(),
providerApplied: z.boolean().optional(),
reason: z.string().min(1).max(512).optional(),
retained: z
.array(
z.object({
itemId: Key.refine((key) => parseAgentJournalItemKey(key) !== null),
body: AgentJournalItemBodySchema,
observedAt: z.number().finite()
})
)
.max(10_000)
})
export type AgentSessionRewindRecord = z.infer<typeof AgentSessionRewindRecordSchema>
export const isAgentSessionRewindRecord = (value: unknown): value is AgentSessionRewindRecord =>
AgentSessionRewindRecordSchema.safeParse(value).success
export function isAgentSessionRewindResult(value: unknown): value is AgentSessionRewindResult {
if (!value || typeof value !== 'object') {
return false
}
const result = value as Partial<AgentSessionRewindResult>
return typeof result.itemId === 'string' && typeof result.epoch === 'string'
}
+4
View File
@@ -1,3 +1,4 @@
import type { AgentSessionRewindReason, AgentSessionRewindSupport } from './agent-session-rewind'
import type { AgentSessionConversationCommand } from './agent-session-conversation-command'
// ─── Structured agent-session wire contract ─────────────────────────────────
// The shapes `agentSession.*` accepts and publishes. Phase 2 builds provider
@@ -194,6 +195,7 @@ export type AgentSessionSubscribeEvent =
* from the journal so no client has to replay a transcript to learn whether a
* turn is running. Additive surface: an older host has no such method. */
export type AgentSessionStatusSummary = {
rewindBlockedReason?: AgentSessionRewindReason
sessionId: string
workspaceId: string
agent: AgentSessionRecord['provider']
@@ -264,6 +266,7 @@ export function isAgentSessionWireRefusalCode(
}
export type AgentSessionWireRefusal = {
rewindReason?: AgentSessionRewindReason
code: AgentSessionWireRefusalCode
message: string
/** On a stale fence, so the client can retry without another round trip. */
@@ -354,6 +357,7 @@ export type AgentSessionCommandsResult = {
/** Provider-reported choices and effective next-turn values. Additive read-only
* surface so older hosts can reject it without changing structured v1 writes. */
export type AgentSessionOptionsResult = {
rewind?: AgentSessionRewindSupport
conversationCommands?: readonly AgentSessionConversationCommand[]
models: AgentSessionModelOption[]
current: {
+3
View File
@@ -156,6 +156,8 @@ export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY =
// advertising agent-session.structured.v1 may still answer it with method_not_found. Clients must
// probe before subscribing or they reconnect forever and never show any status at all.
export const AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY = 'agent-session.status-feed.v1' as const
// The RPC is registered unconditionally; per-session rewind support is a separate check.
export const AGENT_SESSION_REWIND_RUNTIME_CAPABILITY = 'agent-session.rewind.v1' as const
// Why: adding kimi to RESUMABLE_TUI_AGENTS grows terminal.ensureAgentSession's enum, and an
// older host answers the unknown member with invalid_argument — a code the launch fallback does
// not retry on — so clients must probe before taking the host-authority path.
@@ -259,6 +261,7 @@ export const RUNTIME_CAPABILITIES = [
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
GITHUB_MARK_PR_READY_RUNTIME_CAPABILITY,
@@ -24,10 +24,12 @@ import { AgentSessionRecordStore } from '../../../src/main/runtime/agent-session
import { computeAgentSessionPayloadFingerprint } from '../../../src/shared/agent-session-mutation-envelope'
import type { AgentSessionSubscribeEvent } from '../../../src/shared/agent-session-wire'
import {
AGENT_SESSION_REWIND_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../src/shared/protocol-version'
import { resolveBaselineReleaseRef } from './release-checkout'
import { structuredHostStub } from './structured-agent-session-host-fixture'
import {
loadAgentSessionWireBuild,
WORKING_TREE,
@@ -45,6 +47,7 @@ const THREAD = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60'
const NOW = 1_800_000_000_000
const CLIENT_CAPABILITY_UPDATE_METHOD = 'runtime.clientCapabilities.update'
const STATUS_FEED_METHOD = 'agentSession.subscribeStatus'
const REWIND_METHOD = 'agentSession.rewind'
/** Every method the structured surface publishes: the host method it must reach,
* and the result it must hand back. A gate that hides one method and leaks
@@ -75,6 +78,11 @@ const STRUCTURED_CALLS: {
},
{ method: 'agentSession.send', hostMethod: 'send', result: { ok: true, replayed: false } },
{ method: 'agentSession.cancel', hostMethod: 'cancel', result: { ok: true, replayed: false } },
{
method: REWIND_METHOD,
hostMethod: 'rewind',
result: { ok: true, replayed: false, value: { itemId: 'item-1', epoch: 'rewound-epoch' } }
},
{ method: 'agentSession.close', hostMethod: 'close', result: { ok: true } },
{
method: 'agentSession.respondToApproval',
@@ -226,6 +234,10 @@ function paramsFor(method: string): unknown {
}
case 'agentSession.send':
return sendParams('hi', fence)
case REWIND_METHOD: {
const fields = { itemId: 'item-1', expectedEpoch: 'current-epoch' }
return { envelope: envelope({ method, fields, fence }), ...fields }
}
case 'agentSession.cancel':
return {
envelope: envelope({ method: 'agentSession.cancel', fields: { turnId: 'turn-1' }, fence }),
@@ -328,47 +340,6 @@ async function callBuild(
return replies
}
/** The host every skew installs to drive the surface: enough of the real host's
* shape for each handler to run, and a spy per method so "which call reached the
* host" is answerable per call rather than per suite. */
function structuredHostStub(): Record<string, ReturnType<typeof vi.fn>> {
return {
attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId: SESSION } })),
// Attach-shaped entries take a client-supplied location, so the host is asked whether it
// supports creating there. A real host always answers; leaving it unstubbed made every
// `ensure` refuse for the harness's own reason rather than the location's.
supportsCreate: vi.fn(() => true),
conversationCommand: vi.fn(async () => ({
ok: true,
value: { command: 'compact', state: 'completed' }
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
sessionId: SESSION,
workspaceId: WORKSPACE,
agent: 'codex' as const,
readable: true
})),
hold: vi.fn(async () => undefined),
release: vi.fn(() => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })),
readCommands: vi.fn(() => ({ commands: [{ name: 'clear', kind: 'command' as const }] })),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
subscribeStatus: vi.fn((subscriber: { emit: (event: unknown) => void }) => {
subscriber.emit({ type: 'snapshot', sessions: [] })
return () => undefined
}),
unsubscribe: vi.fn()
}
}
/**
* The one thing this suite exists to guarantee, written once and applied per
* build: every method the manifest declares is not merely registered but reaches
@@ -436,7 +407,7 @@ describe('cross-version structured agent sessions', () => {
beforeEach(() => {
operations = 0
hostCalls = structuredHostStub()
hostCalls = structuredHostStub(SESSION, WORKSPACE)
setStructuredAgentSessionHost(hostCalls as unknown as StructuredAgentSessionHost)
})
@@ -495,6 +466,9 @@ describe('cross-version structured agent sessions', () => {
expect(build.capabilities.includes(AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY)).toBe(
build.methodNames.includes(STATUS_FEED_METHOD)
)
expect(build.capabilities.includes(AGENT_SESSION_REWIND_RUNTIME_CAPABILITY)).toBe(
build.methodNames.includes(REWIND_METHOD)
)
}
// Additive surface: bumping the protocol number would strand every paired
// device on this release rather than degrade one feature.
@@ -544,7 +518,7 @@ describe('cross-version structured agent sessions', () => {
// anti-vacuous guard: without it every host-backed method answers
// `structured_agent_session_unsupported`, the same words the capability
// gate uses, and the run would read as a refusal rather than a miss.
const hostCalls = structuredHostStub()
const hostCalls = structuredHostStub(SESSION, WORKSPACE)
await releasedCurrent.installStructuredHost(hostCalls)
try {
await expectDeclaredSurfaceExecutes(
@@ -0,0 +1,50 @@
import { vi } from 'vitest'
/** The host every skew installs to drive the surface: enough of the real host's
* shape for each handler to run, and a spy per method so "which call reached the
* host" is answerable per call rather than per suite. */
export function structuredHostStub(
sessionId: string,
workspaceId: string
): Record<string, ReturnType<typeof vi.fn>> {
return {
attach: vi.fn(async () => ({ ok: true, replayed: false, value: { sessionId } })),
// Attach-shaped entries take a client-supplied location, so the host is asked whether it
// supports creating there. A real host always answers; leaving it unstubbed made every
// `ensure` refuse for the harness's own reason rather than the location's.
supportsCreate: vi.fn(() => true),
conversationCommand: vi.fn(async () => ({
ok: true,
value: { command: 'compact', state: 'completed' }
})),
send: vi.fn(async () => ({ ok: true, replayed: false })),
cancel: vi.fn(async () => ({ ok: true, replayed: false })),
rewind: vi.fn(async () => ({
ok: true,
replayed: false,
value: { itemId: 'item-1', epoch: 'rewound-epoch' }
})),
close: vi.fn(async () => undefined),
revealSession: vi.fn(async () => ({
sessionId,
workspaceId,
agent: 'codex' as const,
readable: true
})),
hold: vi.fn(async () => undefined),
release: vi.fn(() => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({ status: { owner: 'native' } })),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({ models: [], current: { model: 'gpt-live' } })),
readCommands: vi.fn(() => ({ commands: [{ name: 'clear', kind: 'command' as const }] })),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
subscribeStatus: vi.fn((subscriber: { emit: (event: unknown) => void }) => {
subscriber.emit({ type: 'snapshot', sessions: [] })
return () => undefined
}),
unsubscribe: vi.fn()
}
}