mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/nc-turn-rollup
This commit is contained in:
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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 } = {}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
+250
@@ -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,
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ async function failingFlowRunner(
|
||||
throw new Error('unused')
|
||||
},
|
||||
importTuiHistory: async () => {},
|
||||
retryPendingSettlement: async () => true,
|
||||
publish: () => {},
|
||||
schedule: async () => {
|
||||
throw new Error('scheduling failed')
|
||||
|
||||
+52
@@ -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()
|
||||
})
|
||||
})
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+1
@@ -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),
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+24
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
+1
@@ -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>
|
||||
}
|
||||
) {}
|
||||
|
||||
+98
-1
@@ -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()
|
||||
|
||||
+25
-10
@@ -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[] = []
|
||||
|
||||
+148
-1
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+79
@@ -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 {
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
}
|
||||
+64
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user