mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
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.
This commit is contained in:
+185
@@ -0,0 +1,185 @@
|
||||
// Adopting a history row imports its transcript into the new session's journal.
|
||||
//
|
||||
// The import runs AFTER the provider is acquired, because for a create there is no earlier moment
|
||||
// — the journal does not exist until the child does. That ordering is what makes the failure cases
|
||||
// here load-bearing: by then the provider has already resumed and holds the conversation in
|
||||
// context, so an attach that succeeds with an empty journal would show the user a blank chat beside
|
||||
// an agent that can already answer from history.
|
||||
|
||||
import { mkdtemp, rm, writeFile } 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 } from './structured-agent-session-attach-flow'
|
||||
|
||||
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
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
root = null
|
||||
})
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
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, sessionAdapter: StructuredAgentSessionAdapter) {
|
||||
return performAttach({
|
||||
store: await AgentSessionRecordStore.open({
|
||||
directory: join(root!, 'store'),
|
||||
hostId: 'local'
|
||||
}),
|
||||
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('fails the attach when the adopted transcript cannot be read', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-adopt-missing-'))
|
||||
const sessionAdapter = adapter()
|
||||
|
||||
// A post-acquisition failure throws rather than answering a refusal — the same path a journal
|
||||
// failure already takes — so the caller learns the outcome is unknown, not that nothing ran.
|
||||
await expect(attach(join(root, 'does-not-exist.jsonl'), sessionAdapter)).rejects.toThrow(
|
||||
/ENOENT|no such file/
|
||||
)
|
||||
// The provider had already resumed, so its acquisition is released rather than left holding a
|
||||
// conversation no surface will ever show.
|
||||
expect(sessionAdapter.releaseAcquisition).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails the attach when the adopted transcript decodes to no messages', async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-adopt-empty-'))
|
||||
const transcriptPath = join(root, 'empty.jsonl')
|
||||
// Well-formed but conversation-free: the row promised turns and the provider resumed them, so
|
||||
// an empty journal here is a disagreement, not an empty chat.
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: 'session_meta',
|
||||
payload: { id: THREAD, timestamp: '2026-09-06T18:00:00.000Z', cwd: '/workspace' }
|
||||
})}\n`,
|
||||
'utf8'
|
||||
)
|
||||
const sessionAdapter = adapter()
|
||||
|
||||
await expect(attach(transcriptPath, sessionAdapter)).rejects.toThrow(
|
||||
'agent_session_identity_required'
|
||||
)
|
||||
expect(sessionAdapter.releaseAcquisition).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -36,6 +36,7 @@ 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 { importLegacyTranscriptIntoJournal } from '../agent-session-journal/journal-legacy-import'
|
||||
|
||||
export type AttachFlowInput = {
|
||||
store: AgentSessionRecordStore
|
||||
@@ -181,6 +182,7 @@ export async function performAttach(
|
||||
journalRoot: input.journalRoot,
|
||||
adapter: input.adapter
|
||||
})
|
||||
await importAdoptedTranscript(params, attached, record)
|
||||
await input.onAttached(attached, acquisitionGeneration)
|
||||
await store.recordOperationOutcome({
|
||||
callerKey: input.callerKey,
|
||||
@@ -206,6 +208,48 @@ export async function performAttach(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill an adopting session's journal with the conversation so far.
|
||||
*
|
||||
* Runs here, and only here, because for a create there is no earlier moment: the provider is
|
||||
* acquired before the journal exists. It must still land before `onAttached`, which binds the event
|
||||
* sink and publishes the session — after that, streamed rows and client sends would race the import.
|
||||
*
|
||||
* A failure throws, and that is deliberate. By this point the provider has already resumed and holds
|
||||
* the conversation in context; leaving the user an empty journal beside a context-carrying agent is
|
||||
* the exact "claims continuity the provider never gave" inversion this feature must not produce.
|
||||
* The throw reaches `settlePostAcquisitionAttachFailure`, which tears the child down, settles the
|
||||
* operation failed, and publishes no tab.
|
||||
*/
|
||||
async function importAdoptedTranscript(
|
||||
params: AgentSessionAttachParams,
|
||||
attached: AttachedJournal,
|
||||
record: AgentSessionRecord
|
||||
): Promise<void> {
|
||||
const adopt = params.adopt
|
||||
if (!adopt) {
|
||||
return
|
||||
}
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
async function settlePostAcquisitionAttachFailure(
|
||||
input: AttachFlowInput,
|
||||
record: AgentSessionRecord,
|
||||
|
||||
@@ -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,
|
||||
@@ -58,6 +63,18 @@ export type AgentSessionAttachParams = {
|
||||
options?: Readonly<Record<string, 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' }>
|
||||
transcriptPath: string
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-supplied half of the reservation. */
|
||||
@@ -84,6 +101,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
|
||||
}
|
||||
}
|
||||
@@ -182,6 +203,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
|
||||
@@ -200,6 +253,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,137 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { agentSessionLeaseFixture } from '../../shared/agent-session-record.test-fixture'
|
||||
import {
|
||||
findConflictingStructuredAdoption,
|
||||
resolveStructuredAgentSessionAdoption,
|
||||
structuredAdoptionConflictError,
|
||||
type StructuredAgentSessionAdoptionOwnership
|
||||
} from './structured-agent-session-history-adoption'
|
||||
|
||||
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('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,102 @@
|
||||
// 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 { AgentSessionLease } 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,35 @@ export function applyAgentSessionReservation(
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an adoption whose conversation another record already holds.
|
||||
*
|
||||
* Exempts the request's own session id: a retry of a create that already committed re-runs every
|
||||
* pre-commit check, and by then the record it created holds the conversation itself. Without the
|
||||
* exemption the replay would refuse as a conflict instead of replaying.
|
||||
*/
|
||||
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 +231,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] } : {}),
|
||||
|
||||
@@ -6,12 +6,19 @@ import { resolvePinnedCodexRolloutProof } from '../codex/codex-tui-rollout-proof
|
||||
import { supportsCodexStructuredLocation } from '../codex/codex-structured-location-support'
|
||||
import { supportsClaudeStructuredLocation } from '../claude/claude-structured-location-support'
|
||||
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
|
||||
import { listStructuredProviderSessionOwnership } from '../native-chat/agent-session-wire/structured-provider-session-ownership'
|
||||
import { resolveStructuredAgentSessionCreateSupport } from '../native-chat/structured-agent-session-create-support'
|
||||
import {
|
||||
findConflictingStructuredAdoption,
|
||||
resolveStructuredAgentSessionAdoption,
|
||||
structuredAdoptionConflictError
|
||||
} from '../native-chat/structured-agent-session-history-adoption'
|
||||
import { resolveSessionFilePath } from '../native-chat/session-file-resolver'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
|
||||
import { getLocalProjectWorktreeGitOptions } from '../project-runtime-git-options'
|
||||
import type { AgentSessionAttachParams } from '../native-chat/agent-session-wire/structured-agent-session-attach'
|
||||
import { getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { getOrcaManagedCodexHomePath, getSystemCodexHomePath } from '../codex/codex-home-paths'
|
||||
import { resolveTuiAgentLaunchEnv } from '../../shared/tui-agent-launch-defaults'
|
||||
import { resolveStructuredLaunchSeedOptions } from '../../shared/native-chat-session-option-defaults'
|
||||
import { hasPersistedStructuredAgentSessionStore as hasPersistedStructuredAgentSessionStoreOnDisk } from './structured-agent-session-runtime'
|
||||
@@ -111,6 +118,7 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
envelope: { sessionId: string; clientOperationId: string }
|
||||
worktree: string
|
||||
agent: 'claude' | 'codex'
|
||||
resumeFrom?: { providerSessionId: string }
|
||||
}): Promise<AgentSessionAttachParams> {
|
||||
if (input.agent === 'claude') {
|
||||
return this.resolveStructuredAgentSessionIntent(input, async ({ launchEnv, location }) => {
|
||||
@@ -139,11 +147,72 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
})
|
||||
}
|
||||
|
||||
/** Where an adopted conversation may live, most-preferred first. The selected account is tried
|
||||
* before the system default so a row present in both resolves the way a fresh create would. */
|
||||
protected structuredAdoptionAccountHomeCandidates(
|
||||
agent: 'claude' | 'codex',
|
||||
selectedAccountHomePath: string
|
||||
): string[] {
|
||||
if (agent === 'claude') {
|
||||
return [selectedAccountHomePath, join(homedir(), '.claude')]
|
||||
}
|
||||
// Managed per-account homes are enumerated from the store rather than taken from the row: a
|
||||
// Codex conversation recorded under one account must still be findable when another is
|
||||
// selected, and reading the row's own path back would reintroduce the client-supplied path
|
||||
// this whole derivation exists to avoid.
|
||||
const managedHomes = (this.requireStore().getSettings().codexManagedAccounts ?? []).map(
|
||||
(account) => account.managedHomePath
|
||||
)
|
||||
return [
|
||||
selectedAccountHomePath,
|
||||
...managedHomes,
|
||||
getOrcaManagedCodexHomePath(),
|
||||
getSystemCodexHomePath()
|
||||
]
|
||||
}
|
||||
|
||||
protected async resolveStructuredAgentSessionAdoptionForCreate(input: {
|
||||
agent: 'claude' | 'codex'
|
||||
providerSessionId: string
|
||||
selfSessionId: string
|
||||
selectedAccountHomePath: string
|
||||
}) {
|
||||
const host = getStructuredAgentSessionHost()
|
||||
const conflict = host
|
||||
? findConflictingStructuredAdoption({
|
||||
agent: input.agent,
|
||||
providerSessionId: input.providerSessionId,
|
||||
selfSessionId: input.selfSessionId,
|
||||
ownership: listStructuredProviderSessionOwnership(host.deps.store.listRecords())
|
||||
})
|
||||
: null
|
||||
if (conflict) {
|
||||
throw structuredAdoptionConflictError(conflict)
|
||||
}
|
||||
return resolveStructuredAgentSessionAdoption({
|
||||
agent: input.agent,
|
||||
providerSessionId: input.providerSessionId,
|
||||
candidateAccountHomes: this.structuredAdoptionAccountHomeCandidates(
|
||||
input.agent,
|
||||
input.selectedAccountHomePath
|
||||
),
|
||||
resolveTranscript: async ({ agent, providerSessionId, accountHomePath }) =>
|
||||
resolveSessionFilePath(
|
||||
agent,
|
||||
providerSessionId,
|
||||
agent === 'claude'
|
||||
? { claudeProjectsDir: join(accountHomePath, 'projects') }
|
||||
: { codexSessionsDirs: [join(accountHomePath, 'sessions')] }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
protected async resolveStructuredAgentSessionIntent(
|
||||
input: {
|
||||
envelope: { sessionId: string; clientOperationId: string }
|
||||
worktree: string
|
||||
agent: 'claude' | 'codex'
|
||||
resumeFrom?: { providerSessionId: string }
|
||||
},
|
||||
resolveAccountHomePath: (context: {
|
||||
workspacePath: string
|
||||
@@ -168,6 +237,23 @@ export class OrcaRuntimeWithResolveRecoveredStructuredTuiTranscript extends Orca
|
||||
)
|
||||
const location = await this.resolveStructuredAgentSessionLocation(input.worktree)
|
||||
const workspacePath = (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path
|
||||
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 this.resolveStructuredAgentSessionAdoptionForCreate({
|
||||
agent: input.agent,
|
||||
providerSessionId: input.resumeFrom.providerSessionId,
|
||||
selfSessionId: input.envelope.sessionId,
|
||||
selectedAccountHomePath
|
||||
})
|
||||
: null
|
||||
return {
|
||||
envelope: {
|
||||
sessionId: input.envelope.sessionId,
|
||||
@@ -180,9 +266,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'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -104,7 +104,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) {
|
||||
|
||||
@@ -29,7 +29,21 @@ import {
|
||||
resolveAiVaultSessionResumeActions,
|
||||
resolveAiVaultSessionResumeState
|
||||
} from './ai-vault-session-resume'
|
||||
import { useAiVaultSessionLaunchActions } from './ai-vault-session-launch-actions'
|
||||
import {
|
||||
resolveAiVaultTargetWorkspacePath,
|
||||
useAiVaultSessionLaunchActions
|
||||
} from './ai-vault-session-launch-actions'
|
||||
import {
|
||||
resolveAiVaultSessionResumeInChatEligibility,
|
||||
type AiVaultResumeInChatEligibility
|
||||
} from './ai-vault-session-resume-in-chat'
|
||||
import { structuredAgentLaunchSupported } from '@/lib/agent-launch-routing'
|
||||
import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle'
|
||||
import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version'
|
||||
import { readLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities'
|
||||
import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import {
|
||||
useAiVaultSessionWorktreeMap,
|
||||
withAiVaultCurrentWorktreeStatus
|
||||
@@ -287,6 +301,51 @@ 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 => {
|
||||
const resumeState = getSessionResumeState(session)
|
||||
const targetWorkspaceId = resumeState.usesSessionWorktree
|
||||
? resumeState.worktreeId
|
||||
: (resumeState.worktreeId ?? effectiveActiveWorktreeId)
|
||||
const targetWorkspacePath = targetWorkspaceId
|
||||
? resolveAiVaultTargetWorkspacePath(resumeTargetState, targetWorkspaceId)
|
||||
: null
|
||||
return resolveAiVaultSessionResumeInChatEligibility({
|
||||
session,
|
||||
targetWorkspaceId,
|
||||
targetWorkspacePath,
|
||||
structuredRouteAvailable:
|
||||
isAgentSessionHandleProvider(session.agent) &&
|
||||
Boolean(targetWorkspaceId) &&
|
||||
structuredAgentLaunchSupported({
|
||||
agent: session.agent,
|
||||
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
|
||||
)
|
||||
})
|
||||
},
|
||||
[effectiveActiveWorktreeId, getSessionResumeState, resumeTargetState, settings]
|
||||
)
|
||||
|
||||
const handleScopeChange = useCallback((nextScope: AiVaultScope) => {
|
||||
preferredScopeRef.current = nextScope
|
||||
userChangedScopeRef.current = nextScope !== DEFAULT_AI_VAULT_SCOPE
|
||||
@@ -366,7 +425,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}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
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'
|
||||
|
||||
const VAULT_ROW_OVERSCAN = 8
|
||||
const VAULT_EXPANDED_SESSION_ROW_ESTIMATED_HEIGHT = 420
|
||||
@@ -56,11 +57,13 @@ export function AiVaultSessionVirtualList({
|
||||
getWorktreeInfo,
|
||||
getSessionResumeState,
|
||||
getSessionResumeActions,
|
||||
getSessionResumeInChat,
|
||||
onToggleGroup,
|
||||
onJumpToOriginalPane,
|
||||
onJumpToWorktree,
|
||||
onResume,
|
||||
onContinueInNewSession,
|
||||
onResumeInNewChat,
|
||||
onCopyResume,
|
||||
onCopyId,
|
||||
onCopyPath,
|
||||
@@ -83,11 +86,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 +224,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}
|
||||
@@ -255,12 +262,14 @@ function AiVaultVirtualRow({
|
||||
getWorktreeInfo,
|
||||
getSessionResumeState,
|
||||
getSessionResumeActions,
|
||||
getSessionResumeInChat,
|
||||
onToggleGroup,
|
||||
onToggleSessionDetails,
|
||||
onJumpToOriginalPane,
|
||||
onJumpToWorktree,
|
||||
onResume,
|
||||
onContinueInNewSession,
|
||||
onResumeInNewChat,
|
||||
onCopyResume,
|
||||
onCopyId,
|
||||
onCopyPath,
|
||||
@@ -283,12 +292,14 @@ function AiVaultVirtualRow({
|
||||
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
|
||||
@@ -313,6 +324,7 @@ function AiVaultVirtualRow({
|
||||
: 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)
|
||||
@@ -384,6 +396,11 @@ function AiVaultVirtualRow({
|
||||
? () => 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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -29,6 +29,8 @@ import { prepareAiVaultSessionContinuation } from './ai-vault-session-continuati
|
||||
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'
|
||||
|
||||
export function useAiVaultSessionLaunchActions({
|
||||
activeWorktree,
|
||||
@@ -147,6 +149,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 +234,44 @@ 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 {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
if (message.includes('agent_session_conflict')) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.resumeInChatConflict',
|
||||
'Another chat is already holding this conversation.'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
if (message.includes('agent_session_identity_required')) {
|
||||
toast.error(
|
||||
translate(
|
||||
'auto.components.right.sidebar.AiVaultPanel.resumeInChatTranscriptMissing',
|
||||
"This conversation's transcript could not be found, so it cannot be resumed."
|
||||
)
|
||||
)
|
||||
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,7 +283,7 @@ function notifyAiVaultSessionPreparationFailure(error: unknown): void {
|
||||
)
|
||||
}
|
||||
|
||||
function resolveAiVaultTargetWorkspacePath(
|
||||
export function resolveAiVaultTargetWorkspacePath(
|
||||
state: AiVaultSessionResumeTargetState,
|
||||
workspaceId: string
|
||||
): string | null {
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -12981,7 +12981,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 transcript could not be found, so it cannot be resumed.",
|
||||
"resumeInChatFailed": "Could not resume this session in a new chat."
|
||||
},
|
||||
"AiVaultPanelControls": {
|
||||
"scanningSessions": "Scanning sessions",
|
||||
@@ -13117,7 +13120,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?",
|
||||
|
||||
@@ -82,15 +82,25 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa
|
||||
if (initialViewMode !== 'chat') {
|
||||
return 'terminal-tui'
|
||||
}
|
||||
if (input.settings?.experimentalStructuredNativeChat !== true) {
|
||||
return 'legacy-native-chat'
|
||||
}
|
||||
return structuredAgentLaunchSupported(input) ? 'structured-native-chat' : 'legacy-native-chat'
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this workspace/agent pair can run a structured session at all.
|
||||
*
|
||||
* Split out of `resolveAgentLaunchRoute` so callers that already know the user asked for a chat —
|
||||
* resuming a history row into one, say — can ask this without also passing the initial-view-mode
|
||||
* preference, which decides what a *new tab* defaults to and has no bearing on an explicit request.
|
||||
*/
|
||||
export function structuredAgentLaunchSupported(
|
||||
input: Omit<AgentLaunchRoutingInput, 'launchText'>
|
||||
): boolean {
|
||||
const projectRuntime = input.projectRuntime
|
||||
const runtimeRefused =
|
||||
projectRuntime?.status === 'repair-required' || projectRuntime?.runtime.kind === 'wsl'
|
||||
const structuredSupported =
|
||||
return (
|
||||
isAgentSessionHandleProvider(input.agent) &&
|
||||
input.settings?.experimentalStructuredNativeChat === true &&
|
||||
input.promptDelivery !== 'draft' &&
|
||||
input.workspaceKind !== 'floating' &&
|
||||
input.requiresTuiLaunchCustomization !== true &&
|
||||
@@ -101,6 +111,5 @@ export function resolveAgentLaunchRoute(input: AgentLaunchRoutingInput): AgentLa
|
||||
(input.agent !== 'codex' || input.platform !== 'win32') &&
|
||||
!runtimeRefused &&
|
||||
input.hostCapabilities.includes(STRUCTURED_AGENT_SESSION_RUNTIME_CAPABILITY)
|
||||
|
||||
return structuredSupported ? 'structured-native-chat' : 'legacy-native-chat'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -174,7 +174,11 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
focusAfterMenuClose: 'structured-session'
|
||||
})
|
||||
expect(shouldQueueTerminalFocusAfterMenuClose(result!)).toBe(false)
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex')
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'codex',
|
||||
undefined
|
||||
)
|
||||
expect(mockLaunchStructuredCodexSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ worktreeId: 'wt-1' })
|
||||
)
|
||||
@@ -195,7 +199,11 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
const result = launchAgentInNewTab({ agent: 'codex', worktreeId: 'wt-1' })
|
||||
|
||||
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'codex')
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'codex',
|
||||
undefined
|
||||
)
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -205,7 +213,11 @@ describe('structured chat adoption guard on the launch path', () => {
|
||||
const result = launchAgentInNewTab({ agent: 'claude', worktreeId: 'wt-1' })
|
||||
|
||||
expect(result).toMatchObject({ tabId: null, focusAfterMenuClose: 'structured-session' })
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith('wt-1', 'claude')
|
||||
expect(mockCreateStructuredCodexSessionLaunchIntent).toHaveBeenCalledWith(
|
||||
'wt-1',
|
||||
'claude',
|
||||
undefined
|
||||
)
|
||||
expect(mockCreateTab).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -173,8 +173,9 @@ describe('startStructuredAgentLaunch', () => {
|
||||
const codex = startStructuredAgentLaunch(worktreeId, 'codex')
|
||||
await flushLaunchSettlement()
|
||||
|
||||
expect(mocks.createIntent).toHaveBeenNthCalledWith(1, worktreeId, 'claude')
|
||||
expect(mocks.createIntent).toHaveBeenNthCalledWith(2, worktreeId, 'codex')
|
||||
// Third argument is the adopted conversation; a blank launch passes none.
|
||||
expect(mocks.createIntent).toHaveBeenNthCalledWith(1, worktreeId, 'claude', undefined)
|
||||
expect(mocks.createIntent).toHaveBeenNthCalledWith(2, worktreeId, 'codex', undefined)
|
||||
expect(mocks.launch).toHaveBeenCalledTimes(2)
|
||||
expect(vi.mocked(mocks.launch).mock.calls.map(([intent]) => intent.params.agent)).toEqual([
|
||||
'claude',
|
||||
|
||||
@@ -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,7 @@ function structuredAgentLaunchState(
|
||||
}
|
||||
}
|
||||
|
||||
const intent = createStructuredAgentSessionLaunchIntent(worktreeId, agent)
|
||||
const intent = createStructuredAgentSessionLaunchIntent(worktreeId, agent, options.resumeFrom)
|
||||
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