mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
feat(native-chat): resume an Agent Session History row into a new structured chat (#19176)
* feat(native-chat): resume an Agent Session History row into a new structured chat A Claude or Codex row in Agent Session History gains "Resume in New Chat": it opens a new structured native-chat tab that continues that provider conversation, with the prior turns already in the journal. Until now those rows could only be resumed into a PTY terminal; the structured branch could reveal a chat Orca already owned but could not adopt one it had never held. Almost all of the machinery existed. Both lanes already resume from the record's provider handle chain, the journal already has a transcript importer, and the handle chain already models `adopted` as an origin. The gap was that a create always minted an empty chain, so the adapters started a fresh conversation. This seeds that chain. The client names only the conversation. `agentSession.create` is reachable by paired mobile clients, so the transcript path and the account home are derived by the executing host and validated against the account homes it recognises — a client-supplied path would choose which file the host imports and which credential directory the provider child launches against. Failure refuses rather than degrades. A transcript that cannot be found refuses before anything is created; one that fails or decodes empty *after* the provider has resumed fails the attach, tearing the child down and publishing no tab, because an empty journal beside a context-carrying agent claims a continuity the provider never gave. Codex can resume into any workspace since it is handed the rollout path; Claude resolves transcripts under a project key derived from the launch cwd, so it is offered only for the workspace the conversation was recorded in. * fix(native-chat): widen adopted-home discovery and keep ordinary launches untouched Three corrections from review of the first commit. The adoption's account-home candidates now include the extra Codex homes session discovery already scans. A row this host listed could otherwise refuse to resume, which reads as the feature being broken rather than as a scope. Ordinary launches call `createStructuredAgentSessionLaunchIntent` with two arguments again. Passing the resume source unconditionally appended a trailing `undefined` that four existing call-site assertions had to absorb; the churn was the caller's fault, not the tests'. The transactional adoption guard's comment claimed the self-exemption is what lets a committed create replay. It is not: 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 either way. The exemption is part of what "another record" means, and the comment now says that instead. * fix: preserve history adoption through create and retries * fix: replay committed history adoption from durable identity * fix: validate history before claiming adopted sessions * fix: extract AI vault resume domains * fix: recognize typed history resume refusals --------- Co-authored-by: Merge Sim <sim@local>
This commit is contained in:
co-authored by
Merge Sim
parent
bd242a0158
commit
1ae7aa8bb4
@@ -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 } = {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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] } : {}),
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -483,7 +483,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 +500,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 +557,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({
|
||||
|
||||
@@ -127,7 +127,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 +148,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)
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -5,10 +5,24 @@ import {
|
||||
structuredAgentSessionCreateFingerprint
|
||||
} from './structured-agent-session-mutation'
|
||||
|
||||
/**
|
||||
* The conversation a create adopts instead of starting a fresh one.
|
||||
*
|
||||
* Deliberately carries an identity and nothing else. The transcript file and the account home it
|
||||
* lives under are derived by the executing host, never sent: `agentSession.create` is reachable by
|
||||
* paired mobile clients, and a client-supplied path would let one choose which file the host reads
|
||||
* into a journal and which credential directory the provider child launches against.
|
||||
*/
|
||||
export type StructuredAgentSessionResumeSource = {
|
||||
/** claude: the session id. codex: the thread id. */
|
||||
providerSessionId: string
|
||||
}
|
||||
|
||||
export type StructuredAgentSessionCreateParams = {
|
||||
envelope: AgentSessionMutationEnvelope
|
||||
worktree: string
|
||||
agent: AgentSessionHandleProvider
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
}
|
||||
|
||||
/** Provider-prefixed so a session id names its lane on sight, and underscore-only
|
||||
@@ -29,10 +43,15 @@ export function structuredAgentSessionCreateParams(args: {
|
||||
sessionId: string
|
||||
worktree: string
|
||||
agent: AgentSessionHandleProvider
|
||||
resumeFrom?: StructuredAgentSessionResumeSource
|
||||
randomUuid: () => string
|
||||
now?: number
|
||||
}): StructuredAgentSessionCreateParams {
|
||||
const fields = { worktree: args.worktree, agent: args.agent }
|
||||
const fields = {
|
||||
worktree: args.worktree,
|
||||
agent: args.agent,
|
||||
...(args.resumeFrom ? { resumeFrom: args.resumeFrom } : {})
|
||||
}
|
||||
return {
|
||||
envelope: {
|
||||
sessionId: args.sessionId,
|
||||
|
||||
@@ -30,13 +30,17 @@ export function structuredAgentSessionCreateFingerprint(input: {
|
||||
sessionId: string
|
||||
worktree: string
|
||||
agent: 'claude' | 'codex'
|
||||
resumeFrom?: { providerSessionId: string }
|
||||
}): string {
|
||||
return structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.create',
|
||||
sessionId: input.sessionId,
|
||||
fields: {
|
||||
worktree: input.worktree,
|
||||
agent: input.agent
|
||||
agent: input.agent,
|
||||
// `canonicalize` drops undefined, so a plain create keeps the digest it has always had.
|
||||
// Adopting a conversation is a different intent and must not replay as a blank create.
|
||||
resumeFrom: input.resumeFrom
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user