Merge remote-tracking branch 'origin/main' into brennanb2025/nc-turn-rollup

This commit is contained in:
Merge Sim
2026-09-06 23:54:36 -07:00
103 changed files with 4437 additions and 749 deletions
+69
View File
@@ -10,6 +10,75 @@
}
},
"gates": [
{
"id": "terminal-performance.padded-fullscreen-redraw",
"title": "Fullscreen redraw padding does not stall terminal delivery",
"maturity": "experimental",
"protection": "partial",
"owner": "terminal-runtime",
"layer": "runtime-unit-and-electron-cdp",
"surfaces": ["terminal transcript preview", "fullscreen TUI scrolling"],
"platforms": ["macos", "linux", "windows"],
"providers": ["local", "daemon", "ssh", "remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["local", "daemon"],
"coverageNotes": "The trim operation is platform-independent and preserves the same spaces/tabs policy for all providers. Real Pi 0.84.2 was exercised in a hidden macOS Electron renderer through CDP using a folder workspace.",
"motivatingLinks": ["https://github.com/stablyai/orca/issues/14770"],
"invariant": "Transcript preview trimming preserves internal whitespace and terminal read contents without quadratic main-process work on padded fullscreen redraws.",
"oracle": "Preserve 32,000 spaces before a marker while trimming trailing spaces/tabs in both retained-row and carried-prefix redraw paths; four redraws must finish within 500 ms. Existing tail equivalence tests preserve cursor, retention, and pagination behavior.",
"commands": [
"ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts"
],
"testFiles": [
"src/main/runtime/terminal-tail-whitespace.test.ts",
"src/main/runtime/terminal-tail-buffer.test.ts",
"src/main/runtime/retained-tail-redraw-window.equivalence.test.ts"
],
"assertionRefs": [
{
"file": "src/main/runtime/terminal-tail-whitespace.test.ts",
"assertions": [
"handles padded redraws across %i retained rows without stalling",
"preserves terminal text while trimming spaces and tabs: %j"
]
}
],
"evidenceRuns": [
{
"date": "2026-09-06",
"runner": "local",
"platform": "macos",
"command": "ORCA_BACKGROUND_LAUNCH=1 pnpm test src/main/runtime/terminal-tail-whitespace.test.ts src/main/runtime/terminal-tail-buffer.test.ts src/main/runtime/retained-tail-redraw-window.equivalence.test.ts",
"result": "passed",
"durationSeconds": 3.96,
"summary": "17 tests passed. Before the fix both padding budget cases failed, taking approximately 1.7 seconds each."
}
],
"runtimeBudget": {
"p95Seconds": 30,
"scope": "Three unit test files; padding cases allow 500 ms for four redraws."
},
"flakeHistory": {
"status": "not-started",
"evidence": "Initial local red/green validation; no CI soak history yet."
},
"redGreenEvidence": {
"status": "complete",
"evidence": "Both padding budget cases fail with regex trimming and pass with the existing linear trim. A 60-event CDP wheel stream in Pi fullscreen had about 2.1 seconds of output tail before the fix and 14 ms after rebuilding."
},
"performanceBudget": {
"required": true,
"evidence": "The main CPU profile attributed 3.1 seconds to redraw-row whitespace trimming. Reusing the linear trim adds no timers, caches, provider calls, or output dropping."
},
"knownGaps": [
"The user manually compared the fixed dev app with production and confirmed improved responsiveness. A live Terminal.app comparison was not exercised; timing measurements used CDP wheel events.",
"Linux, Windows, and live SSH rendering were not exercised; the shared trimming behavior is covered by unit tests."
],
"promotionCriteria": [
"Complete CI soak requirements and retain the padding budget and tail equivalence oracles."
],
"demotionRule": "Keep experimental until CI soak is stable; investigate any budget failure without weakening transcript preservation."
},
{
"id": "ssh.localhost-terminal-agent-hooks",
"title": "Localhost SSH terminal and agent hooks reach the owning pane",
@@ -27,11 +27,24 @@ vi.mock('react-native', () => ({
vi.mock('lucide-react-native', () => ({ ChevronDown: 'ChevronDown', ChevronRight: 'ChevronRight' }))
vi.mock('../transport/client-context', () => ({ useForceReconnect: () => vi.fn() }))
// Captured at module scope: the list renders rows against Date.now() a few ms later,
// so a 3h offset stays inside the '3h' relative-time bucket.
const RENDER_NOW = Date.now()
function historyResponse(subject: string) {
return {
ok: true,
result: {
items: [{ id: 'commit-1', displayId: 'c0mm1t1', subject, author: 'Ada', parentIds: [] }]
items: [
{
id: 'commit-1',
displayId: 'c0mm1t1',
subject,
author: 'Ada',
parentIds: [],
timestamp: RENDER_NOW - 3 * 3_600_000
}
]
}
}
}
@@ -92,6 +105,9 @@ describe('MobileGitHistoryList', () => {
await render(client, 'connected')
expect(tree()).toContain('first load')
// Rows format the RPC timestamp (epoch ms); a regression to seconds-scaling
// renders every commit as 'just now' instead.
expect(tree()).toContain('3h')
await update(client, 'reconnecting')
expect(tree()).toContain('first load')
@@ -11,20 +11,21 @@ function item(overrides: Partial<GitHistoryItem> = {}): GitHistoryItem {
subject: 'feat: thing',
message: 'feat: thing\n\nbody',
author: 'Jane',
timestamp: NOW / 1000 - 3600,
timestamp: NOW - 3_600_000,
...overrides
}
}
describe('formatCommitTime', () => {
it('formats across thresholds', () => {
const s = NOW / 1000
expect(formatCommitTime(s - 30, NOW)).toBe('just now')
expect(formatCommitTime(s - 5 * 60, NOW)).toBe('5m')
expect(formatCommitTime(s - 3 * 3600, NOW)).toBe('3h')
expect(formatCommitTime(s - 2 * 86400, NOW)).toBe('2d')
expect(formatCommitTime(s - 60 * 86400, NOW)).toBe('2mo')
expect(formatCommitTime(s - 800 * 86400, NOW)).toBe('2y')
it('formats across thresholds from epoch-millisecond timestamps', () => {
// GitHistoryItem.timestamp is epoch ms (git-history-log-parser scales git %at by 1000).
const ms = { min: 60_000, hour: 3_600_000, day: 86_400_000 }
expect(formatCommitTime(NOW - 3 * ms.hour, NOW)).toBe('3h')
expect(formatCommitTime(NOW - 30_000, NOW)).toBe('just now')
expect(formatCommitTime(NOW - 5 * ms.min, NOW)).toBe('5m')
expect(formatCommitTime(NOW - 2 * ms.day, NOW)).toBe('2d')
expect(formatCommitTime(NOW - 60 * ms.day, NOW)).toBe('2mo')
expect(formatCommitTime(NOW - 800 * ms.day, NOW)).toBe('2y')
})
it('returns empty for missing timestamp', () => {
@@ -12,12 +12,13 @@ export type MobileCommitRow = {
}
// Short relative time for a commit list (just now / Xm / Xh / Xd / Xmo / Xy).
export function formatCommitTime(timestampSeconds: number | undefined, nowMs: number): string {
// `timestampMs` is epoch ms, the unit GitHistoryItem.timestamp already carries.
export function formatCommitTime(timestampMs: number | undefined, nowMs: number): string {
// Nullish — not falsy — so a real epoch-0 timestamp still formats.
if (timestampSeconds == null) {
if (timestampMs == null) {
return ''
}
const delta = nowMs - timestampSeconds * 1000
const delta = nowMs - timestampMs
if (delta < 60_000) {
return 'just now'
}
+6
View File
@@ -49,6 +49,12 @@ export function configureAiVaultSessionSources(next: AiVaultSessionSources): voi
sources = next
}
/** The extra Codex homes session discovery scans. Anything that decides what a listed row may be
* resumed from must read the same set, or a row can be listed and then refuse to resume. */
export function configuredAdditionalCodexHomePaths(): readonly string[] {
return sources.getAdditionalCodexHomePaths?.() ?? []
}
export async function listAiVaultSessions(
args?: AiVaultListArgs,
options: { signal?: AbortSignal } = {}
+1
View File
@@ -147,6 +147,7 @@ describe('registerRuntimeHandlers', () => {
}
const runtime = {
getRuntimeId: vi.fn().mockReturnValue('runtime-1'),
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(async () => undefined),
listMobileSessionTabs: vi.fn(async () => ({
worktree: 'workspace-1',
@@ -90,6 +90,24 @@ export async function importLegacyTranscriptIntoJournal(input: {
fence: number
options?: LegacyImportOptions
}): Promise<LegacyImportResult> {
const prepared = await prepareLegacyTranscriptImport(input)
if (!prepared.ok) {
return prepared
}
// An empty import must preserve any existing repair anchor and disclosure.
if (prepared.items.length === 0) {
const current = input.journal.cursor()
return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false }
}
const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, prepared.items)
return { ok: true, epoch: cursor.epoch, cursor, imported: prepared.items.length, replaced: true }
}
export async function prepareLegacyTranscriptImport(input: {
agent: AgentType
sessionId: string
options?: LegacyImportOptions
}): Promise<{ ok: true; items: JournalReplacementItem[] } | { ok: false; error: string }> {
const options = input.options ?? {}
const limits = options.limits ?? DEFAULT_JOURNAL_PAYLOAD_LIMITS
const transcriptAgent = resolveNativeChatTranscriptAgent(input.agent)
@@ -145,22 +163,7 @@ export async function importLegacyTranscriptIntoJournal(input: {
observedAt: message.timestamp ?? undefined
})
}
// A transcript that decodes to nothing reconstructs nothing, and an empty
// replacement is not a harmless no-op: it would delete the repair's anchor and
// its disclosure, leaving nothing to ask for the history again. The epoch
// stands so a later read can still rebuild it.
if (replacement.length === 0) {
const current = input.journal.cursor()
return { ok: true, epoch: current.epoch, cursor: current, imported: 0, replaced: false }
}
const cursor = await input.journal.replaceEpochItems('legacy_import', input.fence, replacement)
return {
ok: true,
epoch: cursor.epoch,
cursor,
imported: decoded.messages.length,
replaced: true
}
return { ok: true, items: replacement }
}
const TRANSCRIPT_DECODERS = {
@@ -0,0 +1,250 @@
// Source validation must finish before a new session claims the provider conversation.
import { mkdtemp, rm, writeFile, truncate } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import {
attachFingerprintFields,
type AgentSessionAttachParams
} from './structured-agent-session-attach'
import { performAttach, type AttachFlowInput } from './structured-agent-session-attach-flow'
import { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry'
import * as legacyImport from '../agent-session-journal/journal-legacy-import'
const NOW = 1_800_000_000_000
const SESSION = 'codex_adopting_session'
const THREAD = 'adopted-thread'
const OPERATION = `${NOW}-${'1'.padStart(32, '0')}`
let root: string | null = null
let store: AgentSessionRecordStore | null = null
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true })
}
root = null
store = null
vi.restoreAllMocks()
})
/** A minimal Codex rollout the legacy transcript decoder can read back. */
async function writeCodexRollout(path: string, text: string): Promise<void> {
const lines = [
JSON.stringify({
type: 'session_meta',
payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' }
}),
JSON.stringify({
type: 'response_item',
timestamp: '2026-09-06T18:00:01.000Z',
payload: {
type: 'message',
role: 'user',
content: text
}
})
]
await writeFile(path, `${lines.join('\n')}\n`, 'utf8')
}
function attachParams(transcriptPath?: string): AgentSessionAttachParams {
const params: AgentSessionAttachParams = {
envelope: {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: null,
payloadFingerprint: ''
},
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
adopt: {
providerHandle: { kind: 'codex', threadId: THREAD },
...(transcriptPath ? { transcriptPath } : {})
}
}
return {
...params,
envelope: {
...params.envelope,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: SESSION,
fields: attachFingerprintFields(params)
})
}
}
}
function adapter(): StructuredAgentSessionAdapter {
return {
acquire: vi
.fn<StructuredAgentSessionAdapter['acquire']>()
.mockImplementation(async ({ fence, spawnToken }) => ({
process: { hostId: 'local', pid: 4242, processStartTimeMs: NOW, spawnToken },
link: {
linkId: 'resumed-link',
handle: { provider: 'codex', threadId: THREAD },
origin: 'resumed',
mintedAtFence: fence,
observedAt: NOW
}
})),
// Proven released, so the failure rethrows its own cause rather than an unproven-exit wrapper.
releaseAcquisition: vi.fn(async () => true),
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
}
}
async function attach(
transcriptPath: string | undefined,
sessionAdapter: StructuredAgentSessionAdapter,
onAttached: AttachFlowInput['onAttached'] = () => {}
) {
store ??= await AgentSessionRecordStore.open({ directory: join(root!, 'store'), hostId: 'local' })
return performAttach({
store,
adapter: sessionAdapter,
journalRoot: root!,
authority: {
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: OPERATION,
probe: { outcome: 'reservation-unused' }
},
callerKey: 'client-1',
params: attachParams(transcriptPath),
now: () => NOW,
onAttached
})
}
describe('adopting a provider conversation on create', () => {
it('seeds the chain from the adopted handle and fills the journal from its transcript', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-adopt-import-'))
const transcriptPath = join(root, 'rollout.jsonl')
await writeCodexRollout(transcriptPath, 'token ORCA-ADOPT-1')
const sessionAdapter = adapter()
const result = await attach(transcriptPath, sessionAdapter)
expect(result).toMatchObject({ ok: true })
// The adapter was asked to resume, not to start: the seeded chain is what tells it which
// conversation this session owns.
const page = (result as { value: { page: { items: unknown[] } } }).value.page
expect(JSON.stringify(page.items)).toContain('ORCA-ADOPT-1')
})
it('replays create without replacing journal-only messages or rereading the source', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-adopt-replay-'))
const transcriptPath = join(root, 'rollout.jsonl')
await writeCodexRollout(transcriptPath, 'original turn')
const sessionAdapter = adapter()
const first = await attach(transcriptPath, sessionAdapter, async ({ journal }) => {
await journal.appendItem(
{ provider: 'legacy', agent: 'codex', sessionId: THREAD, recordId: 'journal-only' },
{ kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'not yet in rollout' }] },
{ fence: 1 }
)
await journal.close()
})
expect(first.ok).toBe(true)
await rm(transcriptPath)
const replay = await attach(transcriptPath, sessionAdapter, async ({ journal }) =>
journal.close()
)
expect(replay).toMatchObject({ ok: true, replayed: true })
if (!first.ok || !replay.ok) {
throw new Error('attach failed')
}
expect(replay.cursor.epoch).toBe(first.cursor.epoch)
expect(JSON.stringify(replay.value.page.items)).toContain('not yet in rollout')
expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1)
})
it.each(['missing', 'oversized', 'empty', 'invalid', 'source-less'] as const)(
'refuses %s source before claiming a conversation',
async (kind) => {
root = await mkdtemp(join(tmpdir(), 'orca-adopt-preflight-'))
const transcriptPath = join(root, 'rollout.jsonl')
if (kind === 'oversized') {
await writeCodexRollout(transcriptPath, 'original turn')
await truncate(transcriptPath, 16 * 1024 * 1024 + 1)
} else if (kind === 'empty' || kind === 'invalid') {
await writeFile(transcriptPath, kind === 'empty' ? '' : 'not json\n')
}
const sessionAdapter = adapter()
const onAttached = vi.fn()
const result = await attach(
kind === 'source-less' ? undefined : transcriptPath,
sessionAdapter,
onAttached
)
expect(result).toMatchObject({
ok: false,
refusal: { code: 'agent_session_identity_required' }
})
expect(sessionAdapter.acquire).not.toHaveBeenCalled()
expect(sessionAdapter.releaseAcquisition).not.toHaveBeenCalled()
expect(onAttached).not.toHaveBeenCalled()
expect(store?.getRecord(SESSION)).toBeNull()
expect(store?.listOperationRows()).toEqual([])
if (kind === 'oversized') {
expect(JSON.stringify(result)).toContain('import bound')
}
}
)
it('still releases acquisition and closes the provisional journal on an import write failure', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-adopt-write-failure-'))
const transcriptPath = join(root, 'rollout.jsonl')
await writeCodexRollout(transcriptPath, 'valid source')
vi.spyOn(AgentSessionJournal.prototype, 'replaceEpochItems').mockRejectedValueOnce(
new Error('disk write failed')
)
const close = vi.spyOn(agentSessionJournalCloseRetries, 'closeOrRetain')
const sessionAdapter = adapter()
await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow('disk write failed')
expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1)
expect(sessionAdapter.releaseAcquisition).toHaveBeenCalledTimes(1)
expect(close).toHaveBeenCalledTimes(1)
})
it('prepares a valid source once before acquisition and imports those exact items', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-adopt-once-'))
const transcriptPath = join(root, 'rollout.jsonl')
await writeCodexRollout(transcriptPath, 'prepared before acquiring')
const prepare = vi.spyOn(legacyImport, 'prepareLegacyTranscriptImport')
const sessionAdapter = adapter()
const acquire = sessionAdapter.acquire
sessionAdapter.acquire = vi.fn(async (input) => {
expect(prepare).toHaveBeenCalledTimes(1)
await rm(transcriptPath)
return acquire(input)
})
const result = await attach(transcriptPath, sessionAdapter, async ({ journal }) =>
journal.close()
)
expect(result.ok).toBe(true)
if (!result.ok) {
throw new Error('attach failed')
}
expect(JSON.stringify(result.value.page.items)).toContain('prepared before acquiring')
expect(prepare).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,110 @@
import type { AgentSessionWireRefusal } from '../../../shared/agent-session-wire'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionAttachParams, AttachedJournal } from './structured-agent-session-attach'
import { agentSessionJournalCloseRetries } from '../agent-session-journal/journal-close-retry'
import type { JournalReplacementItem } from '../agent-session-journal/journal-epoch-replacement'
import {
importLegacyTranscriptIntoJournal,
prepareLegacyTranscriptImport
} from '../agent-session-journal/journal-legacy-import'
export async function prepareAdoptedTranscript(
params: AgentSessionAttachParams
): Promise<
| { ok: true; items: JournalReplacementItem[] | null }
| { ok: false; refusal: AgentSessionWireRefusal }
> {
try {
return { ok: true, items: await readAdoptedTranscript(params) }
} catch (error) {
return {
ok: false,
refusal: {
code: 'agent_session_identity_required',
message: error instanceof Error ? error.message : String(error)
}
}
}
}
// Validate source input before a new record can claim the provider conversation.
async function readAdoptedTranscript(
params: AgentSessionAttachParams
): Promise<JournalReplacementItem[] | null> {
const adopt = params.adopt
if (!adopt) {
return null
}
if (!adopt.transcriptPath) {
throw new Error('agent_session_identity_required')
}
const prepared = await prepareLegacyTranscriptImport({
agent: params.agent,
sessionId:
adopt.providerHandle.kind === 'claude'
? adopt.providerHandle.sessionId
: adopt.providerHandle.threadId,
options: { filePath: adopt.transcriptPath }
})
if (!prepared.ok) {
throw new Error(prepared.error)
}
if (prepared.items.length === 0) {
throw new Error('agent_session_identity_required')
}
return prepared.items
}
// Import before publication so the first visible chat agrees with the provider's resumed context.
export async function importAdoptedTranscript(
params: AgentSessionAttachParams,
attached: AttachedJournal,
record: AgentSessionRecord,
prepared: JournalReplacementItem[] | null
): Promise<void> {
try {
await applyAdoptedTranscript(params, attached, record, prepared)
} catch (error) {
// Publication has not taken ownership of this provisional journal yet.
await agentSessionJournalCloseRetries.closeOrRetain(attached.journal)
throw error
}
}
async function applyAdoptedTranscript(
params: AgentSessionAttachParams,
attached: AttachedJournal,
record: AgentSessionRecord,
prepared: JournalReplacementItem[] | null
): Promise<void> {
const adopt = params.adopt
// A new journal contains only its epoch row; replay must preserve subsequent durable writes.
if (!adopt || attached.journal.cursor().sequence > 1) {
return
}
if (prepared) {
await attached.journal.replaceEpochItems('legacy_import', record.lease.runtimeFence, prepared)
return
}
if (!adopt.transcriptPath) {
throw new Error('agent_session_identity_required')
}
const imported = await importLegacyTranscriptIntoJournal({
journal: attached.journal,
agent: params.agent,
sessionId:
adopt.providerHandle.kind === 'claude'
? adopt.providerHandle.sessionId
: adopt.providerHandle.threadId,
fence: record.lease.runtimeFence,
options: { filePath: adopt.transcriptPath }
})
if (!imported.ok) {
throw new Error(imported.error)
}
// `replaced: false` means the transcript decoded to nothing. The row promised a conversation and
// the provider resumed one, so an empty journal here is a disagreement, not an empty chat.
if (!imported.replaced) {
throw new Error('agent_session_identity_required')
}
}
@@ -36,6 +36,10 @@ import type { StructuredAgentSessionEventSink } from './structured-agent-session
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
import { resolveAgentSessionReplayOutcome } from './structured-agent-session-replay-outcome'
import { readAgentSessionHydrationPage } from './agent-session-history-page'
import {
importAdoptedTranscript,
prepareAdoptedTranscript
} from './structured-agent-session-adopted-import'
export type AttachFlowInput = {
store: AgentSessionRecordStore
@@ -78,6 +82,12 @@ export async function performAttach(
let acquisitionGeneration: string | null = null
let reservedRecord: AgentSessionRecord | null = null
let replayed = false
const preparedTranscript = store.getRecord(sessionId)
? { ok: true as const, items: null }
: await prepareAdoptedTranscript(params)
if (!preparedTranscript.ok) {
return preparedTranscript
}
try {
const reserved = await store.reserveOwner(
reserveRequestFor({
@@ -181,6 +191,7 @@ export async function performAttach(
journalRoot: input.journalRoot,
adapter: input.adapter
})
await importAdoptedTranscript(params, attached, record, preparedTranscript.items)
await input.onAttached(attached, acquisitionGeneration)
await store.recordOperationOutcome({
callerKey: input.callerKey,
@@ -10,7 +10,12 @@ import type {
AgentSessionProviderHandle
} from '../../../shared/agent-session-journal-types'
import type { AgentSessionOwnerProbe } from '../../../shared/agent-session-lease-adjudication'
import type { AgentSessionHandleProvider } from '../../../shared/agent-session-provider-handle'
import type {
AgentSessionHandleProvider,
AgentSessionProviderHandleLink
} from '../../../shared/agent-session-provider-handle'
import { claudeProviderHandleLink } from '../../claude/claude-structured-owner-identity'
import { codexProviderHandleLink } from '../../codex/codex-structured-owner-identity'
import type {
AgentSessionAccountHome,
AgentSessionExecutionLocation,
@@ -59,6 +64,19 @@ export type AgentSessionAttachParams = {
launchArgs?: string[]
/** Omitted only for create-by-intent; the adapter proves the durable handle. */
providerHandle?: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
/**
* Host-resolved only. Present when this create adopts an existing provider conversation rather
* than starting one: it seeds the handle chain so the adapter resumes instead of creating, and
* names the transcript to import so the journal shows the conversation so far.
*
* Deliberately separate from `providerHandle`, which `agentSession.ensure` already supplies
* without adopting — presence of a handle must never be what triggers a resume.
*/
adopt?: {
providerHandle: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
/** Omitted only when the exact committed operation replays an already-imported journal. */
transcriptPath?: string
}
}
/** Host-supplied half of the reservation. */
@@ -85,6 +103,10 @@ export function attachFingerprintFields(params: AgentSessionAttachParams): Recor
accountHome: params.accountHome,
runtimeKind: params.runtimeKind,
providerHandle: params.providerHandle,
// Which conversation this attaches to, so an adopting create and a blank one never share an
// identity. The transcript path is excluded: it is where the host found that conversation this
// time, not part of what the caller asked for.
adoptedProviderHandle: params.adopt?.providerHandle,
expectedRuntimeFence: params.envelope.expectedRuntimeFence
}
}
@@ -183,6 +205,38 @@ export async function attachJournal(input: {
}
}
/**
* The first link of an adopting session's chain.
*
* `adopted` is the only origin besides `created` a chain will accept at its head, and it is the
* honest one here: this session did not create the conversation. The adapter appends its own
* `resumed` link once the provider proves the same identity root — or, when it proves the identical
* handle at the same fence, the validator elides that as a retry and this link stays the head.
*/
const ADOPTED_HANDLE_FENCE = 1
function adoptedProviderHandleLink(
handle: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>,
observedAt: number
): AgentSessionProviderHandleLink {
return handle.kind === 'claude'
? claudeProviderHandleLink({
sessionId: handle.sessionId,
leafUuid: handle.leafUuid,
resumed: false,
origin: 'adopted',
fence: ADOPTED_HANDLE_FENCE,
observedAt
})
: codexProviderHandleLink({
threadId: handle.threadId,
resumed: false,
origin: 'adopted',
fence: ADOPTED_HANDLE_FENCE,
observedAt
})
}
export function reserveRequestFor(input: {
sessionId: string
params: AgentSessionAttachParams
@@ -201,6 +255,13 @@ export function reserveRequestFor(input: {
...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}),
...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}),
runtimeKind: params.runtimeKind,
...(params.adopt
? {
// Fence 1 is a new record's first, and the owner probe requires the head link to carry
// the record's current fence.
adoptedHandleLink: adoptedProviderHandleLink(params.adopt.providerHandle, input.now)
}
: {}),
expectedFence: params.envelope.expectedRuntimeFence,
spawnToken: authority.spawnToken,
claimKeyId: authority.claimKeyId,
@@ -70,6 +70,7 @@ async function failingFlowRunner(
throw new Error('unused')
},
importTuiHistory: async () => {},
retryPendingSettlement: async () => true,
publish: () => {},
schedule: async () => {
throw new Error('scheduling failed')
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../../shared/agent-session-record.test-fixture'
import type { StructuredAgentSessionHandoffDeps } from './structured-agent-session-handoff-types'
import { closeRetainedTuiOwner } from './structured-agent-session-handoff-owner-close'
const NOW = 1_800_000_000_000
describe('closeRetainedTuiOwner', () => {
it('does not latch unexpected-exit settlement after an intentional close', async () => {
let record = agentSessionRecordFixture(agentSessionLeaseFixture())
const closeTuiOwner = vi.fn(async () => ({}))
const releaseOwner = vi.fn()
const owner = {
terminal: { handle: 'terminal-1', tabId: 'tab-1', paneKey: 'pane-1', ptyId: 'pty-1' },
process: record.lease.ownerProcess!,
link: record.providerHandleChain[0]!
}
const deps = {
store: {
transitionHandoff: async (
_sessionId: string,
transition: (current: typeof record) => typeof record
) => {
record = transition(record)
return record
}
},
transport: { closeTuiOwner },
now: () => NOW
} as unknown as StructuredAgentSessionHandoffDeps
await closeRetainedTuiOwner({
sessionId: record.sessionId,
deps,
owner: () => owner,
requireRecord: () => record,
releaseOwner
})
expect(closeTuiOwner).toHaveBeenCalledWith(owner)
expect(releaseOwner).toHaveBeenCalledWith(record.sessionId)
expect(record.lease).toMatchObject({
claimStatus: 'released',
deathEvidence: { kind: 'exit-observed' }
})
expect(record.lease.settlementRetryRequired).toBeUndefined()
expect(record.lease.settlementRetryId).toBeUndefined()
})
})
@@ -26,7 +26,8 @@ export async function closeRetainedTuiOwner(input: {
record: current,
expectedFence: record.lease.runtimeFence,
probe: { outcome: 'exit-observed' },
now: input.deps.now()
now: input.deps.now(),
journalSettlement: 'not-required'
})
)
input.releaseOwner(input.sessionId)
@@ -95,6 +95,13 @@ export async function handoffStructuredSessionToNative(
...(transcriptPath ? { transcriptPath } : {})
})
}
if (record.lease.settlementRetryRequired) {
const settled = await deps.retryPendingSettlement(sessionId)
if (!settled) {
throw new Error('The provider-exit terminal journal settlement is still pending.')
}
record = context.requireRecord(sessionId)
}
const spawnToken = randomUUID()
record = await reserveStoredAgentSessionHandoffOwner(deps.store, {
sessionId,
@@ -73,6 +73,7 @@ export function createStructuredAgentSessionHandoffTestCoordinator(
{ fence, recovered: true }
)
},
retryPendingSettlement: async () => true,
publish: (_sessionId, status) => input.statuses.push(status),
schedule: async (_sessionId, task) => task(),
now: () => input.now
@@ -81,6 +81,7 @@ export type StructuredAgentSessionHandoffDeps = {
fence: number
transcriptPath?: string
}) => Promise<void>
retryPendingSettlement: (sessionId: string) => Promise<boolean>
prepareTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise<void>
recoverTuiHistoryCatchup?: (sessionId: string, fence: number) => Promise<void>
activateTuiHistoryCatchup?: (sessionId: string) => Promise<void>
@@ -179,6 +179,7 @@ function createCoordinator(): StructuredAgentSessionHandoffCoordinator {
{ fence, recovered: true }
)
},
retryPendingSettlement: async () => true,
prepareTuiHistoryCatchup,
recoverTuiHistoryCatchup,
activateTuiHistoryCatchup,
@@ -274,6 +275,7 @@ describe('structured session handoff failure handling', () => {
}),
acquireNativeStop: (_sessionId, turnId) => acquireNativeStop(turnId),
importTuiHistory: vi.fn(async () => undefined),
retryPendingSettlement: vi.fn(async () => true),
prepareTuiHistoryCatchup,
recoverTuiHistoryCatchup,
activateTuiHistoryCatchup,
@@ -14,6 +14,7 @@ import { recoverDeadTuiHandoffStatus } from './structured-agent-session-dead-tui
import { readNativeSessionOptions } from './structured-agent-session-option-restoration'
import type { AgentSessionSubscribers } from './structured-agent-session-subscribers'
import { StructuredTuiTranscriptCatchup } from './structured-tui-transcript-catchup'
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
type HostHandoffAccess = {
session: (sessionId: string) => StructuredAgentSessionHostSession
@@ -92,6 +93,13 @@ export function createStructuredAgentSessionHostHandoff(
acquireNativeStop: async (sessionId, turnId, fence) =>
(await deps.adapter.cancelTurn({ sessionId, turnId, fence })).cancelled,
importTuiHistory: (input) => importTuiHistory(deps, host, input),
retryPendingSettlement: (sessionId) =>
retryLoadedStructuredAgentSessionSettlement({
deps,
sessionId,
session: host.session(sessionId),
now: host.now
}),
prepareTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.prepare(sessionId, fence),
recoverTuiHistoryCatchup: (sessionId, fence) => tuiHistoryCatchup.recover(sessionId, fence),
activateTuiHistoryCatchup: (sessionId) => tuiHistoryCatchup.activate(sessionId),
@@ -127,7 +127,7 @@ export class StructuredAgentSessionHost {
),
evict: (sessionId) => this.close(sessionId)
})
this.restore = createStructuredAgentSessionHostRestore(deps, {
this.restore = createStructuredAgentSessionHostRestore(deps, this.sessions, () => this.now(), {
reconcile: this.reconcileLeases,
resolveRecovery: (sessionId) => this.runtimeState.resolveRecovery(sessionId),
serialize: (sessionId, task) => this.serialize(sessionId, task),
@@ -104,6 +104,7 @@ describe('structured session live TUI restart survival', () => {
suspendNative: vi.fn(),
acquireNative: vi.fn(),
importTuiHistory: vi.fn(),
retryPendingSettlement: vi.fn(async () => true),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -224,6 +225,7 @@ describe('structured session live TUI restart survival', () => {
suspendNative: vi.fn(),
acquireNative: vi.fn(),
importTuiHistory: vi.fn(),
retryPendingSettlement: vi.fn(async () => true),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -3,12 +3,14 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import type { AgentSessionHandoffRequest } from '../../../shared/agent-session-wire'
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { recoverStoredDeadTuiOwnerForHandoff } from '../../runtime/agent-session-handoff-record-transitions'
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { StructuredAgentSessionHandoffCoordinator } from './structured-agent-session-handoff'
import type { StructuredAgentSessionHandoffTransport } from './structured-agent-session-handoff-types'
import { retryLoadedStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
const NOW = 1_800_000_000_000
const SESSION = 'session-proven-dead-retry'
@@ -89,6 +91,15 @@ describe('structured session proven-dead TUI retry', () => {
},
journalDir: join(root, 'journal')
})
await journal.appendItem(
{ provider: 'orca', clientMessageId: 'running-turn' },
{
kind: 'status',
text: 'Working',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: store.getRecord(SESSION)?.lease.runtimeFence ?? tuiFence }
)
const closeTuiOwner =
vi.fn<NonNullable<StructuredAgentSessionHandoffTransport['closeTuiOwner']>>()
const coordinator = new StructuredAgentSessionHandoffCoordinator({
@@ -134,6 +145,17 @@ describe('structured session proven-dead TUI retry', () => {
},
acquireNativeStop: vi.fn(async () => true),
importTuiHistory: vi.fn(),
retryPendingSettlement: (sessionId) =>
retryLoadedStructuredAgentSessionSettlement({
deps: { store },
sessionId,
session: {
journal,
fence: store.getRecord(sessionId)?.lease.runtimeFence ?? 1,
acquisitionGeneration: null
},
now: () => NOW
}),
publish: vi.fn(),
schedule: async (_sessionId, task) => task(),
now: () => NOW
@@ -174,5 +196,7 @@ describe('structured session proven-dead TUI retry', () => {
claimStatus: 'live',
handoffStage: null
})
expect(store.getRecord(SESSION)?.lease.settlementRetryRequired).toBeUndefined()
expect(activeStructuredAgentSessionTurnId(journal.snapshot().items)).toBe(null)
})
})
@@ -27,6 +27,7 @@ describe('StructuredAgentSessionReadableRestorer', () => {
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined,
retrySettlement: async () => true,
restoreHandoff: async () => undefined
})
@@ -20,6 +20,10 @@ export class StructuredAgentSessionReadableRestorer {
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
retrySettlement: (
sessionId: string,
params: RestoredStructuredAgentSessionRead['params']
) => Promise<boolean>
restoreHandoff: (sessionId: string) => Promise<void>
}
) {}
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionAttachParams } from './structured-agent-session-attach'
const { restoreRead } = vi.hoisted(() => ({
restoreRead: vi.fn()
@@ -9,7 +10,10 @@ vi.mock('./structured-agent-session-read-restore', () => ({
restoreStructuredAgentSessionRead: restoreRead
}))
import { restoreStructuredAgentSessionsOnRestart } from './structured-agent-session-restart-restore'
import {
restoreOneStructuredAgentSessionRead,
restoreStructuredAgentSessionsOnRestart
} from './structured-agent-session-restart-restore'
describe('restart journal restoration', () => {
beforeEach(() => restoreRead.mockReset())
@@ -45,6 +49,7 @@ describe('restart journal restoration', () => {
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => undefined,
retrySettlement: async () => true,
restoreHandoff: async () => undefined
})
@@ -56,4 +61,96 @@ describe('restart journal restoration', () => {
expect(restoreRead).toHaveBeenCalledTimes(records.length)
expect(peak).toBe(4)
})
it('runs pending settlement retry after recovery resolution and before handoff', async () => {
const calls: string[] = []
const params: AgentSessionAttachParams = {
envelope: {
sessionId: 'session-1',
clientOperationId: 'read-restore:session-1',
expectedRuntimeFence: 4,
payloadFingerprint: 'fingerprint'
},
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'folder'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/tmp/codex' },
runtimeKind: 'native'
}
restoreRead.mockResolvedValue({
journal: {},
params,
fence: 4,
hasProviderChild: false,
acquisitionGeneration: null
})
await restoreOneStructuredAgentSessionRead(
{
store: {} as never,
journalRoot: '/tmp/journals',
reconcile: async () => null,
resolveRecovery: async () => {
calls.push('resolveRecovery')
},
serialize: async (_sessionId, task) => task(),
hasSession: () => false,
onReadable: () => {
calls.push('onReadable')
},
retrySettlement: async (_sessionId, restoredParams) => {
calls.push(
restoredParams === params ? 'retrySettlement:restored-params' : 'retrySettlement'
)
return true
},
restoreHandoff: async () => {
calls.push('restoreHandoff')
}
},
'session-1'
)
expect(calls).toEqual([
'resolveRecovery',
'onReadable',
'retrySettlement:restored-params',
'restoreHandoff'
])
})
it('does not rerun settlement retry when a second restore finds the session already open', async () => {
const retrySettlement = vi.fn(async () => true)
const restoreHandoff = vi.fn(async () => undefined)
restoreRead.mockResolvedValue({
journal: {},
params: {},
fence: 4,
hasProviderChild: false,
acquisitionGeneration: null
})
await restoreOneStructuredAgentSessionRead(
{
store: {} as never,
journalRoot: '/tmp/journals',
reconcile: async () => null,
resolveRecovery: async () => undefined,
serialize: async (_sessionId, task) => task(),
hasSession: () => true,
onReadable: () => undefined,
retrySettlement,
restoreHandoff
},
'session-1'
)
expect(retrySettlement).not.toHaveBeenCalled()
expect(restoreHandoff).toHaveBeenCalledOnce()
})
})
@@ -30,6 +30,10 @@ export type StructuredAgentSessionReadRestoreDeps = {
serialize: <T>(sessionId: string, task: () => Promise<T>) => Promise<T>
hasSession: (sessionId: string) => boolean
onReadable: (sessionId: string, restored: RestoredStructuredAgentSessionRead) => void
retrySettlement: (
sessionId: string,
params: RestoredStructuredAgentSessionRead['params']
) => Promise<boolean>
restoreHandoff: (sessionId: string) => Promise<void>
}
@@ -64,6 +68,7 @@ export async function restoreOneStructuredAgentSessionRead(
return
}
input.onReadable(sessionId, restored)
await input.retrySettlement(sessionId, restored.params)
await input.restoreHandoff(sessionId)
})
}
@@ -58,6 +58,7 @@ function harness(
serialize,
hasSession: (sessionId) => live.has(sessionId),
onReadable: (sessionId, restored) => live.set(sessionId, restored),
retrySettlement: async () => true,
restoreHandoff
})
return { restorer, live, restoreHandoff, serializedIds }
@@ -16,8 +16,10 @@ import { StructuredAgentSessionReadableRestorer } from './structured-agent-sessi
import { StructuredAgentSessionRestartRestoreGate } from './structured-agent-session-restart-restore-gate'
import type {
StructuredAgentSessionHostDeps,
StructuredAgentSessionHostSession,
StructuredAgentSessionReveal
} from './structured-agent-session-host-types'
import { retryPendingStructuredAgentSessionSettlement } from './structured-agent-session-settlement-retry'
/** Throws its refusal as the code itself, matching `resumeHeldStructuredAgentSession`. */
export async function revealStructuredAgentSession(
@@ -55,9 +57,11 @@ export async function revealStructuredAgentSession(
*/
export function createStructuredAgentSessionHostRestore(
deps: StructuredAgentSessionHostDeps,
sessions: Map<string, StructuredAgentSessionHostSession>,
now: () => number,
wiring: Omit<
ConstructorParameters<typeof StructuredAgentSessionReadableRestorer>[0],
'store' | 'journalRoot' | 'supportsRecord'
'store' | 'journalRoot' | 'supportsRecord' | 'retrySettlement'
>
): {
restoreReadableSessions: (sessionIds?: readonly string[]) => Promise<void>
@@ -67,6 +71,8 @@ export function createStructuredAgentSessionHostRestore(
store: deps.store,
journalRoot: deps.journalRoot,
supportsRecord: (record) => adapterSupportsRecord(deps.adapter, record),
retrySettlement: (sessionId, params) =>
retryPendingStructuredAgentSessionSettlement({ deps, sessions, sessionId, params, now }),
...wiring
})
const gate = new StructuredAgentSessionRestartRestoreGate()
@@ -46,15 +46,27 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
hasProviderChild: false,
acquisitionGeneration: null
} as StructuredAgentSessionHostSession)
return retryLoadedStructuredAgentSessionSettlement({
deps: input.deps,
sessionId: input.sessionId,
session: retrySession,
now: input.now
})
}
export async function retryLoadedStructuredAgentSessionSettlement(input: {
deps: Pick<StructuredAgentSessionHostDeps, 'store' | 'onEventSinkError'>
sessionId: string
session: Pick<StructuredAgentSessionHostSession, 'journal' | 'fence' | 'acquisitionGeneration'>
now: () => number
}): Promise<boolean> {
const record = input.deps.store.getRecord(input.sessionId)
if (!record?.lease.settlementRetryRequired || !record.lease.settlementRetryId) {
return true
}
const retrySession = input.session
retrySession.fence = record.lease.runtimeFence
const context: StructuredAgentSessionUnexpectedExitContext = {
store: input.deps.store,
sessions: input.sessions,
flushLifecycle: async () => ({ ok: true as const }),
publishFence: () => undefined,
hasResumeCapableHolder: () => false,
serialize: async <T>(_id: string, task: () => Promise<T>) => task(),
now: input.now,
const context: Pick<StructuredAgentSessionUnexpectedExitContext, 'onBarrierError'> = {
onBarrierError: (id, error) => input.deps.onEventSinkError?.({ sessionId: id, error })
}
const ok = await retryUnexpectedExitSettlement({
@@ -65,7 +77,7 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
reason: record.lease.deathEvidence?.detail ?? 'provider exited',
cause: 'unexpected-exit',
fence: record.lease.runtimeFence,
acquisitionGeneration: current?.acquisitionGeneration ?? 'recovery'
acquisitionGeneration: retrySession.acquisitionGeneration ?? 'recovery'
},
session: retrySession,
stableSettlementId: record.lease.settlementRetryId
@@ -81,11 +93,14 @@ export async function retryPendingStructuredAgentSessionSettlement(input: {
) {
throw new Error('agent_session_checkpoint_stale')
}
// A dead-TUI retry still needs its stopped-owner stage; recovery-only stages end here.
const preserveHandoff = latest.lease.handoffStage === 'old-owner-stopped'
return {
...latest,
lease: {
...latest.lease,
handoffStage: null,
handoffStage: preserveHandoff ? latest.lease.handoffStage : null,
handoffOperationId: preserveHandoff ? latest.lease.handoffOperationId : null,
settlementRetryRequired: undefined,
settlementRetryId: undefined,
lastRenewedAt: input.now()
@@ -74,6 +74,52 @@ describe('performCancel', () => {
])
})
it('keeps the running lifecycle when cancellation cannot be confirmed', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-turn-cancel-unconfirmed-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
await journal.appendItem(
{
provider: 'legacy',
agent: 'codex',
sessionId: 'session-1',
recordId: 'turn-lifecycle:turn-1'
},
{
kind: 'status',
text: 'Agent is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: 1 }
)
const ctx: AgentSessionTurnContext = {
sessionId: 'session-1',
journal,
fence: 1,
adapter: {
cancelTurn: vi.fn(async () => ({ cancelled: false }))
} as unknown as StructuredAgentSessionAdapter,
persistOptions: async () => undefined,
resolvedBy: 'client-1',
publish: vi.fn(),
now: () => 1
}
const result = await performCancel(ctx, {
clientOperationId: 'cancel-unconfirmed-1',
turnId: 'turn-1'
})
expect(result).toEqual({ ok: true, value: { turnId: 'turn-1', cancelled: false } })
expect(journal.snapshot().items.map((item) => item.body)).toEqual([
{
kind: 'status',
text: 'Agent is working…',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ kind: 'status', text: 'The provider had already finished this turn.' }
])
})
it('stops background tasks without interrupting the foreground turn or writing a row', async () => {
root = await mkdtemp(join(tmpdir(), 'orca-background-task-cancel-'))
const journal = await journals.open({ identity: IDENTITY, journalDir: root })
@@ -161,9 +161,9 @@ export function isStructuredAgentSessionRecoveryTicketCurrent(
}
export async function retryUnexpectedExitSettlement(input: {
context: StructuredAgentSessionUnexpectedExitContext
context: Pick<StructuredAgentSessionUnexpectedExitContext, 'onBarrierError'>
event: UnexpectedExitLifecycleEvent
session: StructuredAgentSessionHostSession
session: Pick<StructuredAgentSessionHostSession, 'journal' | 'fence'>
stableSettlementId: string
}): Promise<boolean> {
try {
@@ -189,7 +189,7 @@ export async function retryUnexpectedExitSettlement(input: {
function unexpectedExitFallbackMutations(
event: UnexpectedExitLifecycleEvent,
session: StructuredAgentSessionHostSession,
session: Pick<StructuredAgentSessionHostSession, 'journal'>,
stableSettlementId: string
): JournalLifecycleMutationInput[] {
const mutations: JournalLifecycleMutationInput[] = []
@@ -15,6 +15,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'
import { evaluateAgentSessionAcquisition } from '../../../shared/agent-session-lease-adjudication'
import { activeStructuredAgentSessionTurnId } from '../../../shared/structured-agent-session-projection'
import type {
AgentSessionClaimStatus,
AgentSessionHandoffStage,
@@ -25,6 +26,9 @@ import type {
import { AgentSessionRecordStore } from '../../runtime/agent-session-record-store'
import { AGENT_SESSION_STORE_FILE_NAME } from '../../runtime/agent-session-record-store-file'
import type { StructuredAgentSessionAdapter } from './structured-agent-session-adapter'
import { openAgentSessionJournal } from '../agent-session-journal/journal-store-factory'
import { journalDirectoryFor } from '../agent-session-journal/journal-paths'
import type { AgentSessionJournal } from '../agent-session-journal/journal-store'
import { StructuredAgentSessionHost } from './structured-agent-session-host'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'
import {
@@ -126,7 +130,8 @@ function openHost(overrides: Partial<StructuredAgentSessionHostDeps> = {}): void
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
setOption: vi.fn(),
supportsCreate: () => true
} as unknown as StructuredAgentSessionAdapter,
journalRoot: root,
claimKeyId: 'key-1',
@@ -174,7 +179,149 @@ function isAcquirable(lease: NonNullable<ReturnType<typeof store.getRecord>>['le
)
}
async function seedRunningTurn(provider: 'codex' | 'claude' = 'codex'): Promise<void> {
const journal = await openAgentSessionJournal({
identity: {
sessionId: SESSION,
workspaceId: LOCATION.workspaceId,
hostId: LOCATION.executionHostId,
agent: provider,
providerHandle:
provider === 'codex'
? { kind: 'codex', threadId: THREAD }
: { kind: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null }
},
journalDir: journalDirectoryFor(root, { workspaceId: LOCATION.workspaceId, sessionId: SESSION })
})
await journal.appendItem(
provider === 'codex'
? { provider: 'codex', threadId: THREAD, turnId: 'turn-1', ordinal: 0 }
: { provider: 'claude', sessionId: 'provider-session-alpha-1', uuid: 'uuid-running' },
{
kind: 'status',
text: 'Agent is working...',
turnLifecycle: { turnId: 'turn-1', state: 'running' }
},
{ fence: 13 }
)
await journal.close()
}
function restoredJournal(): AgentSessionJournal {
const restored = (
host as unknown as { sessions: Map<string, { journal: AgentSessionJournal }> }
).sessions.get(SESSION)
if (!restored) {
throw new Error('expected a restored session journal')
}
return restored.journal
}
describe('already-wedged profiles become usable on load', () => {
it.each(['codex', 'claude'] as const)(
'settles a wedged %s journal on boot without opening a provider child',
async (provider) => {
const record = wedgedRecord({
claimStatus: 'live',
handoffStage: null,
ownerProcess: DEAD_OWNER
})
const providerRecord: AgentSessionRecord =
provider === 'codex'
? record
: {
...record,
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' },
lease: { ...record.lease, provenHandleLinkId: 'claude-13-link' },
providerHandleChain: [
{
linkId: 'claude-13-link',
handle: {
provider: 'claude',
sessionId: 'provider-session-alpha-1',
leafUuid: null
},
origin: 'created',
mintedAtFence: 13,
observedAt: NOW - 10_000
}
]
}
await seedStore(providerRecord)
await seedRunningTurn(provider)
openHost()
await host.restoreReadableSessions()
expect(host.hasSession(SESSION)).toBe(true)
const firstCursor = restoredJournal().cursor()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'released',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
expect(acquire).not.toHaveBeenCalled()
await host.flushAllStreamedEvents()
store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
openHost()
await host.restoreReadableSessions()
expect(restoredJournal().cursor()).toEqual(firstCursor)
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
}
)
it('settles restart eviction through attach when a hold arrives before the boot sweep', async () => {
await seedStore(
wedgedRecord({ claimStatus: 'live', handoffStage: null, ownerProcess: DEAD_OWNER })
)
await seedRunningTurn()
openHost()
await host.hold(SESSION, 'desktop-chat:restart')
expect(acquire).toHaveBeenCalledOnce()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'live',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
})
it('settles an observed-exit latch through attach before the boot sweep', async () => {
const record = wedgedRecord({ claimStatus: 'released', handoffStage: 'recovering' })
record.lease.settlementRetryRequired = true
record.lease.settlementRetryId = `provider-exit:${SESSION}:12:generation-1`
record.lease.deathEvidence = {
kind: 'exit-observed',
detail: 'provider exited: transport closed',
observedAt: NOW - 1_000
}
await seedStore(record)
await seedRunningTurn()
openHost()
expect(await host.attach(CALLER, hostTestAttachParams(13))).toMatchObject({ ok: true })
expect(acquire).toHaveBeenCalledOnce()
expect(activeStructuredAgentSessionTurnId(restoredJournal().snapshot().items)).toBe(null)
expect(store.getRecord(SESSION)?.lease).toMatchObject({
claimStatus: 'live',
handoffStage: null,
settlementRetryRequired: undefined,
settlementRetryId: undefined
})
})
it('re-adjudicates a conflicted manual-recovery record whose owner is provably gone', async () => {
// A crash can leave a conflicted current-schema row in manual recovery; positive death proof
// must make it acquirable again without discarding the provider handle.
@@ -0,0 +1,235 @@
import { describe, expect, it, vi } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import {
findCommittedStructuredAgentSessionAdoptionReplay,
findConflictingStructuredAdoption,
resolveStructuredAgentSessionAdoption,
structuredAdoptionConflictError,
type StructuredAgentSessionAdoptionOwnership
} from './structured-agent-session-history-adoption'
const OPERATION = '1800000000000-00000000000000000000000000000001'
function committedReplay(overrides: { callerKey?: string; operationId?: string } = {}) {
const lease = agentSessionLeaseFixture({ sessionId: 'codex_adopted' })
return findCommittedStructuredAgentSessionAdoptionReplay({
agent: 'codex',
providerSessionId: 'thread-1',
selfSessionId: 'codex_adopted',
callerKey: overrides.callerKey ?? 'client-1',
operationId: overrides.operationId ?? OPERATION,
record: {
...agentSessionRecordFixture(lease),
provider: 'codex',
providerHandleChain: [
{
linkId: 'codex-1-thread-1',
origin: 'adopted',
mintedAtFence: 1,
observedAt: 1_800_000_000_000,
handle: { provider: 'codex', threadId: 'thread-1' }
}
],
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex-original' }
},
operations: [
{
callerKey: 'client-1',
operationId: OPERATION,
fingerprint: 'fingerprint-1',
operationTimestamp: 1_800_000_000_000,
recordedAt: 1_800_000_000_000,
expiresAt: 1_900_000_000_000,
outcome: { status: 'succeeded', sessionId: 'codex_adopted' }
}
]
})
}
function ownership(
overrides: Partial<StructuredAgentSessionAdoptionOwnership> = {}
): StructuredAgentSessionAdoptionOwnership {
return {
sessionId: 'codex_owner',
provider: 'codex',
providerSessionId: 'thread-1',
lease: agentSessionLeaseFixture(),
...overrides
}
}
describe('findConflictingStructuredAdoption', () => {
it('names the session that already holds the conversation', () => {
const owner = ownership()
expect(
findConflictingStructuredAdoption({
agent: 'codex',
providerSessionId: 'thread-1',
selfSessionId: 'codex_new',
ownership: [ownership({ sessionId: 'other', providerSessionId: 'thread-2' }), owner]
})
).toBe(owner)
})
it('exempts the requesting session, so a committed create replays instead of refusing', () => {
expect(
findConflictingStructuredAdoption({
agent: 'codex',
providerSessionId: 'thread-1',
selfSessionId: 'codex_new',
ownership: [ownership({ sessionId: 'codex_new' })]
})
).toBeNull()
})
it('ignores an identical id held under the other provider', () => {
expect(
findConflictingStructuredAdoption({
agent: 'claude',
providerSessionId: 'thread-1',
selfSessionId: 'claude_new',
ownership: [ownership({ provider: 'codex' })]
})
).toBeNull()
})
it('finds nothing when no session holds the conversation', () => {
expect(
findConflictingStructuredAdoption({
agent: 'codex',
providerSessionId: 'thread-unheld',
selfSessionId: 'codex_new',
ownership: [ownership()]
})
).toBeNull()
})
})
describe('findCommittedStructuredAgentSessionAdoptionReplay', () => {
it('returns the record-pinned account and adopted handle for the exact committed operation', () => {
expect(committedReplay()).toMatchObject({
record: { accountHome: { path: '/home/dev/.codex-original' } },
providerHandle: { kind: 'codex', threadId: 'thread-1' }
})
})
it('does not cross caller or operation namespaces', () => {
expect(committedReplay({ callerKey: 'client-2' })).toBeNull()
expect(committedReplay({ operationId: `${OPERATION}-other` })).toBeNull()
})
it('preserves the adopted Claude leaf that participated in the attach fingerprint', () => {
const lease = agentSessionLeaseFixture({ sessionId: 'claude_adopted' })
const record = agentSessionRecordFixture(lease)
record.providerHandleChain[0] = {
...record.providerHandleChain[0]!,
origin: 'adopted',
handle: {
provider: 'claude',
sessionId: 'provider-session-alpha-1',
leafUuid: 'leaf-1'
}
}
expect(
findCommittedStructuredAgentSessionAdoptionReplay({
agent: 'claude',
providerSessionId: 'provider-session-alpha-1',
selfSessionId: 'claude_adopted',
callerKey: 'client-1',
operationId: OPERATION,
record,
operations: [
{
callerKey: 'client-1',
operationId: OPERATION,
fingerprint: 'fingerprint-1',
operationTimestamp: 1_800_000_000_000,
recordedAt: 1_800_000_000_000,
expiresAt: 1_900_000_000_000,
outcome: { status: 'succeeded', sessionId: 'claude_adopted' }
}
]
})
).toMatchObject({
providerHandle: {
kind: 'claude',
sessionId: 'provider-session-alpha-1',
leafUuid: 'leaf-1'
}
})
})
})
describe('structuredAdoptionConflictError', () => {
it('calls a conversation with an admitted writer a conflict', () => {
expect(structuredAdoptionConflictError(ownership()).message).toBe('agent_session_conflict')
})
it.each([
['a reservation with no process yet', { ownerProcess: null, claimStatus: 'reserved' as const }],
['a lease mid-handoff', { handoffStage: 'new-owner-proving' as const }],
['an unreconciled lease', { unreconciled: true }]
])('calls %s an unknown owner rather than a conflict', (_label, leaseOverrides) => {
// Neither verdict admits a second writer; they differ only in what the user is told.
expect(
structuredAdoptionConflictError(
ownership({ lease: agentSessionLeaseFixture(leaseOverrides) })
).message
).toBe('agent_session_ownership_unknown')
})
})
describe('resolveStructuredAgentSessionAdoption', () => {
it('takes the first candidate home that holds the transcript and probes no further', async () => {
const resolveTranscript = vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce('/home/dev/.codex/sessions/thread-1.jsonl')
await expect(
resolveStructuredAgentSessionAdoption({
agent: 'codex',
providerSessionId: 'thread-1',
candidateAccountHomes: ['/home/dev/.orca-codex', '/home/dev/.codex', '/never/probed'],
resolveTranscript
})
).resolves.toEqual({
accountHomePath: '/home/dev/.codex',
transcriptPath: '/home/dev/.codex/sessions/thread-1.jsonl'
})
expect(resolveTranscript).toHaveBeenCalledTimes(2)
})
it('skips blank and repeated candidates instead of probing them again', async () => {
const resolveTranscript = vi.fn().mockResolvedValue(null)
await expect(
resolveStructuredAgentSessionAdoption({
agent: 'claude',
providerSessionId: 'session-1',
candidateAccountHomes: ['', ' ', '/home/dev/.claude', ' /home/dev/.claude ', ''],
resolveTranscript
})
).rejects.toThrow('agent_session_identity_required')
expect(resolveTranscript.mock.calls.map(([args]) => args.accountHomePath)).toEqual([
'/home/dev/.claude'
])
})
it('refuses rather than falling back to a home that does not hold the conversation', async () => {
// A resume under the wrong home lands in a blank chat wearing the old chat's name.
await expect(
resolveStructuredAgentSessionAdoption({
agent: 'claude',
providerSessionId: 'session-1',
candidateAccountHomes: ['/home/dev/.claude-work', '/home/dev/.claude'],
resolveTranscript: async () => null
})
).rejects.toThrow('agent_session_identity_required')
})
})
@@ -0,0 +1,156 @@
// Adopting an Agent Session History row into a brand-new structured chat.
//
// Kept out of the runtime class files because those are `@ts-nocheck`: this decides which
// credential directory a provider child will launch against and which file gets imported into a
// journal, and a call site written there would compile however wrong it was. The runtime hands over
// the facts it owns — the account homes it recognises, the records it holds — and this decides.
import type { AgentSessionOperationRow } from '../../shared/agent-session-operation-ledger'
import type { AgentSessionProviderHandle } from '../../shared/agent-session-journal-types'
import type { AgentSessionLease, AgentSessionRecord } from '../../shared/agent-session-record'
import { agentSessionLeaseAdmitsWriter } from '../../shared/agent-session-lease-adjudication'
export type StructuredAgentSessionAdoptionOwnership = {
sessionId: string
provider: 'claude' | 'codex'
providerSessionId: string
lease: AgentSessionLease
}
export type StructuredAgentSessionAdoption = {
/** The account home the transcript was actually found under — never a client-supplied path. */
accountHomePath: string
transcriptPath: string
}
export type CommittedStructuredAgentSessionAdoptionReplay = {
record: AgentSessionRecord
providerHandle: Exclude<AgentSessionProviderHandle, { kind: 'opaque' }>
}
/** Exact committed-operation identity; attach still validates its fingerprint. */
export function findCommittedStructuredAgentSessionAdoptionReplay(input: {
agent: 'claude' | 'codex'
providerSessionId: string
selfSessionId: string
callerKey: string
operationId: string
record: AgentSessionRecord | null
operations: readonly AgentSessionOperationRow[]
}): CommittedStructuredAgentSessionAdoptionReplay | null {
const operation = input.operations.find(
(row) => row.callerKey === input.callerKey && row.operationId === input.operationId
)
if (
operation?.outcome.status !== 'succeeded' ||
operation.outcome.sessionId !== input.selfSessionId
) {
return null
}
const record = input.record
const adopted = record?.providerHandleChain[0]
if (
!record ||
record.sessionId !== input.selfSessionId ||
record.provider !== input.agent ||
adopted?.origin !== 'adopted'
) {
return null
}
const providerSessionId =
adopted.handle.provider === 'codex' ? adopted.handle.threadId : adopted.handle.sessionId
if (providerSessionId !== input.providerSessionId) {
return null
}
return {
record,
providerHandle:
adopted.handle.provider === 'codex'
? { kind: 'codex', threadId: adopted.handle.threadId }
: {
kind: 'claude',
sessionId: adopted.handle.sessionId,
leafUuid: adopted.handle.leafUuid
}
}
}
/**
* A conversation has exactly one writer. Codex takes no lock of its own: a second app-server holding
* the same thread never errors, it loads history once and then diverges, and the rollout ends up
* recording a conversation that never happened. So the refusal is the correctness guard, and it has
* to be able to tell "someone else owns this" from "this very operation owns it".
*
* @param selfSessionId the structured session this create is reserving. A retry of a committed
* create re-runs every pre-commit check, and by then the record it created is itself in the
* ownership index — without this exemption the replay refuses instead of replaying.
*/
export function findConflictingStructuredAdoption(input: {
agent: 'claude' | 'codex'
providerSessionId: string
selfSessionId: string
ownership: readonly StructuredAgentSessionAdoptionOwnership[]
}): StructuredAgentSessionAdoptionOwnership | null {
return (
input.ownership.find(
(owner) =>
owner.sessionId !== input.selfSessionId &&
owner.provider === input.agent &&
owner.providerSessionId === input.providerSessionId
) ?? null
)
}
/** Mirrors the legacy PTY resume's refusal vocabulary: a conversation with an admitted writer is a
* conflict, one without is an unknown owner. Neither ever admits a second writer. */
export function structuredAdoptionConflictError(
ownership: StructuredAgentSessionAdoptionOwnership
): Error {
return new Error(
agentSessionLeaseAdmitsWriter(ownership.lease)
? 'agent_session_conflict'
: 'agent_session_ownership_unknown'
)
}
/**
* Resolve which recognised account home holds this conversation, by finding its transcript.
*
* The client names only the conversation. Everything else is derived here: `agentSession.create` is
* reachable by paired mobile clients, so a client-supplied account home would choose the credential
* directory the provider child launches against, and a client-supplied transcript path would choose
* which file this host reads into a journal.
*
* Candidates are tried in order and the FIRST hit wins, so the caller must order them by preference
* (selected account before the system default).
*/
export async function resolveStructuredAgentSessionAdoption(input: {
agent: 'claude' | 'codex'
providerSessionId: string
candidateAccountHomes: readonly string[]
resolveTranscript: (args: {
agent: 'claude' | 'codex'
providerSessionId: string
accountHomePath: string
}) => Promise<string | null>
}): Promise<StructuredAgentSessionAdoption> {
const seen = new Set<string>()
for (const accountHomePath of input.candidateAccountHomes) {
const trimmed = accountHomePath.trim()
if (!trimmed || seen.has(trimmed)) {
continue
}
seen.add(trimmed)
const transcriptPath = await input.resolveTranscript({
agent: input.agent,
providerSessionId: input.providerSessionId,
accountHomePath: trimmed
})
if (transcriptPath) {
return { accountHomePath: trimmed, transcriptPath }
}
}
// Refuse rather than fall back to the default home. Resuming under a home that does not hold the
// conversation is how a "resume" silently becomes a blank chat wearing the old chat's name.
throw new Error('agent_session_identity_required')
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import { evictAgentSessionOwner } from './agent-session-lease-transitions'
import { applyAgentSessionRestartAdjudication } from './agent-session-restart-lease-transitions'
const NOW = 1_800_000_000_000
describe('proven-dead agent session eviction settlement', () => {
it('latches restart eviction with a stable id while keeping the lease resumable', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true })
)
const evicted = applyAgentSessionRestartAdjudication({
record,
probe: { outcome: 'pid-absent' },
now: NOW
})
expect(evicted.lease).toMatchObject({
claimStatus: 'released',
runtimeFence: 8,
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
deathEvidence: { kind: 'pid-absent', detail: 'recorded pid absent on host' }
})
})
it('latches recovery eviction from the same evicted disposition', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' })
)
const evicted = evictAgentSessionOwner({
record,
expectedFence: 7,
probe: { outcome: 'identity-mismatch', field: 'process-start-time' },
now: NOW,
journalSettlement: 'required'
})
expect(evicted.lease).toMatchObject({
claimStatus: 'released',
runtimeFence: 8,
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
deathEvidence: { kind: 'identity-mismatch', detail: 'mismatched process-start-time' }
})
})
it('never latches an indeterminate owner', () => {
const restartRecord = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', unreconciled: true })
)
const recovered = applyAgentSessionRestartAdjudication({
record: restartRecord,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW
})
const recoveryRecord = agentSessionRecordFixture(
agentSessionLeaseFixture({ runtimeKind: 'native', handoffStage: 'recovering' })
)
expect(recovered.lease).toMatchObject({
handoffStage: 'recovering',
ownerProcess: { pid: 4242 }
})
expect(recovered.lease).not.toHaveProperty('settlementRetryRequired')
expect(recovered.lease).not.toHaveProperty('settlementRetryId')
expect(() =>
evictAgentSessionOwner({
record: recoveryRecord,
expectedFence: 7,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW,
journalSettlement: 'required'
})
).toThrow('agent_session_ownership_unknown')
expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryRequired')
expect(recoveryRecord.lease).not.toHaveProperty('settlementRetryId')
})
it('preserves a null handoff stage when the latch survives another restart', () => {
const record = agentSessionRecordFixture(
agentSessionLeaseFixture({
runtimeKind: 'native',
ownerProcess: null,
reservedSpawnToken: null,
claimStatus: 'released',
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
unreconciled: true
})
)
const restored = applyAgentSessionRestartAdjudication({
record,
probe: { outcome: 'indeterminate', reason: 'remote host unavailable' },
now: NOW
})
expect(restored.lease).toMatchObject({
handoffStage: null,
settlementRetryRequired: true,
settlementRetryId: 'restart-eviction:session-alpha-1:8',
unreconciled: false
})
})
})
@@ -28,7 +28,7 @@ export function recoverDeadTuiOwnerForHandoff(args: {
) {
throw new Error('agent_session_ownership_unknown')
}
const evicted = evictAgentSessionOwner(args)
const evicted = evictAgentSessionOwner({ ...args, journalSettlement: 'required' })
return withLease(evicted, {
...evicted.lease,
handoffStage: 'old-owner-stopped',
@@ -8,6 +8,7 @@
import {
adjudicateAgentSessionRestart,
agentSessionRestartEvictionSettlementId,
evaluateAgentSessionAcquisition,
type AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
@@ -207,6 +208,7 @@ export function evictAgentSessionOwner(args: {
expectedFence: number
probe: AgentSessionOwnerProbe
now: number
journalSettlement: 'required' | 'not-required'
}): AgentSessionRecord {
const { record } = args
assertFence(record.lease, args.expectedFence)
@@ -232,6 +234,7 @@ export function evictAgentSessionOwner(args: {
if (adjudication.disposition !== 'evicted') {
throw new Error('agent_session_ownership_unknown')
}
const settlementRequired = args.journalSettlement === 'required'
return withLease(record, {
...record.lease,
runtimeFence: adjudication.nextFence,
@@ -242,7 +245,11 @@ export function evictAgentSessionOwner(args: {
claimStatus: 'released',
lastRenewedAt: args.now,
handoffOperationId: null,
deathEvidence: adjudication.evidence
deathEvidence: adjudication.evidence,
settlementRetryRequired: settlementRequired ? true : undefined,
settlementRetryId: settlementRequired
? agentSessionRestartEvictionSettlementId(record.lease, adjudication)
: undefined
})
}
@@ -254,7 +254,9 @@ export class AgentSessionRecordStore {
probe: AgentSessionOwnerProbe
now: number
}): Promise<AgentSessionRecord> {
return this.mutate(args.sessionId, (record) => evictAgentSessionOwner({ ...args, record }))
return this.mutate(args.sessionId, (record) =>
evictAgentSessionOwner({ ...args, record, journalSettlement: 'required' })
)
}
async transitionHandoff(
@@ -0,0 +1,199 @@
// Adoption admission inside the reservation transaction: which conversation a new record may claim.
import { describe, expect, it } from 'vitest'
import {
agentSessionLeaseFixture,
agentSessionRecordFixture
} from '../../shared/agent-session-record.test-fixture'
import type {
AgentSessionExecutionLocation,
AgentSessionRecord
} from '../../shared/agent-session-record'
import type { AgentSessionOwnerProbe } from '../../shared/agent-session-lease-adjudication'
import type { AgentSessionProviderHandleLink } from '../../shared/agent-session-provider-handle'
import {
applyAgentSessionReservation,
type AgentSessionReserveRequest
} from './agent-session-reservation-admission'
import type { AgentSessionStoreState } from './agent-session-record-store-file'
const NOW = 1_800_000_000_000
const LEASE_TTL_MS = 60_000
const LOCATION: AgentSessionExecutionLocation = {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
}
const INDETERMINATE: AgentSessionOwnerProbe = { outcome: 'indeterminate', reason: 'no answer' }
/** The link an adopting create seeds: fence 1, because that is a new record's first. */
function adoptedLink(
overrides: Partial<AgentSessionProviderHandleLink> = {}
): AgentSessionProviderHandleLink {
return {
linkId: 'claude-1-provider-session-alpha-1-empty',
handle: { provider: 'claude', sessionId: 'provider-session-alpha-1', leafUuid: null },
origin: 'adopted',
mintedAtFence: 1,
observedAt: NOW,
...overrides
}
}
function reserveRequest(
overrides: Partial<AgentSessionReserveRequest> = {}
): AgentSessionReserveRequest {
return {
sessionId: 'session-adopting',
location: LOCATION,
provider: 'claude',
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' },
runtimeKind: 'native',
expectedFence: null,
spawnToken: 'spawn-a',
claimKeyId: 'key-1',
handoffOperationId: null,
probe: INDETERMINATE,
operation: { callerKey: 'client-1', operationId: 'op-1', fingerprint: 'fp-1' },
now: NOW,
...overrides
}
}
function storeState(records: readonly AgentSessionRecord[] = []): AgentSessionStoreState {
return {
schemaVersion: 2,
hostId: 'local',
records: new Map(records.map((record) => [record.sessionId, record])),
operations: new Map(),
retiredClaimKeys: [],
unreadableRecords: new Map(),
visibleSessionIds: new Set(),
visibleSessionIdsIndexPresent: true
}
}
describe('adopted handle chain seeding', () => {
it('seeds a new record with the adopted link alone, at the first fence of the record', () => {
const link = adoptedLink()
const { record, disposition } = applyAgentSessionReservation(
storeState(),
reserveRequest({ adoptedHandleLink: link }),
LEASE_TTL_MS
)
expect(disposition).toBe('created')
expect(record.providerHandleChain).toEqual([link])
// The owner probe requires the head link to carry the record's current fence.
expect(record.providerHandleChain[0]?.mintedAtFence).toBe(record.lease.runtimeFence)
})
it('leaves a blank create with no chain, so the adapter starts a conversation', () => {
const { record } = applyAgentSessionReservation(storeState(), reserveRequest(), LEASE_TTL_MS)
expect(record.providerHandleChain).toEqual([])
})
})
describe('adopted conversation ownership', () => {
it('refuses when another record already holds the same conversation root', () => {
// The held link names a leaf; the adoption names none. Same root is the whole test: keying on
// the exact handle would let two writers onto one conversation on different branches.
const holder = agentSessionRecordFixture()
expect(() =>
applyAgentSessionReservation(
storeState([holder]),
reserveRequest({ adoptedHandleLink: adoptedLink() }),
LEASE_TTL_MS
)
).toThrow('agent_session_conflict')
})
it('admits an adoption of a conversation no record holds', () => {
const holder = agentSessionRecordFixture()
expect(() =>
applyAgentSessionReservation(
storeState([holder]),
reserveRequest({
adoptedHandleLink: adoptedLink({
handle: { provider: 'claude', sessionId: 'provider-session-other', leafUuid: null }
})
}),
LEASE_TTL_MS
)
).not.toThrow()
})
it('exempts the requesting session so a committed create can be re-run', () => {
// Pins the guard's own contract. No wire shape reaches it today: `adopt` is accepted only on
// create-by-intent, which always carries a null expected fence, and an existing record with a
// null expected fence is refused a few lines below anyway.
const link = adoptedLink()
const committed: AgentSessionRecord = {
...agentSessionRecordFixture(
agentSessionLeaseFixture({
sessionId: 'session-adopting',
runtimeFence: 1,
handoffStage: 'new-owner-proving',
claimStatus: 'reserved',
ownerProcess: null,
provenHandleLinkId: null,
handoffOperationId: 'handoff-1'
})
),
location: LOCATION,
accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: '/home/dev/.claude' },
providerHandleChain: [link]
}
const { record, disposition } = applyAgentSessionReservation(
storeState([committed]),
reserveRequest({
adoptedHandleLink: link,
expectedFence: 1,
handoffOperationId: 'handoff-1'
}),
LEASE_TTL_MS
)
expect(disposition).toBe('retry-reservation')
expect(record.providerHandleChain).toEqual([link])
})
it('refuses a Codex adoption another record already holds', () => {
const holder: AgentSessionRecord = {
...agentSessionRecordFixture(agentSessionLeaseFixture({ sessionId: 'session-codex' })),
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
providerHandleChain: [
{
linkId: 'codex-1-thread-1',
handle: { provider: 'codex', threadId: 'thread-1' },
origin: 'created',
mintedAtFence: 7,
observedAt: NOW
}
]
}
expect(() =>
applyAgentSessionReservation(
storeState([holder]),
reserveRequest({
sessionId: 'session-codex-adopting',
provider: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
adoptedHandleLink: adoptedLink({
linkId: 'codex-1-thread-1-adopted',
handle: { provider: 'codex', threadId: 'thread-1' }
})
}),
LEASE_TTL_MS
)
).toThrow('agent_session_conflict')
})
})
@@ -28,7 +28,11 @@ import {
type AgentSessionLaunchEnv,
type AgentSessionRecord
} from '../../shared/agent-session-record'
import type { AgentSessionHandleProvider } from '../../shared/agent-session-provider-handle'
import {
agentSessionProviderHandleRoot,
type AgentSessionHandleProvider,
type AgentSessionProviderHandleLink
} from '../../shared/agent-session-provider-handle'
import {
reserveAgentSessionOwner,
type AgentSessionReservation
@@ -46,6 +50,9 @@ export type AgentSessionReserveRequest = {
launchEnv?: AgentSessionLaunchEnv
/** Initial provider options persisted before the first process is acquired. */
options?: Readonly<Record<string, string>>
/** Set only when this create adopts an existing provider conversation. Seeds the handle chain so
* the adapter resumes; without it a new record has never proved a thread and starts a fresh one. */
adoptedHandleLink?: AgentSessionProviderHandleLink
runtimeKind: AgentSessionReservation['runtimeKind']
/** Null when the session does not exist yet; otherwise the fence the caller last observed. */
expectedFence: number | null
@@ -146,6 +153,11 @@ export function applyAgentSessionReservation(
leaseTtlMs: request.leaseTtlMs ?? leaseTtlMs,
now: request.now
}
// Inside the transaction, not only in the RPC resolver: two concurrent adoptions of one
// conversation mint different session ids, so the compare-and-swap never collides and a
// pre-commit check passes for both. Codex would then hold one thread from two app-servers, which
// it permits silently and which corrupts the conversation rather than erroring.
assertAdoptedConversationUnowned(state, request)
const existing = state.records.get(request.sessionId)
if (!existing) {
if (state.unreadableRecords.has(request.sessionId)) {
@@ -181,6 +193,41 @@ export function applyAgentSessionReservation(
})
}
/**
* Refuse an adoption whose conversation ANOTHER record already holds.
*
* The self-exemption is part of that definition, not a replay mechanism: replay is settled earlier
* by the operation ledger, and an adoption always arrives with a null expected fence, so a request
* naming an existing session id is refused a few lines below regardless. Keeping the scan scoped to
* other records is what makes this guard mean what its name says.
*
* It runs inside the store transaction because the pre-commit check in the RPC resolver cannot be
* the guard: two concurrent adoptions of one conversation mint different session ids, so the
* compare-and-swap never collides and both would pass. Codex permits two app-servers on one thread
* silently, so the cost of missing this is a corrupted conversation rather than an error.
*/
function assertAdoptedConversationUnowned(
state: AgentSessionStoreState,
request: AgentSessionReserveRequest
): void {
const adopted = request.adoptedHandleLink
if (!adopted) {
return
}
const root = agentSessionProviderHandleRoot(adopted.handle)
for (const record of state.records.values()) {
if (record.sessionId === request.sessionId) {
continue
}
const holdsSameConversation = record.providerHandleChain.some(
(link) => agentSessionProviderHandleRoot(link.handle) === root
)
if (holdsSameConversation) {
throw new Error('agent_session_conflict')
}
}
}
function createAgentSessionRecord(
request: AgentSessionReserveRequest,
reservation: AgentSessionReservation
@@ -190,7 +237,9 @@ function createAgentSessionRecord(
sessionId: request.sessionId,
location: request.location,
provider: request.provider,
providerHandleChain: [],
// Fence 1 below is this record's first, and the owner probe requires the head link to carry the
// record's current fence — so an adopted link must be minted at that same fence.
providerHandleChain: request.adoptedHandleLink ? [request.adoptedHandleLink] : [],
accountHome: request.accountHome,
...(request.options ? { options: { ...request.options } } : {}),
...(request.launchArgs ? { launchArgs: [...request.launchArgs] } : {}),
@@ -17,6 +17,9 @@ export function adjudicateRestartedAgentSessionHandoff(
if (adjudication.disposition === 'readopt') {
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
}
if (adjudication.disposition === 'settlement-pending') {
return updateLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: now })
}
if (adjudication.disposition === 'free') {
return updateLease(record, {
...record.lease,
@@ -8,6 +8,7 @@
import {
adjudicateAgentSessionRestart,
agentSessionRestartEvictionSettlementId,
type AgentSessionOwnerProbe
} from '../../shared/agent-session-lease-adjudication'
import type {
@@ -50,6 +51,9 @@ export function applyAgentSessionRestartAdjudication(args: {
// Why: re-adoption is not a new generation, so the fence does not move.
return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now })
}
if (adjudication.disposition === 'settlement-pending') {
return withLease(record, { ...record.lease, unreconciled: false, lastRenewedAt: args.now })
}
if (adjudication.disposition === 'free') {
// Why: an already-free lease that reloads into `recovering` is unopenable forever; clearing
// the stage restores it without moving the fence or touching the recorded death evidence.
@@ -74,7 +78,9 @@ export function applyAgentSessionRestartAdjudication(args: {
unreconciled: false,
lastRenewedAt: args.now,
handoffOperationId: null,
deathEvidence: adjudication.evidence
deathEvidence: adjudication.evidence,
settlementRetryRequired: true,
settlementRetryId: agentSessionRestartEvictionSettlementId(record.lease, adjudication)
})
}
const stage: AgentSessionHandoffStage =
@@ -391,6 +391,7 @@ beforeEach(async () => {
}
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async (input: { envelope: unknown }) => ({
...ensureParams(1),
@@ -7,6 +7,10 @@ import { supportsCodexStructuredLocation } from '../codex/codex-structured-locat
import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support'
import {
resolveCommittedStructuredAgentSessionAdoptionIntent,
resolveStructuredAgentSessionAdoptionForCreate
} from './structured-agent-session-create-adoption'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
@@ -111,6 +115,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'claude' | 'codex'
callerKey?: string
resumeFrom?: { providerSessionId: string }
}): Promise<AgentSessionAttachParams> {
if (input.agent === 'claude') {
return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => {
@@ -144,6 +150,8 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
envelope: { sessionId: string; clientOperationId: string }
worktree: string
agent: 'claude' | 'codex'
callerKey?: string
resumeFrom?: { providerSessionId: string }
},
resolveAccountHomePath: (context: {
workspacePath: string
@@ -168,6 +176,35 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
)
const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path
const host = getStructuredAgentSessionHost()
const committedReplay = resolveCommittedStructuredAgentSessionAdoptionIntent({
host,
...input,
location,
...(options ? { options } : {})
})
if (committedReplay) {
return committedReplay
}
const selectedAccountHomePath = await resolveAccountHomePath({
workspacePath,
launchEnv,
location
})
// Adopting pins the account home to wherever the conversation actually lives, which is not
// necessarily the one a fresh create would pick: Codex resolves its rollout under
// `accountHome.path`, and Claude reads its transcript under `<home>/projects`. Resuming under
// the wrong home finds nothing and lands the user in a blank chat wearing the old chat's name.
const adoption = input.resumeFrom
? await resolveStructuredAgentSessionAdoptionForCreate({
host,
settings,
agent: input.agent,
providerSessionId: input.resumeFrom.providerSessionId,
selfSessionId: input.envelope.sessionId,
selectedAccountHomePath
})
: null
return {
envelope: {
sessionId: input.envelope.sessionId,
@@ -180,9 +217,27 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
agent: input.agent,
accountHome: {
variable: input.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: await resolveAccountHomePath({ workspacePath, launchEnv, location })
path: adoption ? adoption.accountHomePath : selectedAccountHomePath
},
...(options ? { options } : {}),
...(input.resumeFrom && adoption
? {
// `adopt` is what makes the reservation seed the handle chain. Presence of
// `providerHandle` alone must not: `agentSession.ensure` already passes one today
// without adopting anything.
adopt: {
providerHandle:
input.agent === 'claude'
? {
kind: 'claude' as const,
sessionId: input.resumeFrom.providerSessionId,
leafUuid: null
}
: { kind: 'codex' as const, threadId: input.resumeFrom.providerSessionId },
transcriptPath: adoption.transcriptPath
}
}
: {}),
runtimeKind: 'native'
}
}
@@ -180,7 +180,9 @@ function createFixture(
getRuntimeId: () => 'test-runtime',
listMobileSessionTabs: vi.fn().mockResolvedValue(snapshot),
getClientSettings: () => ({
experimentalStructuredNativeChat: options.structuredNativeChatEnabled === true
// Why: defaults on, so a fixture that says nothing about the setting exercises capability
// gating alone; callers opt into the off case explicitly.
experimentalStructuredNativeChat: options.structuredNativeChatEnabled !== false
}),
...calls
} as unknown as OrcaRuntimeService
@@ -87,7 +87,9 @@ describe('projectSessionTabAgentStatus', () => {
}
]
}
const oldClient = projectSessionTabAgentStatus(snapshot, 'mobile', [])
// A paired client that never negotiated the capability, with the setting on: mobile keeps an
// unrenderable row under a fallback title, so only a non-mobile old client still loses them.
const oldClient = projectSessionTabAgentStatus(snapshot, 'runtime', [], true)
expect(oldClient.tabs.map((tab) => tab.type)).toEqual(['terminal'])
expect(oldClient.activeTabId).toBe('tab-1::leaf-1')
expect(oldClient.activeTabType).toBe('terminal')
@@ -96,11 +98,6 @@ describe('projectSessionTabAgentStatus', () => {
expect(oldClient.tabGroups).toHaveLength(1)
expect(oldClient.tabGroupLayout).toEqual({ type: 'leaf', groupId: 'group-a' })
expect(
projectSessionTabAgentStatus(snapshot, 'mobile', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
).toEqual(oldClient)
expect(
projectSessionTabAgentStatus(
snapshot,
@@ -118,10 +115,25 @@ describe('projectSessionTabAgentStatus', () => {
)
expect(capableMobile).toBe(snapshot)
const capable = projectSessionTabAgentStatus(snapshot, 'runtime', [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
])
const capable = projectSessionTabAgentStatus(
snapshot,
'runtime',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
true
)
expect(capable).toBe(snapshot)
// The host setting is policy for every caller, so a capable desktop client with the
// setting off sees the same projection an old client does.
expect(
projectSessionTabAgentStatus(
snapshot,
'runtime',
[STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
false
)
).toEqual(oldClient)
expect(projectSessionTabAgentStatus(snapshot, undefined, undefined, false)).toEqual(oldClient)
})
const claudeSnapshot = {
@@ -276,8 +288,10 @@ describe('projectSessionTabAgentStatus', () => {
)
it('keeps Claude rows on the local renderer, which negotiates nothing', () => {
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined)).toBe(claudeSnapshot)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [])).toBe(claudeSnapshot)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, undefined, true)).toBe(
claudeSnapshot
)
expect(projectSessionTabAgentStatus(claudeSnapshot, undefined, [], true)).toBe(claudeSnapshot)
})
it('leaves Codex rows untouched whether or not the Claude capability is present', () => {
@@ -295,11 +309,11 @@ describe('projectSessionTabAgentStatus', () => {
)
}
}
expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined)).toBe(codexOnly)
expect(projectSessionTabAgentStatus(codexOnly, undefined, undefined, true)).toBe(codexOnly)
})
it('withholds session boundaries from legacy paired clients', () => {
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [])
const projected = projectSessionTabAgentStatus(makeSnapshot(true), 'runtime', [], true)
expect(projected.tabs[0]).not.toHaveProperty('agentStatus')
})
@@ -308,7 +322,12 @@ describe('projectSessionTabAgentStatus', () => {
const snapshot = makeSnapshot(true)
expect(
projectSessionTabAgentStatus(snapshot, 'runtime', [AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY])
projectSessionTabAgentStatus(
snapshot,
'runtime',
[AGENT_SESSION_BOUNDARY_RUNTIME_CAPABILITY],
true
)
).toBe(snapshot)
})
@@ -317,8 +336,12 @@ describe('projectSessionTabAgentStatus', () => {
const mobileBoundary = makeSnapshot(true)
const runtimeCompletion = makeSnapshot(false)
expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined)).toBe(localBoundary)
expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [])).toBe(mobileBoundary)
expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [])).toBe(runtimeCompletion)
expect(projectSessionTabAgentStatus(localBoundary, undefined, undefined, true)).toBe(
localBoundary
)
expect(projectSessionTabAgentStatus(mobileBoundary, 'mobile', [], true)).toBe(mobileBoundary)
expect(projectSessionTabAgentStatus(runtimeCompletion, 'runtime', [], true)).toBe(
runtimeCompletion
)
})
})
@@ -55,7 +55,7 @@ export function projectSessionTabAgentStatus<TPayload extends SessionTabsPayload
payload: TPayload,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: readonly RuntimeCapability[] | undefined,
structuredNativeChatEnabled?: boolean
structuredNativeChatEnabled: boolean
): TPayload {
const structuredVisible = structuredNativeChatProjectionEnabled({
clientKind,
@@ -21,9 +21,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
raw,
context.clientKind,
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
isStructuredNativeChatEnabled(context.runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
assertAgentSessionTabDestructiveMutationSupported(
@@ -100,9 +98,7 @@ export const SESSION_TAB_CLOSE_METHODS: RpcAnyMethod[] = [
raw,
context.clientKind,
context.clientCapabilities,
context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
isStructuredNativeChatEnabled(context.runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
assertAgentSessionTabDestructiveMutationSupported(
@@ -19,7 +19,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
assertProjectedSessionTabVisible(visible, params.tabId)
}
@@ -42,7 +42,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
result,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
}
}),
@@ -57,7 +57,7 @@ export const SESSION_TAB_MUTATION_METHODS: RpcAnyMethod[] = [
raw,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
translated = translateProjectedSessionTabMove(raw, projected, params)
}
@@ -142,7 +142,7 @@ async function assertVisibleMutationTab(
await runtime.listMobileSessionTabs(worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
assertProjectedSessionTabVisible(visible, tabId)
}
@@ -28,7 +28,7 @@ export function projectSessionTabsForClient(
snapshot: RuntimeMobileSessionTabsResult,
clientKind: 'mobile' | 'runtime' | undefined,
clientCapabilities: Parameters<typeof projectSessionTabAgentStatus>[2],
structuredNativeChatEnabled?: boolean
structuredNativeChatEnabled: boolean
): RuntimeMobileSessionTabsResult {
return projectSessionTabBrowserPlacements(
projectSessionTabAgentStatus(
@@ -41,12 +41,6 @@ export function projectSessionTabsForClient(
)
}
function structuredNativeChatEnabledForContext(context: RpcContext): boolean | undefined {
return context.clientKind === 'mobile'
? isStructuredNativeChatEnabled(context.runtime)
: undefined
}
function projectInventory(
inventory: SessionTabsInventory,
context: RpcContext
@@ -57,7 +51,7 @@ function projectInventory(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
isStructuredNativeChatEnabled(context.runtime)
)
),
...(inventory.authoritative && clientUnderstandsAuthoritativeInventory(context)
@@ -128,7 +122,7 @@ export async function subscribeSessionTabsInventory(
snapshot,
context.clientKind,
context.clientCapabilities,
structuredNativeChatEnabledForContext(context)
isStructuredNativeChatEnabled(context.runtime)
) as SessionTabsChange
const withoutNavigationIntent = (snapshot: SessionTabsChange): SessionTabsChange => {
if (snapshot.navigationIntent === undefined) {
@@ -0,0 +1,23 @@
export function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
@@ -1,22 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it, vi, type Mock } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
import { visibleSnapshot } from './session-tabs-snapshot.test-fixture'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeRuntime(experimentalStructuredNativeChat: boolean): OrcaRuntimeService {
return {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
}
describe('structured session tab restoration follows one rule for every caller', () => {
it('does not restore for the desktop renderer while the host setting is off', async () => {
const runtime = makeRuntime(false)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).not.toHaveBeenCalled()
})
it('restores for the desktop renderer once the host setting is on', async () => {
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
makeRequest('session.tabs.list', { worktree: 'id:wt-1' }),
{
clientKind: 'runtime',
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
)
expect(response.ok).toBe(true)
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
it('restores for an in-process caller on the same setting that admits remote clients', async () => {
const restoreCallsBySetting = new Map<boolean, number>()
for (const enabled of [false, true]) {
const runtime = makeRuntime(enabled)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
await dispatcher.dispatch(makeRequest('session.tabs.list', { worktree: 'id:wt-1' }))
restoreCallsBySetting.set(
enabled,
(runtime.restoreStructuredAgentSessionTabs as unknown as Mock).mock.calls.length
)
}
expect(restoreCallsBySetting.get(false)).toBe(0)
expect(restoreCallsBySetting.get(true)).toBe(1)
})
})
describe('session tab structured restore gating', () => {
it('does not restore structured tabs for mobile while the host setting is off', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: false })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(false)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -34,12 +91,7 @@ describe('session tab structured restore gating', () => {
// Why: an old build has no capability to advertise, and skipping the restore left it with
// nothing to project after a desktop restart — neither the chat nor its fallback row.
it('restores structured tabs for a mobile client that advertises no capability', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -52,12 +104,7 @@ describe('session tab structured restore gating', () => {
})
it('restores structured tabs for mobile once the setting is present', async () => {
const runtime = {
getRuntimeId: () => 'test-runtime',
getClientSettings: vi.fn(() => ({ experimentalStructuredNativeChat: true })),
restoreStructuredAgentSessionTabs: vi.fn(),
listMobileSessionTabs: vi.fn().mockResolvedValue(visibleSnapshot())
} as unknown as OrcaRuntimeService
const runtime = makeRuntime(true)
const dispatcher = new RpcDispatcher({ runtime, methods: SESSION_TAB_METHODS })
const response = await dispatcher.dispatch(
@@ -72,27 +119,3 @@ describe('session tab structured restore gating', () => {
expect(runtime.restoreStructuredAgentSessionTabs).toHaveBeenCalledTimes(1)
})
})
function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
@@ -4,6 +4,7 @@ import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { SESSION_TAB_CLOSE_INTENT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { SESSION_TAB_METHODS } from './session-tabs'
import { visibleSnapshot } from './session-tabs-snapshot.test-fixture'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
@@ -816,27 +817,3 @@ describe('session tab RPC methods', () => {
)
})
})
function visibleSnapshot() {
return {
worktree: 'wt-1',
publicationEpoch: 'epoch-1',
snapshotVersion: 1,
activeGroupId: 'group-1',
activeTabId: 'tab-1::leaf-1',
activeTabType: 'terminal' as const,
tabGroups: [{ id: 'group-1', activeTabId: 'tab-1', tabOrder: ['tab-1'] }],
tabs: [
{
type: 'terminal' as const,
id: 'tab-1::leaf-1',
parentTabId: 'tab-1',
leafId: 'leaf-1',
title: 'Terminal',
status: 'ready' as const,
terminal: 'pty-1',
isActive: true
}
]
}
}
+3 -3
View File
@@ -28,7 +28,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
await runtime.listMobileSessionTabs(params.worktree, pairedDeviceId),
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
}
}),
@@ -121,7 +121,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
initial,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
})
initialized = true
@@ -137,7 +137,7 @@ export const SESSION_TAB_METHODS: RpcAnyMethod[] = [
snapshot,
clientKind,
clientCapabilities,
clientKind === 'mobile' ? isStructuredNativeChatEnabled(runtime) : undefined
isStructuredNativeChatEnabled(runtime)
)
})
}
@@ -0,0 +1,112 @@
// Admission can be revoked while sessions are still open: the host setting is turned off with a
// chat already on screen. What the caller may still do to that chat is the rule this suite pins.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import {
ADMISSION_METHODS,
CLEANUP_METHODS
} from './structured-agent-session-gate-classification.test-fixture'
import {
call,
clearStructuredHostStub,
envelope,
hostCalls,
installStructuredHostStub,
SESSION,
STRUCTURED_CLIENT
} from './structured-agent-session-rpc.test-fixture'
beforeEach(() => {
installStructuredHostStub()
})
afterEach(() => {
clearStructuredHostStub()
})
describe('admission revoked while a session is still open', () => {
// The host setting is admission control. Turning it off must not strand a chat that was opened
// while it was on: the pane is still mounted, so its close has to land.
const SETTING_OFF = { getClientSettings: () => ({ experimentalStructuredNativeChat: false }) }
it.each(CLEANUP_METHODS)(
'still serves $method after the host setting is turned off',
async ({ method, params, hostCall }) => {
const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF)
expect(response).toMatchObject({ ok: true })
// `unsubscribe` retires runtime-owned subscriptions rather than calling the host, so its
// result payload is the observable effect.
if (hostCall === 'unsubscribe') {
expect(response).toMatchObject({ result: { unsubscribed: true } })
} else {
expect(hostCalls[hostCall]).toHaveBeenCalled()
}
}
)
it('stops the provider child when closing a chat the setting no longer admits', async () => {
const response = await call('agentSession.close', { sessionId: SESSION }, STRUCTURED_CLIENT, {
...SETTING_OFF
})
expect(response).toMatchObject({ ok: true, result: { ok: true } })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
// The durable tab has to be retired too, or the chat comes back on the next sync.
expect(hostCalls.setSessionTabVisibility).toHaveBeenCalledWith(SESSION, false)
})
it('cancels an in-flight turn the setting no longer admits', async () => {
const response = await call(
'agentSession.cancel',
{ envelope: envelope(), turnId: 'turn-1' },
STRUCTURED_CLIENT,
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.cancel).toHaveBeenCalledOnce()
})
it.each(['runtime', 'mobile'] as const)(
'lets a %s client close a chat it already owns',
async (clientKind) => {
const response = await call(
'agentSession.close',
{ sessionId: SESSION },
{ clientKind, clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY] },
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
}
)
it('lets an in-process caller close, which is how terminal disposal retires a chat', async () => {
const response = await call(
'agentSession.close',
{ sessionId: SESSION },
undefined,
SETTING_OFF
)
expect(response).toMatchObject({ ok: true })
expect(hostCalls.close).toHaveBeenCalledWith(SESSION)
})
it.each(ADMISSION_METHODS)(
'keeps $method refused once the setting is off',
async ({ method, params }) => {
const response = await call(method, params, STRUCTURED_CLIENT, SETTING_OFF)
// Asserting the gate's own code, not merely `ok: false`: a params-validation failure would
// pass a bare falsy check and hide a gate that had stopped refusing.
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
}
)
})
@@ -0,0 +1,235 @@
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { AgentSessionRecordStore } from '../../agent-session-record-store'
import type { StructuredAgentSessionAdapter } from '../../../native-chat/agent-session-wire/structured-agent-session-adapter'
import { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
const SESSION = 'session-adoption-replay'
const THREAD = 'thread-adoption-replay'
const WORKSPACE = 'workspace-1'
const OPERATION = `${Date.now()}-00000000000000000000000000000001`
const CLIENT = {
clientId: 'device-a',
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
let root: string
let host: StructuredAgentSessionHost
function adapter(): StructuredAgentSessionAdapter {
return {
supportsCreate: () => true,
acquire: vi
.fn<StructuredAgentSessionAdapter['acquire']>()
.mockImplementation(async ({ fence, spawnToken }) => ({
process: {
hostId: 'local',
pid: 4242,
processStartTimeMs: 1_800_000_000_000,
spawnToken
},
link: {
linkId: `codex-${fence}-${THREAD}`,
handle: { provider: 'codex', threadId: THREAD },
origin: 'resumed',
mintedAtFence: fence,
observedAt: 1_800_000_000_000
}
})),
releaseAcquisition: vi.fn(async () => true),
dispatch: vi.fn(),
cancelTurn: vi.fn(),
answerPrompt: vi.fn(),
setOption: vi.fn()
}
}
function createParams(operationId = OPERATION) {
const fields = {
worktree: `id:${WORKSPACE}`,
agent: 'codex' as const,
resumeFrom: { providerSessionId: THREAD }
}
return {
envelope: {
sessionId: SESSION,
clientOperationId: operationId,
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields
})
},
...fields
}
}
async function call(dispatcher: RpcDispatcher, params: unknown, client = CLIENT) {
const replies: RpcResponse[] = []
const request: RpcRequest = {
id: `request-${replies.length + 1}`,
authToken: 'token',
method: 'agentSession.create',
params
}
await dispatcher.dispatchStreaming(
request,
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
)
return replies[0]
}
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-adoption-rpc-replay-'))
})
afterEach(async () => {
setStructuredAgentSessionHost(null)
await host?.flushAllStreamedEvents()
await host?.close(SESSION)
await rm(root, { recursive: true, force: true })
vi.restoreAllMocks()
})
describe('committed adopting create RPC replay', () => {
it('republishes from durable identity after the source disappears and account selection drifts', async () => {
const originalHome = join(root, 'account-original')
const driftedHome = join(root, 'account-drifted')
const transcriptPath = join(
originalHome,
'sessions',
'2026',
'09',
'06',
`rollout-2026-09-06T18-00-00-${THREAD}.jsonl`
)
await mkdir(dirname(transcriptPath), { recursive: true })
await writeFile(
transcriptPath,
`${JSON.stringify({
type: 'session_meta',
payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' }
})}\n${JSON.stringify({
type: 'response_item',
timestamp: '2026-09-06T18:00:01.000Z',
payload: { type: 'message', role: 'user', content: 'durable adopted history' }
})}\n`,
'utf8'
)
let selectedHome = originalHome
const selectAccountHome = vi.fn(() => selectedHome)
const runtime = new OrcaRuntimeService(
{
getSettings: () => ({ agentDefaultEnv: { codex: {} } })
} as never,
undefined,
{ prepareCodexStructuredLaunch: selectAccountHome }
)
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
supported: true
})
const internal = runtime as unknown as {
resolveStructuredAgentSessionLocation: () => Promise<{
executionHostId: 'local'
wslDistro: null
workspaceId: string
workspaceKind: 'git-worktree'
}>
resolveRuntimeFileTarget: () => Promise<{ worktree: { path: string } }>
ensureStructuredAgentSessionHost: () => Promise<void>
publishStructuredAgentSessionTab: () => Promise<void>
}
internal.resolveStructuredAgentSessionLocation = vi.fn(async () => ({
executionHostId: 'local' as const,
wslDistro: null,
workspaceId: WORKSPACE,
workspaceKind: 'git-worktree' as const
}))
internal.resolveRuntimeFileTarget = vi.fn(async () => ({
worktree: { path: '/repos/workspace-1' }
}))
internal.ensureStructuredAgentSessionHost = vi.fn(async () => undefined)
internal.publishStructuredAgentSessionTab = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error('simulated lost tab publication'))
.mockResolvedValue(undefined)
const store = await AgentSessionRecordStore.open({
directory: join(root, 'store'),
hostId: 'local'
})
const sessionAdapter = adapter()
host = new StructuredAgentSessionHost({
store,
adapter: sessionAdapter,
journalRoot: root,
claimKeyId: 'key-1'
})
setStructuredAgentSessionHost(host)
const dispatcher = new RpcDispatcher({
runtime,
methods: STRUCTURED_AGENT_SESSION_METHODS
})
const params = createParams()
expect(await call(dispatcher, params)).toMatchObject({
ok: true,
result: { ok: false, refusal: { code: 'agent_session_operation_unknown' } }
})
await rm(transcriptPath)
selectedHome = driftedHome
setStructuredAgentSessionHost(null)
internal.ensureStructuredAgentSessionHost = vi.fn(async () => {
setStructuredAgentSessionHost(host)
})
expect(await call(dispatcher, params)).toMatchObject({
ok: true,
result: {
ok: true,
replayed: true,
value: {
page: {
items: expect.arrayContaining([
expect.objectContaining({
body: expect.objectContaining({
blocks: expect.arrayContaining([
expect.objectContaining({ text: 'durable adopted history' })
])
})
})
])
}
}
}
})
expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1)
expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2)
expect(selectAccountHome).toHaveBeenCalledTimes(1)
const otherOperation = createParams(`${Date.now()}-00000000000000000000000000000002`)
expect(await call(dispatcher, otherOperation)).toMatchObject({
ok: true,
result: { ok: false, refusal: { code: 'agent_session_identity_required' } }
})
expect(await call(dispatcher, params, { ...CLIENT, clientId: 'device-b' })).toMatchObject({
ok: true,
result: { ok: false, refusal: { code: 'agent_session_identity_required' } }
})
expect(sessionAdapter.acquire).toHaveBeenCalledTimes(1)
expect(internal.publishStructuredAgentSessionTab).toHaveBeenCalledTimes(2)
})
})
@@ -24,6 +24,7 @@ import {
} from '../../../native-chat/agent-session-wire/structured-agent-session-attach'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import type { StructuredAgentSessionResumeSource } from '../../../../shared/structured-agent-session-create'
import type { OrcaRuntimeService } from '../../orca-runtime'
import {
resolveUncommittedStructuredCreate,
@@ -46,18 +47,24 @@ export async function prepareStructuredAgentSessionCreateForWorktree(args: {
envelope: AgentSessionMutationEnvelope
worktree: string
agent: 'claude' | 'codex'
caller: StructuredAgentSessionCaller
resumeFrom?: StructuredAgentSessionResumeSource
}): Promise<PreparedStructuredAgentSessionCreate> {
// Adoption replay may need the record loaded from disk before source discovery can be skipped.
let host = args.resumeFrom ? await args.ensureHost() : null
const resolved = await args.runtime.resolveStructuredAgentSessionCreateIntent({
envelope: args.envelope,
worktree: args.worktree,
agent: args.agent
agent: args.agent,
callerKey: args.caller.callerKey,
...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {})
})
const hostFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.attach',
sessionId: args.envelope.sessionId,
fields: attachFingerprintFields({ ...resolved, envelope: args.envelope })
})
const host = await args.ensureHost()
host ??= await args.ensureHost()
const { agent: _resolvedAgent, provider: _resolvedProvider, ...resolvedAttach } = resolved
return {
host,
@@ -0,0 +1,79 @@
// The method-to-gate classification from `structured-agent-session-gate.ts`, as a table the
// suites iterate. Adding an `agentSession.*` method means adding it to exactly one of these.
import {
attachParams,
envelope,
sendParams,
SESSION
} from './structured-agent-session-rpc.test-fixture'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
/** Stops or retires work the caller already owns, so admission may already have been revoked. */
export const CLEANUP_METHODS = [
{
method: 'agentSession.close',
params: { sessionId: SESSION },
hostCall: 'close'
},
{
method: 'agentSession.cancel',
params: { envelope: envelope(), turnId: 'turn-1' },
hostCall: 'cancel'
},
{
method: 'agentSession.release',
params: { sessionId: SESSION, holderId: 'surface-1' },
hostCall: 'release'
},
{
method: 'agentSession.unsubscribe',
params: { sessionId: SESSION },
hostCall: 'unsubscribe'
}
] as const
/** Starts, extends, retains or reads work, so every one stays refused once the setting is off. */
export const ADMISSION_METHODS = [
{ method: 'agentSession.createSupport', params: { worktree: 'id:workspace-1', agent: 'codex' } },
{
method: 'agentSession.create',
params: {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields: { worktree: 'id:workspace-1', agent: 'codex' }
})
}),
worktree: 'id:workspace-1',
agent: 'codex'
}
},
{ method: 'agentSession.ensure', params: attachParams() },
{ method: 'agentSession.send', params: sendParams() },
{
method: 'agentSession.respondToApproval',
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'allow' }
},
{
method: 'agentSession.respondToQuestion',
params: { envelope: envelope(), itemId: 'item-1', expectedRevision: 1, optionId: 'yes' }
},
{
method: 'agentSession.setOption',
params: { envelope: envelope(), key: 'model', value: 'gpt-live' }
},
{
method: 'agentSession.requestHandoff',
params: { envelope: envelope(), direction: 'to-tui', mode: 'now' }
},
{ method: 'agentSession.handoffStatus', params: { sessionId: SESSION } },
{ method: 'agentSession.options', params: { sessionId: SESSION } },
{ method: 'agentSession.history', params: { sessionId: SESSION, direction: 'tail' } },
{ method: 'agentSession.subscribe', params: { sessionId: SESSION } },
{ method: 'agentSession.hold', params: { sessionId: SESSION, holderId: 'surface-1' } },
{ method: 'agentSession.reveal', params: { sessionId: SESSION } },
{ method: 'agentSession.subscribeStatus', params: null }
] as const
@@ -12,7 +12,10 @@ import { getStructuredAgentSessionHost } from '../../../native-chat/agent-sessio
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import type { StructuredAgentSessionCaller } from '../../../native-chat/agent-session-wire/structured-agent-session-host-types'
import type { RpcContext } from '../core'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
import {
supportsStructuredAgentSessionCapability,
supportsStructuredAgentSessions
} from './structured-agent-session-policy'
/**
* In-process callers are the same build as the host, so they carry no negotiated
@@ -37,6 +40,39 @@ export function requireStructuredHost(ctx: RpcContext): StructuredAgentSessionHo
return host
}
/**
* WHICH GATE DOES A NEW `agentSession.*` METHOD GET?
*
* The host setting is admission control, and admission can be revoked while sessions are still
* open. So the surface splits by what a method does to work in flight, not by how dangerous it
* sounds:
*
* - Starts, extends, retains or reads work -> `requireStructuredHost`. Revoked admission means
* no new turns, no new holds, no new reads. create, send, ensure, setOption, requestHandoff,
* subscribe, hold, reveal, history, options and the status stream all live here.
* - Stops or retires work the caller already owns -> `requireStructuredCleanupHost`. close,
* cancel, unsubscribe and release live here.
*
* Cleanup keeps working after the setting is turned off because the alternative strands the user:
* a session opened while the setting was on stays open, and refusing its close leaves a chat with
* a live provider child that its own owner can no longer shut down. Stopping is never the thing
* the policy exists to prevent.
*
* Cleanup is not an escape hatch. It still demands the negotiated wire capability, so a client
* that never advertised the surface still cannot see it, and it never creates a host — it can
* only retire what already exists.
*/
export function requireStructuredCleanupHost(ctx: RpcContext): StructuredAgentSessionHost {
if (!supportsStructuredAgentSessionCapability(ctx)) {
throw new Error('structured_agent_session_unsupported')
}
const host = getStructuredAgentSessionHost()
if (!host) {
throw new Error('structured_agent_session_unsupported')
}
return host
}
/** Builds the host for the calls that address a session by durable record rather than by live
* state: attach, which is the only way a session comes into being, plus hold and reveal, which
* each reach for a record on disk this process may not have opened yet. Every other method
@@ -41,6 +41,7 @@ let runtime: OrcaRuntimeService
let dispatcher: RpcDispatcher
let closeSession: Mock<NonNullable<StructuredAgentSessionAdapter['closeSession']>>
let requests = 0
let structuredNativeChatEnabled = true
async function call(method: string, params: unknown): Promise<RpcResponse> {
const replies: RpcResponse[] = []
@@ -57,6 +58,7 @@ beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'orca-hold-wire-'))
resetHostTestOperationIds()
requests = 0
structuredNativeChatEnabled = true
closeSession = vi.fn(async () => true)
store = await AgentSessionRecordStore.open({ directory: join(root, 'store'), hostId: 'local' })
host = new StructuredAgentSessionHost({
@@ -86,6 +88,13 @@ beforeEach(async () => {
})
setStructuredAgentSessionHost(host)
runtime = new OrcaRuntimeService()
// The structured surface is settings-gated for every caller, in-process included.
vi.spyOn(runtime, 'getClientSettings').mockImplementation(
() =>
({ experimentalStructuredNativeChat: structuredNativeChatEnabled }) as ReturnType<
OrcaRuntimeService['getClientSettings']
>
)
dispatcher = new RpcDispatcher({ runtime, methods: STRUCTURED_AGENT_SESSION_METHODS })
expect(await host.attach({ callerKey: 'client-1' }, hostTestAttachParams(null))).toMatchObject({
ok: true
@@ -121,6 +130,23 @@ describe('a client that holds a session', () => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('releases its hold and cleanup after the setting is disabled', async () => {
const release = vi.spyOn(host, 'release')
await call('agentSession.hold', { sessionId: SESSION, holderId: 'chat-1' })
structuredNativeChatEnabled = false
expect(
await call('agentSession.release', { sessionId: SESSION, holderId: 'chat-1' })
).toMatchObject({ ok: true })
const releaseCallsAfterRpc = release.mock.calls.length
runtime.cleanupSubscriptionsForConnection(CONNECTION)
expect(releaseCallsAfterRpc).toBe(2)
expect(release).toHaveBeenCalledTimes(releaseCallsAfterRpc)
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('does not report success when no provider child can be acquired', async () => {
const response = await call('agentSession.hold', {
sessionId: 'session-missing',
@@ -213,6 +239,31 @@ describe('a client that disappears without cleanup', () => {
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('unsubscribes and releases stream retention after the setting is disabled', async () => {
await dispatcher.dispatchStreaming(
{
id: 'stream-disabled-cleanup',
authToken: 'token',
method: 'agentSession.subscribe',
params: { sessionId: SESSION }
},
() => {},
CLIENT
)
expect(host.isHeld(SESSION)).toBe(true)
structuredNativeChatEnabled = false
expect(
await call('agentSession.unsubscribe', {
sessionId: SESSION,
subscriptionId: 'stream-disabled-cleanup'
})
).toMatchObject({ ok: true })
await vi.waitFor(() => expect(host.hasSession(SESSION)).toBe(false))
expect(closeSession).toHaveBeenCalledWith(SESSION)
})
it('does not let a stream alone resume a released session', async () => {
await host.close(SESSION)
expect(host.hasSession(SESSION)).toBe(false)
@@ -12,6 +12,7 @@
import { defineMethod, type RpcAnyMethod, type RpcContext } from '../core'
import {
ensureStructuredHostInstalled,
requireStructuredCleanupHost,
requireStructuredHost
} from './structured-agent-session-gate'
import { HoldParams } from './structured-agent-session-schemas'
@@ -53,7 +54,7 @@ export const STRUCTURED_AGENT_SESSION_HOLD_METHODS: RpcAnyMethod[] = [
name: 'agentSession.release',
params: HoldParams,
handler: async (params, ctx) => {
const host = requireStructuredHost(ctx)
const host = requireStructuredCleanupHost(ctx)
const holderKey = holderKeyFor(ctx, params.holderId)
host.release(params.sessionId, holderKey)
// Retires the backstop too; its release is a no-op against a holder already gone.
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { supportsStructuredAgentSessions } from './structured-agent-session-policy'
function runtimeWithSetting(
experimentalStructuredNativeChat: boolean
): Pick<OrcaRuntimeService, 'getClientSettings'> {
return {
getClientSettings: () => ({ experimentalStructuredNativeChat })
} as unknown as Pick<OrcaRuntimeService, 'getClientSettings'>
}
const CAPABLE = [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
/** Every caller shape that reaches the policy: desktop renderer, paired phone, in-process. */
const CALLERS = [
{ name: 'desktop renderer', clientKind: 'runtime' as const, clientCapabilities: CAPABLE },
{ name: 'paired mobile', clientKind: 'mobile' as const, clientCapabilities: CAPABLE },
{ name: 'in-process', clientKind: undefined, clientCapabilities: undefined }
]
describe('supportsStructuredAgentSessions', () => {
it.each([true, false])('admits every caller alike when the setting is %s', (enabled) => {
const decisions = CALLERS.map((caller) =>
supportsStructuredAgentSessions({
clientKind: caller.clientKind,
clientCapabilities: caller.clientCapabilities,
runtime: runtimeWithSetting(enabled)
})
)
expect(decisions).toEqual([enabled, enabled, enabled])
})
it('admits a capability-less in-process caller, which negotiates nothing', () => {
expect(
supportsStructuredAgentSessions({
clientKind: undefined,
clientCapabilities: undefined,
runtime: runtimeWithSetting(true)
})
).toBe(true)
})
it('still refuses a remote client that did not advertise the capability', () => {
for (const clientKind of ['runtime', 'mobile'] as const) {
expect(
supportsStructuredAgentSessions({
clientKind,
clientCapabilities: [],
runtime: runtimeWithSetting(true)
})
).toBe(false)
}
})
it('leaves desktop launch admission unchanged, because launches require the setting anyway', () => {
// `agent-launch-routing.ts` refuses to route a structured launch unless
// `experimentalStructuredNativeChat` is on, so the only state a desktop launch can
// reach the host in is setting-on — which admits exactly as it did before.
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
runtime: runtimeWithSetting(true)
})
).toBe(true)
})
it('reads the setting from the caller-supplied value when no runtime is available', () => {
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
structuredNativeChatEnabled: true
})
).toBe(true)
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
structuredNativeChatEnabled: false
})
).toBe(false)
})
it('treats an unreadable settings store as off rather than admitting', () => {
expect(
supportsStructuredAgentSessions({
clientKind: 'runtime',
clientCapabilities: CAPABLE,
runtime: {
getClientSettings: () => {
throw new Error('settings unavailable')
}
} as unknown as Pick<OrcaRuntimeService, 'getClientSettings'>
})
).toBe(false)
})
})
@@ -20,18 +20,24 @@ export function isStructuredNativeChatEnabled(
}
}
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (context.clientKind === undefined) {
return true
}
const hasCapability =
export function supportsStructuredAgentSessionCapability(
context: Pick<StructuredPolicyContext, 'clientCapabilities' | 'clientKind'>
): boolean {
return (
context.clientKind === undefined ||
context.clientCapabilities?.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY) === true
if (!hasCapability) {
)
}
/**
* One rule for every caller. The host setting is policy and applies to desktop, mobile and
* in-process callers alike; the negotiated capability is a wire term, so it is asked of remote
* clients only — in-process callers are the same build as the host and never negotiate one.
*/
export function supportsStructuredAgentSessions(context: StructuredPolicyContext): boolean {
if (!supportsStructuredAgentSessionCapability(context)) {
return false
}
if (context.clientKind !== 'mobile') {
return true
}
return (
context.structuredNativeChatEnabled === true ||
(context.runtime ? isStructuredNativeChatEnabled(context.runtime) : false)
@@ -41,7 +47,8 @@ export function supportsStructuredAgentSessions(context: StructuredPolicyContext
export function structuredNativeChatProjectionEnabled(args: {
clientKind: 'mobile' | 'runtime' | undefined
clientCapabilities: readonly RuntimeCapability[] | undefined
structuredNativeChatEnabled?: boolean
// Required so no call site can silently project as if the host setting were off.
structuredNativeChatEnabled: boolean
}): boolean {
return supportsStructuredAgentSessions(args)
}
@@ -71,6 +71,9 @@ async function create(
): Promise<RpcResponse> {
const runtime = {
getRuntimeId: () => 'runtime-1',
// The structured surface is settings-gated for every caller; these fixtures probe the
// pre-commit boundary, which only runs once the gate admits the call.
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
@@ -0,0 +1,270 @@
// The `agentSession.*` dispatcher harness, shared by the suites that exercise the wire
// boundary. `hostCalls` and `runtimeCalls` keep one identity for the process and are
// repopulated per test, so a suite can read `hostCalls.close` without re-importing it.
import { vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
StructuredAgentSessionStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import type { OrcaRuntimeService } from '../../orca-runtime'
import { STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
export const SESSION = 'session-alpha'
export const FINGERPRINT = 'f'.repeat(64)
export const OPERATION = '1800000000000-00000000000000000000000000000001'
export function envelope(overrides: Record<string, unknown> = {}) {
return {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: 1,
payloadFingerprint: FINGERPRINT,
...overrides
}
}
export function sendParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope(),
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
...overrides
}
}
export function attachParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope({ expectedRuntimeFence: null }),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
...overrides
}
}
function request(method: string, params: unknown): RpcRequest {
return { id: 'request-1', authToken: 'token', method, params }
}
export const hostCalls: Record<string, ReturnType<typeof vi.fn>> = {}
export const runtimeCalls: Record<string, ReturnType<typeof vi.fn>> = {}
function reset(record: Record<string, ReturnType<typeof vi.fn>>): void {
for (const key of Object.keys(record)) {
delete record[key]
}
}
export const STATUS_SESSION = 'session-status'
export const STATUS_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: 2,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
}
]
/** One indexed session over a journal that reads back fixed items; the projection is real. */
function statusFeed(): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
STATUS_SESSION,
{
journal: {
isReadOnly: false,
lastActivityAt: () => 2,
snapshot: () => ({ items: STATUS_ITEMS })
} as unknown as AgentSessionJournal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
]
]),
getRecord: () => null,
now: () => 1_000
})
}
export function hostStub(): StructuredAgentSessionHost {
reset(hostCalls)
Object.assign(hostCalls, {
attach: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
sessionId: SESSION,
fence: 1,
page: {
sessionId: SESSION,
epoch: 'epoch-a',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-a', sequence: 0 }
},
liveCursor: { epoch: 'epoch-a', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
})),
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-1',
agent: 'codex' as const,
readable: true
})),
setSessionTabVisibility: vi.fn(async () => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
status: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
}
})),
supportsCreate: vi.fn(() => true),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
})),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
// A real feed, so the snapshot this method hands back is a genuine projection rather
// than a shape the stub restated.
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
statusFeed().subscribe(subscriber)
),
unsubscribe: vi.fn(),
release: vi.fn()
})
return hostCalls as unknown as StructuredAgentSessionHost
}
export function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
reset(runtimeCalls)
Object.assign(runtimeCalls, {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: params.agent,
agent: params.agent,
accountHome: {
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
},
options:
params.agent === 'claude'
? { model: 'opus', effort: 'high' }
: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
})
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
})
}
/** The reply path is the only one that carries a client's negotiated identity,
* which is exactly what the capability gate reads. */
export async function call(
method: string,
params: unknown,
client?: {
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
)
const first = replies[0]
if (!first) {
throw new Error(`no reply for ${method}`)
}
return first
}
export const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
export const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
/** Every suite wants the same lifecycle: a fresh stub per test, no host left installed. */
export function installStructuredHostStub(): void {
setStructuredAgentSessionHost(hostStub())
}
export function clearStructuredHostStub(): void {
setStructuredAgentSessionHost(null)
}
@@ -96,11 +96,21 @@ export const AttachParams = z
})
.strict()
/** An identity, and nothing the host would otherwise read off disk. A transcript path or account
* home here would let a client choose which file this host imports and which credential directory
* the provider child launches against; both are derived host-side from this id instead. */
const ResumeSource = z
.object({
providerSessionId: Identifier('Invalid provider session id')
})
.strict()
export const CreateIntentParams = z
.object({
envelope: MutationEnvelope,
worktree: Identifier('Invalid worktree selector'),
agent: z.enum(['claude', 'codex'])
agent: z.enum(['claude', 'codex']),
resumeFrom: ResumeSource.optional()
})
.strict()
@@ -1,15 +1,8 @@
// The wire boundary: who may see `agentSession.*` at all, and what shapes it
// accepts once they can.
// accepts once they can. The dispatcher harness lives in the shared fixture.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentJournalRenderItem } from '../../../../shared/agent-session-journal-types'
import type { AgentSessionJournal } from '../../../native-chat/agent-session-journal/journal-store'
import type { StructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-host'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
import {
StructuredAgentSessionStatusFeed,
type StructuredAgentSessionStatusSubscriber
} from '../../../native-chat/agent-session-wire/structured-agent-session-status-feed'
import {
RUNTIME_CAPABILITIES,
RUNTIME_PROTOCOL_VERSION,
@@ -17,252 +10,31 @@ import {
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY
} from '../../../../shared/protocol-version'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { RpcRequest, RpcResponse } from '../core'
import { RpcDispatcher } from '../dispatcher'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
import { ALL_RPC_METHODS } from './index'
import { STRUCTURED_AGENT_SESSION_METHODS } from './structured-agent-session'
import { computeAgentSessionPayloadFingerprint } from '../../../../shared/agent-session-mutation-envelope'
const SESSION = 'session-alpha'
const FINGERPRINT = 'f'.repeat(64)
const OPERATION = '1800000000000-00000000000000000000000000000001'
function envelope(overrides: Record<string, unknown> = {}) {
return {
sessionId: SESSION,
clientOperationId: OPERATION,
expectedRuntimeFence: 1,
payloadFingerprint: FINGERPRINT,
...overrides
}
}
function sendParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope(),
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'hi' }] },
...overrides
}
}
function attachParams(overrides: Record<string, unknown> = {}) {
return {
envelope: envelope({ expectedRuntimeFence: null }),
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: 'codex',
agent: 'codex',
accountHome: { variable: 'CODEX_HOME', path: '/home/dev/.codex' },
runtimeKind: 'native',
providerHandle: { kind: 'codex', threadId: 'thread-1' },
...overrides
}
}
function request(method: string, params: unknown): RpcRequest {
return { id: 'request-1', authToken: 'token', method, params }
}
let hostCalls: Record<string, ReturnType<typeof vi.fn>>
let runtimeCalls: Record<string, ReturnType<typeof vi.fn>>
const STATUS_SESSION = 'session-status'
const STATUS_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'user-1',
sequence: 1,
revision: 1,
observedAt: 1,
body: { kind: 'message', role: 'user', blocks: [{ type: 'text', text: 'write a poem' }] }
},
{
itemId: 'turn-1',
sequence: 2,
revision: 1,
observedAt: 2,
body: { kind: 'status', text: 'Working', turnLifecycle: { turnId: 'turn-1', state: 'running' } }
}
]
/** One indexed session over a journal that reads back fixed items; the projection is real. */
function statusFeed(): StructuredAgentSessionStatusFeed {
return new StructuredAgentSessionStatusFeed({
sessions: new Map([
[
STATUS_SESSION,
{
journal: {
isReadOnly: false,
lastActivityAt: () => 2,
snapshot: () => ({ items: STATUS_ITEMS })
} as unknown as AgentSessionJournal,
params: { location: { workspaceId: 'workspace-1' }, provider: 'codex' as const }
}
]
]),
getRecord: () => null,
now: () => 1_000
})
}
function hostStub(): StructuredAgentSessionHost {
hostCalls = {
attach: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
sessionId: SESSION,
fence: 1,
page: {
sessionId: SESSION,
epoch: 'epoch-a',
direction: 'tail',
items: [],
removedItemIds: [],
submissions: [],
window: {
oldest: null,
newest: null,
nextCursor: { epoch: 'epoch-a', sequence: 0 }
},
liveCursor: { epoch: 'epoch-a', sequence: 0 },
hasOlder: false,
hasNewer: false
},
unconfirmedClientMessageIds: []
}
})),
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-1',
agent: 'codex' as const,
readable: true
})),
setSessionTabVisibility: vi.fn(async () => undefined),
respondToPrompt: vi.fn(async () => ({ ok: true, replayed: false })),
setOption: vi.fn(async () => ({ ok: true, replayed: false })),
requestHandoff: vi.fn(async () => ({
ok: true,
replayed: false,
fence: 1,
cursor: { epoch: 'epoch-a', sequence: 0 },
value: {
status: {
owner: 'native',
direction: null,
phase: 'idle',
stage: null,
operationId: null
}
}
})),
supportsCreate: vi.fn(() => true),
handoffStatus: vi.fn(async () => ({ owner: 'native' })),
readOptions: vi.fn(async () => ({
models: [{ id: 'gpt-live', label: 'GPT Live', isDefault: true, efforts: [] }],
current: { model: 'gpt-live' }
})),
history: vi.fn(() => ({ ok: true, page: { items: [] } })),
subscribe: vi.fn(() => () => undefined),
// A real feed, so the snapshot this method hands back is a genuine projection rather
// than a shape the stub restated.
subscribeStatus: vi.fn((subscriber: StructuredAgentSessionStatusSubscriber) =>
statusFeed().subscribe(subscriber)
),
unsubscribe: vi.fn()
}
return hostCalls as unknown as StructuredAgentSessionHost
}
function dispatcher(runtimeOverrides: Record<string, unknown> = {}): RpcDispatcher {
runtimeCalls = {
getStructuredAgentSessionCreateSupport: vi.fn(async () => ({ supported: true })),
resolveStructuredAgentSessionCreateIntent: vi.fn(async (params) => ({
envelope: params.envelope,
location: {
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree'
},
provider: params.agent,
agent: params.agent,
accountHome: {
variable: params.agent === 'claude' ? 'CLAUDE_CONFIG_DIR' : 'CODEX_HOME',
path: params.agent === 'claude' ? '/host/.claude' : '/host/.codex'
},
options:
params.agent === 'claude'
? { model: 'opus', effort: 'high' }
: { model: 'gpt-5.6-sol', effort: 'medium' },
runtimeKind: 'native'
})),
publishStructuredAgentSessionTab: vi.fn()
}
const runtime = {
getRuntimeId: () => 'runtime-1',
registerSubscriptionCleanup: vi.fn(),
cleanupSubscription: vi.fn(),
cleanupSubscriptionsByPrefix: vi.fn(),
...runtimeCalls,
...runtimeOverrides
}
return new RpcDispatcher({
runtime: runtime as unknown as OrcaRuntimeService,
methods: STRUCTURED_AGENT_SESSION_METHODS
})
}
/** The reply path is the only one that carries a client's negotiated identity,
* which is exactly what the capability gate reads. */
async function call(
method: string,
params: unknown,
client?: {
clientId?: string
clientKind?: 'mobile' | 'runtime'
clientCapabilities?: string[]
},
runtimeOverrides: Record<string, unknown> = {}
): Promise<RpcResponse> {
const replies: RpcResponse[] = []
await dispatcher(runtimeOverrides).dispatchStreaming(
request(method, params),
(raw) => replies.push(JSON.parse(raw) as RpcResponse),
client
)
const first = replies[0]
if (!first) {
throw new Error(`no reply for ${method}`)
}
return first
}
const STRUCTURED_CLIENT = {
clientKind: 'runtime' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
const STRUCTURED_MOBILE_CLIENT = {
clientKind: 'mobile' as const,
clientCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY]
}
import { CLEANUP_METHODS } from './structured-agent-session-gate-classification.test-fixture'
import {
attachParams,
call,
clearStructuredHostStub,
envelope,
hostCalls,
installStructuredHostStub,
runtimeCalls,
SESSION,
sendParams,
STATUS_SESSION,
STRUCTURED_CLIENT,
STRUCTURED_MOBILE_CLIENT
} from './structured-agent-session-rpc.test-fixture'
beforeEach(() => {
setStructuredAgentSessionHost(hostStub())
installStructuredHostStub()
})
afterEach(() => {
setStructuredAgentSessionHost(null)
clearStructuredHostStub()
})
describe('agentSession.reveal', () => {
@@ -454,6 +226,41 @@ describe('capability gating', () => {
expect(hostCalls.send).toHaveBeenCalledTimes(1)
})
it.each(CLEANUP_METHODS)(
'keeps $method hidden from remote clients without the capability',
async ({ method, params, hostCall }) => {
const response = await call(method, params, {
clientKind: 'runtime',
clientCapabilities: []
})
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(hostCalls[hostCall]).not.toHaveBeenCalled()
}
)
it.each(CLEANUP_METHODS)(
'does not install a host for cleanup-only method $method',
async ({ method, params }) => {
const ensureHost = vi.fn()
setStructuredAgentSessionHost(null)
const response = await call(method, params, STRUCTURED_CLIENT, {
getClientSettings: () => ({ experimentalStructuredNativeChat: false }),
ensureStructuredAgentSessionHost: ensureHost
})
expect(response).toMatchObject({
ok: false,
error: { message: expect.stringContaining('structured_agent_session_unsupported') }
})
expect(ensureHost).not.toHaveBeenCalled()
}
)
it('serves an in-process caller, which negotiates no capabilities at all', async () => {
const response = await call('agentSession.send', sendParams())
expect(response).toMatchObject({ ok: true })
@@ -483,7 +290,10 @@ describe('method routing', () => {
}
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true, result: { ok: true } })
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({
...params,
callerKey: 'trusted-local:runtime'
})
expect(hostCalls.attach).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -497,6 +307,36 @@ describe('method routing', () => {
)
})
it.each(['claude', 'codex'])(
'forwards a %s history resume through create preparation',
async (agent) => {
const fields = {
worktree: 'id:workspace-1',
agent,
resumeFrom: { providerSessionId: 'prior-session' }
}
const params = {
envelope: envelope({
expectedRuntimeFence: null,
payloadFingerprint: computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION,
fields
})
}),
...fields
}
expect(await call('agentSession.create', params, STRUCTURED_CLIENT)).toMatchObject({
ok: true,
result: { ok: true }
})
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({
...params,
callerKey: 'trusted-local:runtime'
})
}
)
it('routes Claude create support and create through the provider-aware runtime', async () => {
const worktree = 'id:workspace-1'
const support = await call(
@@ -524,7 +364,10 @@ describe('method routing', () => {
}
const created = await call('agentSession.create', params, STRUCTURED_CLIENT)
expect(created).toMatchObject({ ok: true, result: { ok: true } })
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith(params)
expect(runtimeCalls.resolveStructuredAgentSessionCreateIntent).toHaveBeenCalledWith({
...params,
callerKey: 'trusted-local:runtime'
})
expect(hostCalls.attach).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
@@ -14,6 +14,7 @@ import { defineMethod, defineStreamingMethod, type RpcAnyMethod, type RpcContext
import {
ensureStructuredHostInstalled as ensureHostInstalled,
requireStructuredCapability,
requireStructuredCleanupHost,
requireStructuredHost as requireHost,
structuredCallerFor as callerFor,
supportsStructuredSessions
@@ -127,7 +128,14 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
const intentFingerprint = computeAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: params.envelope.sessionId,
fields: { worktree: params.worktree, agent: params.agent }
// `resumeFrom` is part of the intent, not a detail of it: without it here, a retry of
// "adopt this conversation" would replay as, or conflict with, a blank create. The
// canonicalizer drops `undefined`, so plain creates keep the digest they always had.
fields: {
worktree: params.worktree,
agent: params.agent,
resumeFrom: params.resumeFrom
}
})
const conflict = agentSessionFingerprintConflict(params.envelope, intentFingerprint)
if (conflict) {
@@ -141,7 +149,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
},
envelope: params.envelope,
worktree: params.worktree,
agent: params.agent as 'claude' | 'codex'
agent: params.agent as 'claude' | 'codex',
caller: callerFor(ctx),
...(params.resumeFrom ? { resumeFrom: params.resumeFrom } : {})
})
}
const { host, attachParams } = await resolveClientSuppliedAttach(params, ctx)
@@ -169,9 +179,10 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
handler: async (params, ctx) => requireHost(ctx).send(callerFor(ctx), params)
}),
defineMethod({
// Stopping a turn, so it stays available after admission is revoked: see the gate's rule.
name: 'agentSession.cancel',
params: CancelParams,
handler: async (params, ctx) => requireHost(ctx).cancel(callerFor(ctx), params)
handler: async (params, ctx) => requireStructuredCleanupHost(ctx).cancel(callerFor(ctx), params)
}),
defineMethod({
// Releasing a chat view, not ending a conversation: the record and journal stay on disk so the
@@ -179,7 +190,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
name: 'agentSession.close',
params: OptionsParams,
handler: async (params, ctx) => {
const host = requireHost(ctx)
// Cleanup gate: turning the host setting off must not strand an open chat whose owner can
// then never close it. See the rule on `requireStructuredCleanupHost`.
const host = requireStructuredCleanupHost(ctx)
// Terminal-disposal closes use this RPC without the session-tabs retirement RPC.
if (typeof host.setSessionTabVisibility === 'function') {
await host.setSessionTabVisibility(params.sessionId, false)
@@ -275,7 +288,9 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
name: 'agentSession.unsubscribe',
params: UnsubscribeParams,
handler: async (params, ctx) => {
requireHost(ctx)
// Why: cleanup must stay available after the setting is disabled, so an admitted caller can
// retire resources it already owns; the base still comes from main's shared helper.
requireStructuredCleanupHost(ctx)
const base = subscriptionBaseFor(ctx, params.sessionId)
if (params.subscriptionId) {
ctx.runtime.cleanupSubscription(`${base}:${params.subscriptionId}`)
@@ -0,0 +1,113 @@
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { AgentSessionExecutionLocation } from '../../shared/agent-session-record'
import { agentSessionExecutionLocationsEqual } from '../../shared/agent-session-record'
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
import type { StructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-host'
import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
import {
findCommittedStructuredAgentSessionAdoptionReplay,
findConflictingStructuredAdoption,
resolveStructuredAgentSessionAdoption,
structuredAdoptionConflictError
} from '../native-chat/structured-agent-session-history-adoption'
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
import { configuredAdditionalCodexHomePaths } from '../ai-vault/cached-session-list'
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
type AdoptionSettings = {
codexManagedAccounts?: readonly { managedHomePath: string }[]
}
export function resolveCommittedStructuredAgentSessionAdoptionIntent(input: {
host: StructuredAgentSessionHost | null
envelope: { sessionId: string; clientOperationId: string }
agent: 'claude' | 'codex'
callerKey?: string
resumeFrom?: { providerSessionId: string }
location: AgentSessionExecutionLocation
options?: Readonly<Record<string, string>>
}): AgentSessionAttachParams | null {
const replay =
input.resumeFrom && input.callerKey && input.host
? findCommittedStructuredAgentSessionAdoptionReplay({
agent: input.agent,
providerSessionId: input.resumeFrom.providerSessionId,
selfSessionId: input.envelope.sessionId,
callerKey: input.callerKey,
operationId: input.envelope.clientOperationId,
record: input.host.deps.store.getRecord(input.envelope.sessionId),
operations: input.host.deps.store.listOperationRows()
})
: null
if (!replay || !agentSessionExecutionLocationsEqual(replay.record.location, input.location)) {
return null
}
return {
envelope: {
sessionId: input.envelope.sessionId,
clientOperationId: input.envelope.clientOperationId,
expectedRuntimeFence: null,
payloadFingerprint: ''
},
location: input.location,
provider: input.agent,
agent: input.agent,
accountHome: replay.record.accountHome,
...(input.options ? { options: input.options } : {}),
adopt: { providerHandle: replay.providerHandle },
runtimeKind: replay.record.lease.runtimeKind
}
}
export async function resolveStructuredAgentSessionAdoptionForCreate(input: {
host: StructuredAgentSessionHost | null
settings: AdoptionSettings
agent: 'claude' | 'codex'
providerSessionId: string
selfSessionId: string
selectedAccountHomePath: string
}) {
const conflict = input.host
? findConflictingStructuredAdoption({
agent: input.agent,
providerSessionId: input.providerSessionId,
selfSessionId: input.selfSessionId,
ownership: listStructuredProviderSessionOwnership(input.host.deps.store.listRecords())
})
: null
if (conflict) {
throw structuredAdoptionConflictError(conflict)
}
return resolveStructuredAgentSessionAdoption({
agent: input.agent,
providerSessionId: input.providerSessionId,
candidateAccountHomes: structuredAdoptionAccountHomeCandidates(input),
resolveTranscript: async ({ agent, providerSessionId, accountHomePath }) =>
resolveSessionFilePath(
agent,
providerSessionId,
agent === 'claude'
? { claudeProjectsDir: join(accountHomePath, 'projects') }
: { codexSessionsDirs: [join(accountHomePath, 'sessions')] }
)
})
}
/** Recognised adoption homes, most-preferred first. */
function structuredAdoptionAccountHomeCandidates(input: {
settings: AdoptionSettings
agent: 'claude' | 'codex'
selectedAccountHomePath: string
}): string[] {
if (input.agent === 'claude') {
return [input.selectedAccountHomePath, join(homedir(), '.claude')]
}
return [
input.selectedAccountHomePath,
...(input.settings.codexManagedAccounts ?? []).map((account) => account.managedHomePath),
...configuredAdditionalCodexHomePaths(),
getOrcaManagedCodexHomePath(),
getSystemCodexHomePath()
]
}
@@ -242,6 +242,7 @@ beforeEach(async () => {
configuredCodexProfile = 'configured'
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
@@ -290,6 +290,7 @@ beforeEach(async () => {
configuredCodexProfile = 'configured'
const runtime = {
getRuntimeId: () => 'runtime-1',
getClientSettings: () => ({ experimentalStructuredNativeChat: true }),
getStructuredAgentSessionCreateSupport: async () => ({ supported: true }),
resolveStructuredAgentSessionCreateIntent: async () => {
const {
+1 -1
View File
@@ -293,7 +293,7 @@ function appendNormalizedToMultilineTailBuffer(
const line = rewritten[index]!
const lastChar = line.charCodeAt(line.length - 1)
if (lastChar === 32 || lastChar === 9) {
rewritten[index] = line.replace(/[ \t]+$/g, '')
rewritten[index] = trimTerminalLineRight(line)
}
}
for (const line of windowed.lines) {
@@ -185,7 +185,7 @@ function finalizeRetainedTerminalRows(
newlyCompletedLines: string[]
} {
let truncated = initialTruncated
let retainedRows = rows.map((row) => ({ ...row, text: row.text.replace(/[ \t]+$/g, '') }))
let retainedRows = rows.map((row) => ({ ...row, text: trimTerminalLineRight(row.text) }))
if (retainedRows.length > MAX_TAIL_LINES + 1) {
const removeCount = retainedRows.length - (MAX_TAIL_LINES + 1)
@@ -0,0 +1,32 @@
import { performance } from 'node:perf_hooks'
import { describe, expect, it } from 'vitest'
import { appendNormalizedToTailBuffer } from './terminal-tail-buffer'
import { trimTerminalLineRight } from './terminal-tail-line-controls'
describe('terminal redraw whitespace', () => {
it.each([
['hello \t', 'hello'],
[' \thello \t world \t', ' \thello \t world'],
[' \t', ''],
['hello\u00a0 \t', 'hello\u00a0'],
['hello\n', 'hello\n']
])('preserves terminal text while trimming spaces and tabs: %j', (input, expected) => {
expect(trimTerminalLineRight(input)).toBe(expected)
})
it.each([2, 20])('handles padded redraws across %i retained rows without stalling', (rows) => {
const padded = `${' '.repeat(32_000)}marker \t`
const previousLines = Array.from({ length: rows }, (_, index) =>
index === 0 ? padded : `row ${index}`
)
const start = performance.now()
let result: ReturnType<typeof appendNormalizedToTailBuffer> | undefined
for (let frame = 0; frame < 4; frame += 1) {
result = appendNormalizedToTailBuffer(previousLines, 'footer', '\x1b[1A\rupdated')
}
const elapsedMs = performance.now() - start
expect(result?.lines[0]).toBe(`${' '.repeat(32_000)}marker`)
// Interior padding made the trailing-whitespace regex backtrack quadratically.
expect(elapsedMs).toBeLessThan(500)
})
})
@@ -30,6 +30,8 @@ import {
resolveAiVaultSessionResumeState
} from './ai-vault-session-resume'
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import { resolveAiVaultSessionResumeInChatForWorkspace } from './ai-vault-session-resume-in-chat-workspace'
import {
useAiVaultSessionWorktreeMap,
withAiVaultCurrentWorktreeStatus
@@ -287,6 +289,22 @@ export default function AiVaultPanel(): React.JSX.Element {
[allWorktrees, effectiveActiveWorktreeId, getSessionWorktreeInfo, repos, resumeTargetState]
)
// Resuming into a chat asks a different question from resuming into a terminal: not "can this
// workspace host a PTY" but "will the provider still find this conversation from the workspace we
// would run it in". The workspace it targets is the session's own when that is open, because
// Claude looks its transcript up under a directory derived from the launch cwd.
const getSessionResumeInChat = useCallback(
(session: AiVaultSession): AiVaultResumeInChatEligibility =>
resolveAiVaultSessionResumeInChatForWorkspace({
session,
resumeState: getSessionResumeState(session),
activeWorkspaceId: effectiveActiveWorktreeId,
targetState: resumeTargetState,
settings
}),
[effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings]
)
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
preferredScopeRef.current = nextScope
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
@@ -366,7 +384,9 @@ export default function AiVaultPanel(): React.JSX.Element {
onJumpToOriginalPane={jumpToOriginalPane}
onJumpToWorktree={jumpToWorktree}
onResume={launchActions.handleResume}
getSessionResumeInChat={getSessionResumeInChat}
onContinueInNewSession={launchActions.handleContinueInNewSession}
onResumeInNewChat={launchActions.handleResumeInNewChat}
onCopyResume={(session, worktreeId) =>
void launchActions.copyResumeCommand(session, worktreeId)
}
@@ -4,6 +4,7 @@ import {
FolderOpen,
LocateFixed,
MessageSquarePlus,
MessagesSquare,
PanelTopOpen,
Play,
Trash2
@@ -19,6 +20,7 @@ export function SessionActionMenuItems({
resumeLabel,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onJumpToOriginalPane,
showJumpToWorktree,
onJumpToWorktree,
@@ -36,6 +38,7 @@ export function SessionActionMenuItems({
resumeLabel: string
onResume: () => void
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
onJumpToOriginalPane?: () => void
showJumpToWorktree: boolean
onJumpToWorktree?: () => void
@@ -93,6 +96,15 @@ export function SessionActionMenuItems({
<Play className="size-3.5" />
{resumeLabel}
</Item>
{onResumeInNewChat ? (
<Item onSelect={onResumeInNewChat}>
<MessagesSquare className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat',
'Resume in New Chat'
)}
</Item>
) : null}
{onContinueInNewSession ? (
<Item onSelect={onContinueInNewSession}>
<MessageSquarePlus className="size-3.5" />
@@ -1,5 +1,12 @@
import type React from 'react'
import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react'
import {
FileJson,
FolderGit2,
MessageSquare,
MessageSquarePlus,
MessagesSquare,
Play
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@@ -30,6 +37,7 @@ export function SessionInlineDetails({
onResumeInWorktree,
onResumeInNewTab,
onContinueInNewSession,
onResumeInNewChat,
onOpenLog
}: {
id: string
@@ -43,6 +51,7 @@ export function SessionInlineDetails({
onResumeInWorktree: () => void
onResumeInNewTab: () => void
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
onOpenLog?: () => void
}): React.JSX.Element {
// A zero-turn transcript would resume into an empty conversation, so the plain
@@ -68,7 +77,11 @@ export function SessionInlineDetails({
event.stopPropagation()
}}
>
{showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? (
{showResumeInWorktree ||
showResumeInNewTab ||
onContinueInNewSession ||
onResumeInNewChat ||
onOpenLog ? (
<div className="flex flex-wrap items-center gap-1.5 border-b border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{showResumeInWorktree ? (
<Button
@@ -110,6 +123,25 @@ export function SessionInlineDetails({
)}
</Button>
) : null}
{onResumeInNewChat ? (
<Button
type="button"
variant="secondary"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onResumeInNewChat()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<MessagesSquare className="size-3.5" />
{translate(
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewChat',
'Resume in New Chat'
)}
</Button>
) : null}
{onContinueInNewSession ? (
<Button
type="button"
@@ -39,6 +39,7 @@ export function VaultSessionRow({
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
resumeLabel,
resumeActions,
onResumeInWorktree,
@@ -65,6 +66,7 @@ export function VaultSessionRow({
onJumpToWorktree?: () => void
onResume: () => void
onContinueInNewSession?: () => void
onResumeInNewChat?: () => void
resumeLabel: string
resumeActions: AiVaultSessionResumeActions
onResumeInWorktree: () => void
@@ -173,6 +175,7 @@ export function VaultSessionRow({
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -217,6 +220,7 @@ export function VaultSessionRow({
onResumeInWorktree={onResumeInWorktree}
onResumeInNewTab={onResumeInNewTab}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onOpenLog={onOpenLog}
/>
) : null}
@@ -232,6 +236,7 @@ export function VaultSessionRow({
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -3,44 +3,28 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
import { getActiveStickyHeaderIndexForScroll } from '../sidebar/worktree-list/viewport/virtual-rows'
import { VaultGroupHeader } from './AiVaultPanelControls'
import { EmptyState, SessionLoadingState } from './AiVaultSessionListStates'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
import type {
AiVaultSessionResumeActions,
AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
canOpenAiVaultSessionLogInOrca,
canUseLocalAiVaultSessionPathActions
} from './ai-vault-session-path-actions'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import {
extractVaultVirtualRowIndexes,
getVaultStickyHeaderIndexes,
VAULT_GROUP_HEADER_ROW_HEIGHT,
VAULT_SESSION_ROW_HEIGHT
} from './ai-vault-virtual-rows'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
import { AiVaultVirtualRow, type AiVaultListRow } from './AiVaultVirtualRow'
const VAULT_ROW_OVERSCAN = 8
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
| { type: 'session'; groupKey: string; session: AiVaultSession }
export function AiVaultSessionVirtualList({
groups,
collapsedGroups,
@@ -56,11 +40,13 @@ export function AiVaultSessionVirtualList({
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
getSessionResumeInChat,
onToggleGroup,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onCopyResume,
onCopyId,
onCopyPath,
@@ -83,11 +69,13 @@ export function AiVaultSessionVirtualList({
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
getSessionResumeInChat: (session: AiVaultSession) => AiVaultResumeInChatEligibility
onToggleGroup: (key: string) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
@@ -219,12 +207,14 @@ export function AiVaultSessionVirtualList({
getWorktreeInfo={getWorktreeInfo}
getSessionResumeState={getSessionResumeState}
getSessionResumeActions={getSessionResumeActions}
getSessionResumeInChat={getSessionResumeInChat}
onToggleGroup={onToggleGroup}
onToggleSessionDetails={toggleSessionDetails}
onJumpToOriginalPane={onJumpToOriginalPane}
onJumpToWorktree={onJumpToWorktree}
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onCopyResume={onCopyResume}
onCopyId={onCopyId}
onCopyPath={onCopyPath}
@@ -239,176 +229,3 @@ export function AiVaultSessionVirtualList({
</div>
)
}
function AiVaultVirtualRow({
row,
index,
start,
activeStickyHeaderIndex,
measureElement,
collapsedGroups,
expandedSessionIds,
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onContinueInNewSession,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
}: {
row: AiVaultListRow | undefined
index: number
start: number
activeStickyHeaderIndex: number | null
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
}): React.JSX.Element | null {
if (!row) {
return null
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
// Why: omit the jump affordance when the session already lives in the
// worktree on screen — jumping there is a no-op.
const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo)
const worktreeJumpId =
showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const continuationWorktreeId =
row.type === 'session' &&
canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId)
? resumeState?.worktreeId
: null
// Gate resume on real content: a zero-turn transcript would resume into an
// empty conversation, so it is never offered as normally resumable.
const resumeGating =
row.type === 'session'
? aiVaultSessionRowResumeGating(row.session, resumeState)
: { resumeDisabled: true, canCopyResumeCommand: false }
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
const canOpenLocalSessionPaths =
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
// Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode)
// identities that have no single file to open, while Reveal/CWD stay on the
// existing local-path gate.
const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session)
return (
<div
ref={measureElement}
data-index={index}
className={cn(
'left-0 w-full',
isActiveStickyHeader ? 'sticky top-0 z-10 bg-sidebar' : 'absolute top-0'
)}
style={isActiveStickyHeader ? undefined : { transform: `translateY(${start}px)` }}
>
{row.type === 'group' ? (
<VaultGroupHeader
group={row.group}
collapsed={collapsedGroups.has(row.group.key)}
onToggle={() => onToggleGroup(row.group.key)}
/>
) : (
<VaultSessionRow
session={row.session}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
realHomeResumeStartup={buildResumeStartup(
{ ...row.session, codexHome: null },
resumeState?.worktreeId
)}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeGating.resumeDisabled}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onContinueInNewSession={
continuationWorktreeId
? () => onContinueInNewSession(row.session, continuationWorktreeId)
: undefined
}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={
resumeGating.canCopyResumeCommand
? () => onCopyResume(row.session, resumeState?.worktreeId)
: undefined
}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined}
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
onOpenCwd={
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
}
onRequestDelete={onRequestDelete}
/>
)}
</div>
)
}
@@ -0,0 +1,212 @@
import type { AgentStatusState } from '../../../../shared/agent-status-types'
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
import { cn } from '@/lib/utils'
import { VaultGroupHeader } from './AiVaultPanelControls'
import { VaultSessionRow } from './AiVaultSessionRow'
import type { AiVaultSessionGroup } from './ai-vault-session-filters'
import type { AiVaultOriginalPaneTarget } from './ai-vault-original-pane'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,
type AiVaultSessionResumeActions,
type AiVaultSessionResumeState
} from './ai-vault-session-resume'
import {
canJumpToAiVaultSessionWorktree,
isAiVaultSessionInCurrentWorktree,
type AiVaultSessionWorktreeInfo
} from './ai-vault-session-worktree'
import {
canOpenAiVaultSessionLogInOrca,
canUseLocalAiVaultSessionPathActions
} from './ai-vault-session-path-actions'
import { canContinueAiVaultSessionInNewSession } from './ai-vault-session-continuation'
import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat'
export type AiVaultListRow =
| { type: 'group'; group: AiVaultSessionGroup }
| { type: 'session'; groupKey: string; session: AiVaultSession }
export function AiVaultVirtualRow({
row,
index,
start,
activeStickyHeaderIndex,
measureElement,
collapsedGroups,
expandedSessionIds,
vaultScope,
buildResumeStartup,
getOriginalPaneTarget,
getSessionLiveState,
getWorktreeInfo,
getSessionResumeState,
getSessionResumeActions,
getSessionResumeInChat,
onToggleGroup,
onToggleSessionDetails,
onJumpToOriginalPane,
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onCopyResume,
onCopyId,
onCopyPath,
onOpenLog,
onRevealLog,
onOpenCwd,
onRequestDelete
}: {
row: AiVaultListRow | undefined
index: number
start: number
activeStickyHeaderIndex: number | null
measureElement: (node: Element | null) => void
collapsedGroups: ReadonlySet<string>
expandedSessionIds: ReadonlySet<string>
vaultScope: AiVaultScope
buildResumeStartup: (session: AiVaultSession, worktreeId?: string | null) => AiVaultResumeStartup
getOriginalPaneTarget: (session: AiVaultSession) => AiVaultOriginalPaneTarget | null
getSessionLiveState: (session: AiVaultSession) => AgentStatusState | null
getWorktreeInfo: (session: AiVaultSession) => AiVaultSessionWorktreeInfo | null
getSessionResumeState: (session: AiVaultSession) => AiVaultSessionResumeState
getSessionResumeActions: (session: AiVaultSession) => AiVaultSessionResumeActions
getSessionResumeInChat: (session: AiVaultSession) => AiVaultResumeInChatEligibility
onToggleGroup: (key: string) => void
onToggleSessionDetails: (sessionId: string) => void
onJumpToOriginalPane: (session: AiVaultSession) => void
onJumpToWorktree: (worktreeId: string) => void
onResume: (session: AiVaultSession, worktreeId: string) => void
onContinueInNewSession: (session: AiVaultSession, worktreeId: string) => void
onResumeInNewChat: (session: AiVaultSession, worktreeId: string) => void
onCopyResume: (session: AiVaultSession, worktreeId?: string | null) => void
onCopyId: (session: AiVaultSession) => void
onCopyPath: (session: AiVaultSession) => void
onOpenLog: (session: AiVaultSession) => void
onRevealLog: (session: AiVaultSession) => void
onOpenCwd: (session: AiVaultSession) => void
onRequestDelete: (session: AiVaultSession) => void
}): React.JSX.Element | null {
if (!row) {
return null
}
const isActiveStickyHeader = row.type === 'group' && activeStickyHeaderIndex === index
const originalPaneTarget = row.type === 'session' ? getOriginalPaneTarget(row.session) : null
const worktreeInfo = row.type === 'session' ? getWorktreeInfo(row.session) : null
// Why: omit the jump affordance when the session already lives in the
// worktree on screen — jumping there is a no-op.
const showJumpToWorktree = !isAiVaultSessionInCurrentWorktree(worktreeInfo)
const worktreeJumpId =
showJumpToWorktree && canJumpToAiVaultSessionWorktree(worktreeInfo)
? worktreeInfo?.worktreeId
: null
const resumeState = row.type === 'session' ? getSessionResumeState(row.session) : null
const resumeActions = row.type === 'session' ? getSessionResumeActions(row.session) : null
const resumeInChat = row.type === 'session' ? getSessionResumeInChat(row.session) : null
const continuationWorktreeId =
row.type === 'session' &&
canContinueAiVaultSessionInNewSession(row.session, resumeState?.worktreeId)
? resumeState?.worktreeId
: null
// Gate resume on real content: a zero-turn transcript would resume into an
// empty conversation, so it is never offered as normally resumable.
const resumeGating =
row.type === 'session'
? aiVaultSessionRowResumeGating(row.session, resumeState)
: { resumeDisabled: true, canCopyResumeCommand: false }
const resumeLabel = resumeState ? aiVaultSessionResumeLabel(resumeState) : ''
const canOpenLocalSessionPaths =
row.type === 'session' && canUseLocalAiVaultSessionPathActions(row.session.executionHostId)
// Why: in-Orca View Log additionally withholds synthetic (SQLite/OpenCode)
// identities that have no single file to open, while Reveal/CWD stay on the
// existing local-path gate.
const canOpenLogInOrca = row.type === 'session' && canOpenAiVaultSessionLogInOrca(row.session)
return (
<div
ref={measureElement}
data-index={index}
className={cn(
'left-0 w-full',
isActiveStickyHeader ? 'sticky top-0 z-10 bg-sidebar' : 'absolute top-0'
)}
style={isActiveStickyHeader ? undefined : { transform: `translateY(${start}px)` }}
>
{row.type === 'group' ? (
<VaultGroupHeader
group={row.group}
collapsed={collapsedGroups.has(row.group.key)}
onToggle={() => onToggleGroup(row.group.key)}
/>
) : (
<VaultSessionRow
session={row.session}
liveState={getSessionLiveState(row.session)}
resumeStartup={buildResumeStartup(row.session, resumeState?.worktreeId)}
realHomeResumeStartup={buildResumeStartup(
{ ...row.session, codexHome: null },
resumeState?.worktreeId
)}
worktreeInfo={worktreeInfo}
vaultScope={vaultScope}
detailsExpanded={expandedSessionIds.has(row.session.id)}
resumeDisabled={resumeGating.resumeDisabled}
resumeLabel={resumeLabel}
resumeActions={
resumeActions ?? {
worktree: { worktreeId: null, disabled: true },
newTab: { worktreeId: null, disabled: true }
}
}
onToggleDetails={() => onToggleSessionDetails(row.session.id)}
onJumpToOriginalPane={
originalPaneTarget ? () => onJumpToOriginalPane(row.session) : undefined
}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={worktreeJumpId ? () => onJumpToWorktree(worktreeJumpId) : undefined}
onResume={() => {
if (resumeState?.worktreeId) {
onResume(row.session, resumeState.worktreeId)
}
}}
onContinueInNewSession={
continuationWorktreeId
? () => onContinueInNewSession(row.session, continuationWorktreeId)
: undefined
}
onResumeInNewChat={
resumeInChat?.available
? () => onResumeInNewChat(row.session, resumeInChat.workspaceId)
: undefined
}
onResumeInWorktree={() => {
if (resumeActions?.worktree.worktreeId) {
onResume(row.session, resumeActions.worktree.worktreeId)
}
}}
onResumeInNewTab={() => {
if (resumeActions?.newTab.worktreeId) {
onResume(row.session, resumeActions.newTab.worktreeId)
}
}}
onCopyResume={
resumeGating.canCopyResumeCommand
? () => onCopyResume(row.session, resumeState?.worktreeId)
: undefined
}
onCopyId={() => onCopyId(row.session)}
onCopyPath={() => onCopyPath(row.session)}
onOpenLog={canOpenLogInOrca ? () => onOpenLog(row.session) : undefined}
onRevealLog={canOpenLocalSessionPaths ? () => onRevealLog(row.session) : undefined}
onOpenCwd={
canOpenLocalSessionPaths && row.session.cwd ? () => onOpenCwd(row.session) : undefined
}
onRequestDelete={onRequestDelete}
/>
)}
</div>
)
}
@@ -53,6 +53,7 @@ export function SessionRowTrailingActions({
onJumpToWorktree,
onResume,
onContinueInNewSession,
onResumeInNewChat,
onCopyResume,
onCopyId,
onCopyPath,
@@ -75,6 +76,8 @@ export function SessionRowTrailingActions({
onJumpToWorktree?: () => void
onResume: () => void
onContinueInNewSession?: () => void
/** Passed through to the overflow menu only; the resting row keeps its two-icon budget. */
onResumeInNewChat?: () => void
onCopyResume?: () => void
onCopyId: () => void
onCopyPath: () => void
@@ -256,6 +259,7 @@ export function SessionRowTrailingActions({
resumeLabel={resumeLabel}
onResume={onResume}
onContinueInNewSession={onContinueInNewSession}
onResumeInNewChat={onResumeInNewChat}
onJumpToOriginalPane={onJumpToOriginalPane}
showJumpToWorktree={showJumpToWorktree}
onJumpToWorktree={onJumpToWorktree}
@@ -10,25 +10,24 @@ import {
activateAndRevealWorktree
} from '@/lib/worktree-activation'
import { useAppStore } from '@/store'
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import type { AiVaultAgent, AiVaultSession } from '../../../../shared/ai-vault-types'
import { prepareAiVaultSessionForResume } from '@/lib/ai-vault-session-resume-preparation'
import type { Worktree } from '../../../../shared/worktree/types'
import { translate } from '@/i18n/i18n'
import { agentLabel } from './ai-vault-session-filters'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
isKnownAiVaultResumeWorkspaceTarget,
type AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuation'
import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { activateAiVaultStructuredSession } from '@/lib/activate-ai-vault-structured-session'
import { startStructuredAgentLaunch } from '@/lib/structured-agent-session-launch'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { hasRuntimeRpcErrorCode } from '../../../../shared/runtime-rpc-error-code'
import {
aiVaultResumeUnsupportedMessage,
resolveAiVaultSessionLaunchTarget,
resolveAiVaultTargetWorkspacePath
} from './ai-vault-session-launch-target'
export function useAiVaultSessionLaunchActions({
activeWorktree,
@@ -147,6 +146,44 @@ export function useAiVaultSessionLaunchActions({
[activeWorktree?.id, activeWorktreeId, buildResumeStartup, targetState]
)
const handleResumeInNewChat = useCallback(
(session: AiVaultSession, targetWorktreeId?: string): void => {
if (!isAgentSessionHandleProvider(session.agent)) {
return
}
const worktreeId = targetWorktreeId ?? activeWorktreeId ?? activeWorktree?.id ?? null
if (!worktreeId) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.openWorkspaceBeforeResuming',
'Open a workspace before resuming a session.'
)
)
return
}
// Codex rows can live under a shared legacy home; the same preparation the terminal resume
// runs re-pins them, and its result is what names the conversation the host will look for.
void prepareAiVaultSessionForResume(session)
.then((preparedSession) => {
const launch = startStructuredAgentLaunch(
worktreeId,
session.agent as 'claude' | 'codex',
{
resumeFrom: { providerSessionId: preparedSession.sessionId }
}
)
return launch.launchResult
})
.then(() => {
if (useAppStore.getState().activeWorktreeId !== worktreeId) {
activateAiVaultResumeWorkspace(worktreeId)
}
})
.catch(notifyAiVaultSessionResumeInChatFailure)
},
[activeWorktree?.id, activeWorktreeId]
)
const handleContinueInNewSession = useCallback(
(session: AiVaultSession, targetWorktreeId: string): void => {
const targetId = resolveAiVaultSessionLaunchTargetOrNotify({
@@ -194,12 +231,43 @@ export function useAiVaultSessionLaunchActions({
buildResumeStartup,
copyResumeCommand,
handleResume,
handleResumeInNewChat,
handleContinueInNewSession,
continuationRequest,
handleContinuationDialogOpenChange
}
}
/** The host refuses an adoption whose conversation another chat already holds, and refuses one it
* cannot find under any account home it recognises. Both are actionable, and neither is the
* generic "could not prepare" the terminal resume reports. */
function notifyAiVaultSessionResumeInChatFailure(error: unknown): void {
if (hasRuntimeRpcErrorCode(error, 'agent_session_conflict')) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict',
'Another chat is already holding this conversation.'
)
)
return
}
if (hasRuntimeRpcErrorCode(error, 'agent_session_identity_required')) {
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeInChatTranscriptMissing',
"This conversation's history could not be loaded, so it cannot be resumed in chat."
)
)
return
}
toast.error(
translate(
'auto.components.right.sidebar.AiVaultPanel.resumeInChatFailed',
'Could not resume this session in a new chat.'
)
)
}
function notifyAiVaultSessionPreparationFailure(error: unknown): void {
toast.error(
error instanceof Error
@@ -211,66 +279,9 @@ function notifyAiVaultSessionPreparationFailure(error: unknown): void {
)
}
function resolveAiVaultTargetWorkspacePath(
state: AiVaultSessionResumeTargetState,
workspaceId: string
): string | null {
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
return (
state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId)
?.folderPath ?? null
)
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null
}
export type AiVaultSessionLaunchTarget =
| { status: 'missing' }
| {
status: 'unsupported'
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
}
| { status: 'ready'; worktreeId: string }
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
}): AiVaultSessionLaunchTarget {
const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId
if (
!targetWorktreeId ||
!isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId)
) {
return { status: 'missing' }
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
args.targetState,
targetWorktreeId
)
if (
!canResumeAiVaultSessionOnTarget({
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
targetExecutionHostId
})
) {
return { status: 'unsupported', targetStatus }
}
return { status: 'ready', worktreeId: targetWorktreeId }
}
function resolveAiVaultSessionLaunchTargetOrNotify(
args: Parameters<typeof resolveAiVaultSessionLaunchTarget>[0]
): Extract<AiVaultSessionLaunchTarget, { status: 'ready' }> | null {
): Extract<ReturnType<typeof resolveAiVaultSessionLaunchTarget>, { status: 'ready' }> | null {
const target = resolveAiVaultSessionLaunchTarget(args)
if (target.status === 'missing') {
toast.error(
@@ -288,23 +299,6 @@ function resolveAiVaultSessionLaunchTargetOrNotify(
return target
}
function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
// Why: local and SSH targets can both be valid generally; this branch means
// the session's recorded host does not match the selected workspace.
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
'This session belongs to a different host. Open a workspace on the same host to resume it.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a workspace before resuming a session.'
)
}
function activateAiVaultResumeWorkspace(workspaceId: string): void {
const workspaceScope = parseWorkspaceKey(workspaceId)
if (workspaceScope?.type === 'folder') {
@@ -0,0 +1,87 @@
import {
canResumeAiVaultSessionOnTarget,
getAiVaultResumeWorkspaceExecutionHostId,
getAiVaultResumeWorkspaceTargetStatus
} from '@/lib/ai-vault-resume-target'
import { translate } from '@/i18n/i18n'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
isKnownAiVaultResumeWorkspaceTarget,
type AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
export function resolveAiVaultTargetWorkspacePath(
state: AiVaultSessionResumeTargetState,
workspaceId: string
): string | null {
const scope = parseWorkspaceKey(workspaceId)
if (scope?.type === 'folder') {
return (
state.folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId)
?.folderPath ?? null
)
}
const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceId
return findWorktreeById(state.worktreesByRepo, worktreeId)?.path ?? null
}
export type AiVaultSessionLaunchTarget =
| { status: 'missing' }
| {
status: 'unsupported'
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
}
| { status: 'ready'; worktreeId: string }
export function resolveAiVaultSessionLaunchTarget(args: {
sessionFilePath: string | null
sessionExecutionHostId?: AiVaultSession['executionHostId'] | null
activeWorktreeId: string | null
targetWorktreeId?: string
targetState: AiVaultSessionResumeTargetState
}): AiVaultSessionLaunchTarget {
const targetWorktreeId = args.targetWorktreeId ?? args.activeWorktreeId
if (
!targetWorktreeId ||
!isKnownAiVaultResumeWorkspaceTarget(args.targetState, targetWorktreeId)
) {
return { status: 'missing' }
}
const targetStatus = getAiVaultResumeWorkspaceTargetStatus(args.targetState, targetWorktreeId)
const targetExecutionHostId = getAiVaultResumeWorkspaceExecutionHostId(
args.targetState,
targetWorktreeId
)
if (
!canResumeAiVaultSessionOnTarget({
sessionFilePath: args.sessionFilePath,
sessionExecutionHostId: args.sessionExecutionHostId,
targetStatus,
targetExecutionHostId
})
) {
return { status: 'unsupported', targetStatus }
}
return { status: 'ready', worktreeId: targetWorktreeId }
}
export function aiVaultResumeUnsupportedMessage(
targetStatus: ReturnType<typeof getAiVaultResumeWorkspaceTargetStatus>
): string {
// Why: local and SSH targets can both be valid generally; this branch means
// the session's recorded host does not match the selected workspace.
if (targetStatus === 'ssh' || targetStatus === 'local' || targetStatus === 'runtime') {
return translate(
'auto.components.right.sidebar.AiVaultPanel.sessionHostMismatchUnsupported',
'This session belongs to a different host. Open a workspace on the same host to resume it.'
)
}
return translate(
'auto.components.right.sidebar.AiVaultPanel.openSupportedWorkspace',
'Open a workspace before resuming a session.'
)
}
@@ -0,0 +1,64 @@
import {
structuredAgentLaunchSupported,
type AgentLaunchRoutingInput
} from '@/lib/agent-launch-routing'
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
import { useAppStore } from '@/store'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target'
import {
resolveAiVaultSessionResumeInChatEligibility,
type AiVaultResumeInChatEligibility
} from './ai-vault-session-resume-in-chat'
import type {
AiVaultSessionResumeState,
AiVaultSessionResumeTargetState
} from './ai-vault-session-resume'
export function resolveAiVaultSessionResumeInChatForWorkspace(args: {
session: AiVaultSession
resumeState: AiVaultSessionResumeState
activeWorkspaceId: string | null
targetState: AiVaultSessionResumeTargetState
settings: AgentLaunchRoutingInput['settings']
}): AiVaultResumeInChatEligibility {
const targetWorkspaceId = args.resumeState.usesSessionWorktree
? args.resumeState.worktreeId
: (args.resumeState.worktreeId ?? args.activeWorkspaceId)
const targetWorkspacePath = targetWorkspaceId
? resolveAiVaultTargetWorkspacePath(args.targetState, targetWorkspaceId)
: null
return resolveAiVaultSessionResumeInChatEligibility({
session: args.session,
targetWorkspaceId,
targetWorkspacePath,
structuredRouteAvailable:
isAgentSessionHandleProvider(args.session.agent) &&
Boolean(targetWorkspaceId) &&
structuredAgentLaunchSupported({
agent: args.session.agent,
settings: args.settings,
executionHostId: getExecutionHostIdForWorktree(
useAppStore.getState(),
targetWorkspaceId as string
),
platform: CLIENT_PLATFORM,
hostCapabilities: readLocalRuntimeCapabilities(),
workspaceKind: (targetWorkspaceId as string).startsWith('folder:')
? 'folder'
: 'git-worktree',
projectRuntime: getLocalProjectExecutionRuntimeContext(
useAppStore.getState(),
targetWorkspaceId as string
)
}) &&
readLocalRuntimeCapabilities().includes(
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
)
})
}
@@ -0,0 +1,170 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import {
aiVaultSessionCwdMatchesWorkspace,
resolveAiVaultSessionResumeInChatEligibility
} from './ai-vault-session-resume-in-chat'
type ResumeInChatSession = Parameters<
typeof resolveAiVaultSessionResumeInChatEligibility
>[0]['session']
const WORKSPACE_PATH = '/repo/orca'
function session(overrides: Partial<ResumeInChatSession> = {}): ResumeInChatSession {
return {
agent: 'claude',
cwd: WORKSPACE_PATH,
filePath: '/home/dev/.claude/projects/-repo-orca/session-1.jsonl',
executionHostId: 'local',
messageCount: 12,
previewMessages: [],
...overrides
}
}
function eligibility(
overrides: Partial<Parameters<typeof resolveAiVaultSessionResumeInChatEligibility>[0]> = {}
) {
return resolveAiVaultSessionResumeInChatEligibility({
session: session(),
targetWorkspaceId: 'repo-1::/repo/orca',
targetWorkspacePath: WORKSPACE_PATH,
structuredRouteAvailable: true,
...overrides
})
}
describe('resolveAiVaultSessionResumeInChatEligibility', () => {
it('offers the chat for a local Claude row in its own workspace', () => {
expect(eligibility()).toEqual({ available: true, workspaceId: 'repo-1::/repo/orca' })
})
it.each(['hermes', 'grok', 'opencode'] as AiVaultSession['agent'][])(
'refuses %s, which has no structured lane',
(agent) => {
expect(eligibility({ session: session({ agent }) })).toEqual({
available: false,
reason: 'agent'
})
}
)
it('refuses a row already adopted into a chat before any other check', () => {
// That row reopens its own chat; a second adoption is a conflict the host would refuse.
expect(
eligibility({
session: {
...session(),
structuredSession: { sessionId: 'claude_1', workspaceId: 'repo-1::/repo/orca' }
}
})
).toEqual({ available: false, reason: 'already-structured' })
})
it('refuses a row recorded on a remote host', () => {
expect(eligibility({ session: session({ executionHostId: 'ssh:build-box' }) })).toEqual({
available: false,
reason: 'remote'
})
})
it('refuses a row whose transcript is stored inside WSL', () => {
expect(
eligibility({
session: session({
filePath: '//wsl.localhost/Ubuntu-22.04/home/dev/.claude/projects/p/session-1.jsonl'
})
})
).toEqual({ available: false, reason: 'remote' })
})
it('refuses a transcript that holds no conversation', () => {
expect(eligibility({ session: session({ messageCount: 0, previewMessages: [] }) })).toEqual({
available: false,
reason: 'empty'
})
})
it('offers a zero-count row whose preview proves the turns exist', () => {
// Some parsers only learn the turn count from metadata that may be absent.
expect(
eligibility({
session: session({
messageCount: 0,
previewMessages: [{ role: 'user', text: 'hello', timestamp: null }]
})
})
).toMatchObject({ available: true })
})
it('refuses when the same pair could not take the structured route for a fresh chat', () => {
expect(eligibility({ structuredRouteAvailable: false })).toEqual({
available: false,
reason: 'workspace'
})
})
it('refuses when there is no target workspace at all', () => {
expect(eligibility({ targetWorkspaceId: null })).toEqual({
available: false,
reason: 'workspace'
})
})
})
describe('workspace matching, which only Claude is bound by', () => {
it('refuses a Claude row whose conversation was recorded in another workspace', () => {
// Claude's SDK keys transcripts by launch cwd, so resuming elsewhere silently finds nothing.
expect(
eligibility({
session: session({ cwd: '/repo/other' }),
targetWorkspacePath: WORKSPACE_PATH
})
).toEqual({ available: false, reason: 'workspace' })
})
it('refuses a Claude row that recorded no cwd', () => {
expect(eligibility({ session: session({ cwd: null }) })).toEqual({
available: false,
reason: 'workspace'
})
})
it('keeps Codex available in a different workspace, and with no recorded cwd', () => {
// Codex is handed the rollout file and a cwd, so it resumes anywhere.
expect(eligibility({ session: session({ agent: 'codex', cwd: '/repo/other' }) })).toMatchObject(
{ available: true }
)
expect(eligibility({ session: session({ agent: 'codex', cwd: null }) })).toMatchObject({
available: true
})
})
it('treats Windows spellings of one directory as the same workspace', () => {
expect(
eligibility({
session: session({ cwd: 'C:\\Users\\Dev\\repo\\Orca\\' }),
targetWorkspacePath: 'c:/users/dev/repo/orca'
})
).toMatchObject({ available: true })
})
})
describe('aiVaultSessionCwdMatchesWorkspace', () => {
it('ignores separator, case, and a trailing slash', () => {
expect(aiVaultSessionCwdMatchesWorkspace('C:\\repo\\Orca', 'c:/repo/orca')).toBe(true)
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca/', '/repo/orca')).toBe(true)
expect(aiVaultSessionCwdMatchesWorkspace(' /repo/orca ', '/repo/orca')).toBe(true)
})
it('never calls a missing path a match', () => {
expect(aiVaultSessionCwdMatchesWorkspace(null, '/repo/orca')).toBe(false)
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca', null)).toBe(false)
expect(aiVaultSessionCwdMatchesWorkspace('', '')).toBe(false)
})
it('does not treat a sibling directory as the same workspace', () => {
expect(aiVaultSessionCwdMatchesWorkspace('/repo/orca-2', '/repo/orca')).toBe(false)
})
})
@@ -0,0 +1,100 @@
// Whether an Agent Session History row can be resumed into a structured native chat, and where.
//
// Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the
// terminal resume asks whether a workspace can host a PTY, this asks whether a provider will still
// find the conversation from the workspace we would run it in.
import { isWslStoredAiVaultSessionFile } from '@/lib/ai-vault-resume-target'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
import {
isAiVaultSessionResumableContent,
type AiVaultSession
} from '../../../../shared/ai-vault-types'
export type AiVaultResumeInChatBlockedReason =
| 'agent'
| 'remote'
| 'empty'
| 'already-structured'
| 'workspace'
export type AiVaultResumeInChatEligibility =
| { available: true; workspaceId: string }
| { available: false; reason: AiVaultResumeInChatBlockedReason }
/**
* Claude and Codex do not have the same freedom about *where* a conversation may be resumed.
*
* Codex is handed the rollout file and a cwd, so it can resume into any workspace. Claude's SDK
* stores transcripts under a project key derived from the launch cwd, so resuming from a workspace
* other than the one the conversation was recorded in looks in a directory the transcript is not in.
* That is a resume that silently yields nothing, which is worse than a disabled affordance.
*/
export function aiVaultSessionResumeInChatWorkspaceMatters(
agent: AiVaultSession['agent']
): boolean {
return agent === 'claude'
}
/**
* Does the row's recorded directory name the same place as the target workspace?
*
* Uses the shared runtime-path comparison rather than a local normalizer, which also keeps POSIX
* paths case-SENSITIVE — folding their case would call two genuinely different directories the same.
*/
export function aiVaultSessionCwdMatchesWorkspace(
cwd: string | null | undefined,
workspacePath: string | null | undefined
): boolean {
if (!cwd || !workspacePath) {
return false
}
return (
normalizeRuntimePathForComparison(cwd.trim()) ===
normalizeRuntimePathForComparison(workspacePath.trim())
)
}
export function resolveAiVaultSessionResumeInChatEligibility(args: {
session: Pick<
AiVaultSession,
'agent' | 'cwd' | 'filePath' | 'executionHostId' | 'messageCount' | 'previewMessages'
> & { structuredSession?: AiVaultSession['structuredSession'] }
targetWorkspaceId: string | null
targetWorkspacePath: string | null
/** The route the same (workspace, agent) pair would take for a fresh chat. Reused rather than
* re-derived: it already encodes the settings flag, host capability, platform refusals and the
* WSL/repair refusal, and a second copy of those conditions would drift from it. */
structuredRouteAvailable: boolean
}): AiVaultResumeInChatEligibility {
const { session } = args
if (!isAgentSessionHandleProvider(session.agent)) {
return { available: false, reason: 'agent' }
}
// An already-adopted row reopens its own chat instead; offering a second resume of it would ask
// for a conflict the host would rightly refuse.
if (session.structuredSession) {
return { available: false, reason: 'already-structured' }
}
if (
session.executionHostId !== LOCAL_EXECUTION_HOST_ID ||
isWslStoredAiVaultSessionFile(session.filePath)
) {
return { available: false, reason: 'remote' }
}
if (!isAiVaultSessionResumableContent(session)) {
return { available: false, reason: 'empty' }
}
if (!args.targetWorkspaceId || !args.structuredRouteAvailable) {
return { available: false, reason: 'workspace' }
}
if (
aiVaultSessionResumeInChatWorkspaceMatters(session.agent) &&
!aiVaultSessionCwdMatchesWorkspace(session.cwd, args.targetWorkspacePath)
) {
return { available: false, reason: 'workspace' }
}
return { available: true, workspaceId: args.targetWorkspaceId }
}
@@ -3,7 +3,7 @@ import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type { AiVaultSessionWorktreeInfo } from './ai-vault-session-worktree'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-actions'
import { resolveAiVaultSessionLaunchTarget } from './ai-vault-session-launch-target'
import {
aiVaultSessionResumeLabel,
aiVaultSessionRowResumeGating,
+6 -2
View File
@@ -13006,7 +13006,10 @@
"localSessionSshWorkspaceUnsupported": "This session's history is stored on this machine, so it can't resume in an SSH workspace. Open a local workspace instead.",
"prepareSessionResumeFailed": "Could not prepare this session for resume.",
"sessionDeleted": "Session deleted",
"sessionDeleteFailed": "Couldn't delete the session"
"sessionDeleteFailed": "Couldn't delete the session",
"resumeInChatConflict": "Another chat is already holding this conversation.",
"resumeInChatTranscriptMissing": "This conversation's history could not be loaded, so it cannot be resumed in chat.",
"resumeInChatFailed": "Could not resume this session in a new chat."
},
"AiVaultPanelControls": {
"scanningSessions": "Scanning sessions",
@@ -13142,7 +13145,8 @@
"delete": "Delete",
"deleteReasonNonLocalHost": "Only sessions on this device can be deleted.",
"deleteReasonSyntheticPath": "This session can't be deleted from Orca.",
"deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca."
"deleteReasonUnsupportedAgent": "{{value0}} sessions can't be deleted from Orca.",
"resumeInNewChat": "Resume in New Chat"
},
"AiVaultSessionDeleteDialog": {
"title": "Delete this session?",
@@ -4,7 +4,8 @@ import {
hasExplicitTuiAgentArgs,
hasExplicitTuiLaunchCustomization,
hasSemanticallyNonEmptyAgentArgs,
resolveAgentLaunchRoute
resolveAgentLaunchRoute,
structuredAgentLaunchSupported
} from './agent-launch-routing'
const settings = {
@@ -190,3 +191,28 @@ describe('resolveAgentLaunchRoute', () => {
expect(hasExplicitTuiAgentArgs('codex', '--model gpt-5.6-sol')).toBe(true)
})
})
describe('explicit structured chat requests', () => {
it.each(['claude', 'codex'] as const)(
'supports %s history resume when new tabs default to terminal',
(agent) => {
const input = {
agent,
settings: { ...settings, openAgentTabsInChatByDefault: false },
executionHostId: 'local',
platform: 'darwin' as const,
hostCapabilities: [STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY],
workspaceKind: 'folder' as const
}
expect(resolveAgentLaunchRoute(input)).toBe('terminal-tui')
expect(structuredAgentLaunchSupported(input)).toBe(true)
expect(structuredAgentLaunchSupported({ ...input, hostCapabilities: [] })).toBe(false)
expect(
structuredAgentLaunchSupported({
...input,
settings: { ...input.settings, experimentalStructuredNativeChat: false }
})
).toBe(false)
}
)
})
+20 -12
View File
@@ -56,16 +56,24 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa
if (!prefersStructuredNativeChatByDefault(input.settings)) {
return 'legacy-native-chat'
}
return resolveStructuredNativeChatSupport({
agent: input.agent,
executionHostId: input.executionHostId,
platform: input.platform,
hostCapabilities: input.hostCapabilities,
workspaceKind: input.workspaceKind,
projectRuntime: input.projectRuntime,
isDraftPrompt: input.promptDelivery === 'draft',
requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization
}).supported
? 'structured-native-chat'
: 'legacy-native-chat'
return structuredAgentLaunchSupported(input) ? 'structured-native-chat' : 'legacy-native-chat'
}
// Explicit chat requests do not depend on the default view mode for new tabs.
export function structuredAgentLaunchSupported(
input: Omit<AgentLaunchRoutingInput, 'launchText'>
): boolean {
return (
input.settings?.experimentalStructuredNativeChat === true &&
resolveStructuredNativeChatSupport({
agent: input.agent,
executionHostId: input.executionHostId,
platform: input.platform,
hostCapabilities: input.hostCapabilities,
workspaceKind: input.workspaceKind,
projectRuntime: input.projectRuntime,
isDraftPrompt: input.promptDelivery === 'draft',
requiresTuiLaunchCustomization: input.requiresTuiLaunchCustomization
}).supported
)
}
@@ -6,7 +6,8 @@ import type {
import {
createStructuredAgentSessionId,
structuredAgentSessionCreateParams,
type StructuredAgentSessionCreateParams
type StructuredAgentSessionCreateParams,
type StructuredAgentSessionResumeSource
} from '../../../shared/structured-agent-session-create'
import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code'
import { isDefinitiveAgentSessionCreateRefusal } from '../../../shared/agent-session-definitive-refusal'
@@ -88,7 +89,8 @@ export function isDefinitiveStructuredAgentSessionCreateError(error: unknown): b
export function createStructuredAgentSessionLaunchIntent(
worktreeId: string,
agent: AgentSessionHandleProvider
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): StructuredAgentSessionLaunchIntent {
const sessionId = createStructuredAgentSessionId(agent, () => crypto.randomUUID())
const state = useAppStore.getState()
@@ -107,6 +109,7 @@ export function createStructuredAgentSessionLaunchIntent(
sessionId,
worktree: toRuntimeWorktreeSelector(worktreeId),
agent,
...(resumeFrom ? { resumeFrom } : {}),
randomUuid: () => crypto.randomUUID()
})
}
@@ -4,6 +4,7 @@ import {
type StructuredPromptDeliveryResult
} from '@/lib/structured-agent-session-launch-prompt'
import type { StructuredAgentSessionOutboxEntry } from '../../../shared/structured-agent-session-outbox'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
export type StructuredRefusalFallback = () =>
| void
@@ -14,6 +15,9 @@ export type StructuredAgentLaunchOptions = {
prompt?: string
promptDelivery?: 'auto-submit' | 'submit-after-ready'
onPromptDelivered?: () => void
/** Adopt an existing provider conversation instead of starting a fresh one. Part of the launch's
* identity, not a preference — see `launchIdentity`. */
resumeFrom?: StructuredAgentSessionResumeSource
}
export type StructuredLaunchCaller = {
@@ -0,0 +1,157 @@
// @vitest-environment happy-dom
// Launch coalescing when a launch adopts a conversation. Drives the real intent builder, because
// the identity under test is derived there — mocking it out would assert only the mock's shape.
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { StructuredAgentSessionCreateParams } from '../../../shared/structured-agent-session-create'
const mocks = vi.hoisted(() => ({
call: vi.fn(),
refresh: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn(), message: vi.fn() }
}))
vi.mock('@/i18n/i18n', () => ({
translate: (_key: string, fallback: string) => fallback
}))
vi.mock('@/lib/agent-catalog', () => ({
getAgentCatalog: () => [{ id: 'codex', label: 'Codex' }]
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: mocks.call
}))
vi.mock('@/runtime/local-structured-session-tabs-sync', () => ({
LOCAL_STRUCTURED_SESSION_OWNER: 'local',
refreshLocalStructuredSessionTabs: mocks.refresh
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({ unifiedTabsByWorktree: {} }),
subscribe: () => () => {}
}
}))
import {
getStructuredAgentLaunchStatus,
startStructuredAgentLaunch
} from './structured-agent-session-launch'
/** The create is dispatched off a microtask, so every assertion on it has to drain them first. */
async function flushLaunchDispatch(): Promise<void> {
for (let i = 0; i < 20; i += 1) {
await Promise.resolve()
}
}
/** Every create is left in flight, so each launch is still pending when the next one arrives. */
function createParams(): StructuredAgentSessionCreateParams[] {
return mocks.call.mock.calls
.filter(([, method]) => method === 'agentSession.create')
.map(([, , params]) => params as StructuredAgentSessionCreateParams)
}
describe('a launch that adopts a conversation is its own identity', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mocks.refresh.mockResolvedValue([])
mocks.call.mockImplementation(async (_target: unknown, method: string) =>
method === 'agentSession.create'
? new Promise(() => {})
: { ok: true, value: { submission: { dispatchState: 'accepted' } } }
)
})
it('does not hand a resume the blank launch already pending for the same worktree', async () => {
// A joining caller is handed the EXISTING intent and contributes only its prompt, so joining
// here would silently drop the adoption and open a blank chat instead.
const worktreeId = 'wt-resume-vs-blank'
const blank = startStructuredAgentLaunch(worktreeId, 'codex')
const resume = startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'thread-1' }
})
await flushLaunchDispatch()
expect(resume.sessionId).not.toBe(blank.sessionId)
expect(createParams()).toEqual([
expect.not.objectContaining({ resumeFrom: expect.anything() }),
expect.objectContaining({ resumeFrom: { providerSessionId: 'thread-1' } })
])
})
it('does not hand a blank launch the resume already pending for the same worktree', async () => {
const worktreeId = 'wt-blank-vs-resume'
const resume = startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'thread-1' }
})
const blank = startStructuredAgentLaunch(worktreeId, 'codex')
await flushLaunchDispatch()
expect(blank.sessionId).not.toBe(resume.sessionId)
expect(createParams()).toHaveLength(2)
})
it('keeps two resumes of different rows apart', async () => {
const worktreeId = 'wt-two-rows'
const first = startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'thread-1' }
})
const second = startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'thread-2' }
})
await flushLaunchDispatch()
expect(second.sessionId).not.toBe(first.sessionId)
expect(createParams().map((params) => params.resumeFrom?.providerSessionId)).toEqual([
'thread-1',
'thread-2'
])
})
it('coalesces a duplicate click on the same row', async () => {
const worktreeId = 'wt-same-row-twice'
const resumeFrom = { providerSessionId: 'thread-1' }
const first = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
const second = startStructuredAgentLaunch(worktreeId, 'codex', { resumeFrom })
await flushLaunchDispatch()
expect(second.sessionId).toBe(first.sessionId)
expect(createParams()).toHaveLength(1)
})
it('keeps the same row apart across worktrees and agents', async () => {
const resumeFrom = { providerSessionId: 'thread-1' }
const here = startStructuredAgentLaunch('wt-here', 'codex', { resumeFrom })
const there = startStructuredAgentLaunch('wt-there', 'codex', { resumeFrom })
await flushLaunchDispatch()
expect(there.sessionId).not.toBe(here.sessionId)
expect(createParams()).toHaveLength(2)
})
it('reports a pending resume as a launch in flight for the worktree', () => {
// "Is a chat starting here" means any launch for the pair, not only the blank one.
const worktreeId = 'wt-resume-status'
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('idle')
startStructuredAgentLaunch(worktreeId, 'codex', {
resumeFrom: { providerSessionId: 'thread-1' }
})
expect(getStructuredAgentLaunchStatus(worktreeId, 'codex')).toBe('pending')
expect(getStructuredAgentLaunchStatus(worktreeId, 'claude')).toBe('idle')
})
})
@@ -33,6 +33,7 @@ import {
type StructuredLaunchCallerGroup,
type StructuredRefusalFallback
} from '@/lib/structured-agent-session-launch-callers'
import type { StructuredAgentSessionResumeSource } from '../../../shared/structured-agent-session-create'
export type { StructuredAgentLaunchOptions, StructuredAgentLaunchReceipt }
@@ -79,11 +80,18 @@ export function getStructuredAgentLaunchStatus(
worktreeId: string,
agent: AgentSessionHandleProvider
): StructuredAgentLaunchStatus {
const state = pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent))
if (!state) {
// Any launch for this pair, not just the blank one: adopting launches carry the conversation in
// their identity, and a caller asking "is a chat starting here" means all of them.
const states = [
pendingStructuredLaunchesByIdentity.get(launchIdentity(worktreeId, agent)),
...[...pendingStructuredLaunchesByIdentity.entries()]
.filter(([identity]) => identity.startsWith(`${agent}:${worktreeId}:resume:`))
.map(([, state]) => state)
].filter((state): state is StructuredLaunchState => Boolean(state))
if (states.length === 0) {
return 'idle'
}
return state.visibilityUnknown ? 'unknown' : 'pending'
return states.some((state) => state.visibilityUnknown) ? 'unknown' : 'pending'
}
export function useStructuredAgentLaunchStatus(
@@ -99,8 +107,19 @@ export function useStructuredAgentLaunchStatus(
// Why keyed by agent too: one worktree can hold a Claude and a Codex launch at once, and a shared
// key would hand the second caller the first agent's intent.
function launchIdentity(worktreeId: string, agent: AgentSessionHandleProvider): string {
return `${agent}:${worktreeId}`
//
// Why keyed by the adopted conversation as well: a joining caller is handed the EXISTING intent and
// contributes only its prompt, so without this a resume that arrives while a blank launch is pending
// would be silently dropped — the user would get a blank chat, or another row's conversation, with
// no error. A launch that adopts a conversation is a different launch.
function launchIdentity(
worktreeId: string,
agent: AgentSessionHandleProvider,
resumeFrom?: StructuredAgentSessionResumeSource
): string {
return resumeFrom
? `${agent}:${worktreeId}:resume:${resumeFrom.providerSessionId}`
: `${agent}:${worktreeId}`
}
function cleanupLaunchState(state: StructuredLaunchState): void {
@@ -207,7 +226,7 @@ function structuredAgentLaunchState(
agent: AgentSessionHandleProvider,
options: StructuredAgentLaunchOptions
): StructuredLaunchStateResult {
const identity = launchIdentity(worktreeId, agent)
const identity = launchIdentity(worktreeId, agent, options.resumeFrom)
const existing = pendingStructuredLaunchesByIdentity.get(identity)
if (existing) {
if (existing.visibilityUnknown) {
@@ -233,7 +252,12 @@ function structuredAgentLaunchState(
}
}
const intent = createStructuredAgentSessionLaunchIntent(worktreeId, agent)
// Only pass the third argument when adopting: every ordinary launch keeps the two-argument call
// it has always made, so this change adds no trailing `undefined` for call-site assertions to
// absorb.
const intent = options.resumeFrom
? createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom)
: createStructuredAgentSessionLaunchIntent(worktreeId, agent)
const text = options.prompt?.trim() ?? ''
const stagedPrompt = text
? enqueueStructuredAgentSessionLaunchPrompt(intent.sessionId, text)
+10 -5
View File
@@ -47,12 +47,21 @@ export type AgentSessionAcquisitionDecision =
export type AgentSessionRestartAdjudication =
| { disposition: 'readopt' }
/** A journal settlement latch survives restart without changing its handoff stage. */
| { disposition: 'settlement-pending' }
/** Nothing is outstanding — no owner, no reservation. Clear any latched stage; the fence stays. */
| { disposition: 'free'; reason: string }
| { disposition: 'evicted'; nextFence: number; evidence: AgentSessionDeathEvidence }
| { disposition: 'recovering'; stage: AgentSessionHandoffStage; reason: string }
| { disposition: 'conflicted'; reason: string }
export function agentSessionRestartEvictionSettlementId(
lease: Pick<AgentSessionLease, 'sessionId'>,
eviction: Extract<AgentSessionRestartAdjudication, { disposition: 'evicted' }>
): string {
return `restart-eviction:${lease.sessionId}:${eviction.nextFence}`
}
/** Stages that can legally admit a new owner at all; the rest have an owner or no evidence. */
const STAGES_ADMITTING_NEW_OWNER: ReadonlySet<AgentSessionHandoffStage> = new Set([
'old-owner-stopped',
@@ -201,11 +210,7 @@ export function adjudicateAgentSessionRestart(args: {
if (lease.settlementRetryRequired) {
// A watched provider death can leave terminal rows unsettled. This latch is not owner
// uncertainty and must survive restart until the journal settlement is durably accepted.
return {
disposition: 'recovering',
stage: 'recovering',
reason: 'provider-exit settlement requires retry'
}
return { disposition: 'settlement-pending' }
}
if (lease.reservedSpawnToken === null && lease.claimStatus !== 'reserved') {
// Why: the spawn token is minted before the child and is the only thing a child could be
@@ -300,3 +300,94 @@ describe('chain lookup and validation', () => {
).toBe(false)
})
})
describe('adopted chain heads', () => {
// What a resume-from-history builds: the create seeds an `adopted` head, then the provider's own
// proof lands on top of it.
const adopted = (overrides: Partial<AgentSessionProviderHandleLink> = {}) =>
link({
linkId: 'claude-1-sess-1-empty',
origin: 'adopted',
handle: { ...CLAUDE, leafUuid: null },
...overrides
})
it('appends a Claude resume that lands on the adopted root with a leaf', () => {
// The adopted head names no leaf; the provider answers with one. Same root, so it is a resume.
const resumed = link({
linkId: 'claude-1-sess-1-leaf-1',
origin: 'resumed',
handle: CLAUDE,
mintedAtFence: 1
})
const chain = appendAgentSessionProviderHandleLink([adopted()], resumed)
expect(chain.map((entry) => entry.origin)).toEqual(['adopted', 'resumed'])
expect(agentSessionProviderHandleChainHead(chain)).toBe(resumed)
})
it('elides a Claude re-proof of the identical adopted handle at the same fence', () => {
const chain = [adopted()]
const elided = appendAgentSessionProviderHandleLink(
chain,
link({
linkId: 'claude-1-sess-1-empty-retry',
origin: 'resumed',
handle: { ...CLAUDE, leafUuid: null },
mintedAtFence: 1
})
)
expect(elided).toEqual(chain)
})
it('appends a Codex resume only once the fence has moved', () => {
const codexAdopted = link({
linkId: 'codex-1-thread-1',
origin: 'adopted',
handle: { provider: 'codex', threadId: 'thread-1' }
})
const reproved = link({
linkId: 'codex-1-thread-1-retry',
origin: 'resumed',
handle: { provider: 'codex', threadId: 'thread-1' },
mintedAtFence: 1
})
// Codex's thread id is the whole key, so a same-fence re-proof can only ever be a retry.
expect(appendAgentSessionProviderHandleLink([codexAdopted], reproved)).toEqual([codexAdopted])
expect(
appendAgentSessionProviderHandleLink([codexAdopted], { ...reproved, mintedAtFence: 2 })
).toHaveLength(2)
})
it('refuses a second origin link on top of an adopted head', () => {
// Nothing re-origins a chain: a create landing here would erase where the conversation came from.
for (const origin of ['created', 'adopted'] as const) {
expect(() =>
appendAgentSessionProviderHandleLink(
[adopted()],
link({ linkId: 'claude-2-sess-1-leaf-1', origin, handle: CLAUDE, mintedAtFence: 2 })
)
).toThrow('agent_session_provider_handle_invalid')
}
})
it('refuses a resume that landed on another conversation entirely', () => {
expect(() =>
appendAgentSessionProviderHandleLink(
[adopted()],
link({
linkId: 'claude-1-sess-9-leaf-9',
origin: 'resumed',
handle: { provider: 'claude', sessionId: 'sess-9', leafUuid: 'leaf-9' },
mintedAtFence: 1
})
)
).toThrow('agent_session_provider_handle_forked')
})
it('accepts an adopted head as a persisted chain', () => {
expect(isAgentSessionProviderHandleChain([adopted()])).toBe(true)
})
})
+1
View File
@@ -48,6 +48,7 @@ export type GitHistoryItem = {
displayId?: string
author?: string
authorEmail?: string
/** Epoch milliseconds (git %at seconds × 1000). */
timestamp?: number
statistics?: GitHistoryItemStatistics
references?: GitHistoryItemRef[]
+3 -1
View File
@@ -115,7 +115,9 @@ describe('git history parsing', () => {
message: 'feat: add graph\n\nbody line',
author: 'Ada Lovelace',
authorEmail: 'ada@example.com',
displayId: HEAD_OID.slice(0, 7)
displayId: HEAD_OID.slice(0, 7),
// The format feeds %at seconds; consumers get epoch milliseconds.
timestamp: 1_700_000_000_000
})
expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([
['refs/heads/feature', 'feature', 'branches'],
+7
View File
@@ -146,6 +146,12 @@ export const STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY =
// negotiation rather than by calling and reading a refusal it cannot distinguish from a real one.
export const STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY =
'agent-session.structured.reveal.v1' as const
// Why: `agentSession.create` gains an optional `resumeFrom`, and its params are a STRICT union — an
// older host rejects the unknown key as a schema error, which a client cannot tell from a real
// refusal. Worse, without probing, a client cannot know whether a host that accepted the call
// adopted the conversation or quietly started a blank one. Negotiate before offering the action.
export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY =
'agent-session.structured.resume-history.v1' as const
// Why: agentSession.subscribeStatus is additive to a surface that already shipped, so a host
// 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.
@@ -251,6 +257,7 @@ export const RUNTIME_CAPABILITIES = [
STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_HOLD_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_REVEAL_RUNTIME_CAPABILITY,
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY,
AGENT_SESSION_STATUS_FEED_RUNTIME_CAPABILITY,
AGENT_SESSION_KIMI_RESUME_RUNTIME_CAPABILITY,
FILE_MUTATION_OWNERSHIP_RUNTIME_CAPABILITY,
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'
import { structuredAgentSessionCreateParams } from './structured-agent-session-create'
import {
structuredAgentSessionCreateFingerprint,
structuredAgentSessionPayloadFingerprint
} from './structured-agent-session-mutation'
const SESSION_ID = 'codex_11111111_2222_3333_4444_555555555555'
const RESUME = { providerSessionId: 'thread-abc' }
/** Distinct per call so two envelopes never share an operation id by accident. */
let uuidCounter = 0
function nextUuid(): string {
uuidCounter += 1
return `00000000-0000-4000-8000-${String(uuidCounter).padStart(12, '0')}`
}
function createParams(overrides: { resumeFrom?: { providerSessionId: string } } = {}) {
return structuredAgentSessionCreateParams({
sessionId: SESSION_ID,
worktree: 'id:repo-1::/repo/orca',
agent: 'codex',
...overrides,
randomUuid: nextUuid,
now: 1_800_000_000_000
})
}
describe('structured agent session create params', () => {
it('carries resumeFrom only when the create adopts a conversation', () => {
expect(createParams()).not.toHaveProperty('resumeFrom')
expect(createParams({ resumeFrom: RESUME })).toMatchObject({ resumeFrom: RESUME })
})
it('declares a fingerprint the host can recompute from the same fields', () => {
const params = createParams({ resumeFrom: RESUME })
expect(params.envelope.payloadFingerprint).toBe(
structuredAgentSessionCreateFingerprint({
sessionId: SESSION_ID,
worktree: 'id:repo-1::/repo/orca',
agent: 'codex',
resumeFrom: RESUME
})
)
})
it('separates an adopting create from a blank one and from another row', () => {
const blank = createParams().envelope.payloadFingerprint
const adopted = createParams({ resumeFrom: RESUME }).envelope.payloadFingerprint
const otherRow = createParams({
resumeFrom: { providerSessionId: 'thread-other' }
}).envelope.payloadFingerprint
expect(adopted).not.toBe(blank)
expect(otherRow).not.toBe(adopted)
})
it('gives a replay of the same adoption the same digest under a new operation id', () => {
const first = createParams({ resumeFrom: RESUME })
const second = createParams({ resumeFrom: RESUME })
expect(second.envelope.clientOperationId).not.toBe(first.envelope.clientOperationId)
expect(second.envelope.payloadFingerprint).toBe(first.envelope.payloadFingerprint)
})
it('leaves a blank create byte-identical to the pre-resume digest', () => {
// Pinned literal: a create with no `resumeFrom` must keep the digest older clients and hosts
// already compute, so adding a field to the create fingerprint fails here rather than in the
// field on a mixed-version pair.
expect(createParams().envelope.payloadFingerprint).toBe(
structuredAgentSessionPayloadFingerprint({
method: 'agentSession.create',
sessionId: SESSION_ID,
fields: { worktree: 'id:repo-1::/repo/orca', agent: 'codex' }
})
)
expect(createParams().envelope.payloadFingerprint).toBe(
'56cb15e22414c0f62fd89d77d00d2d6a0a422f16e95edee154fb8b5bf53fbbc3'
)
})
})

Some files were not shown because too many files have changed in this diff Show More