Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ export type AgentSessionAttachParams = {
providerHandle: AgentSessionProviderHandle
/** Omitted only when the exact committed operation replays an already-imported journal. */
transcriptPath?: string
/** The source conversation's title, seeded as the new record's name so the chat does not read
* as the agent's default label. Not on the attach fingerprint: it is derived, not requested. */
conversationName?: string
}
}

Expand Down Expand Up @@ -314,6 +317,7 @@ export function reserveRequestFor(input: {
...(authority.launchArgs ? { launchArgs: authority.launchArgs } : {}),
...(authority.launchEnv ? { launchEnv: authority.launchEnv } : {}),
...(authority.launchDirectory ? { launchDirectory: authority.launchDirectory } : {}),
...(params.adopt?.conversationName ? { conversationName: params.adopt.conversationName } : {}),
...(params.adopt
? {
// Fence 1 is a new record's first, and the owner probe requires the head link to carry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,17 @@ it('imports adoption at rest and resumes its original provider identity after it
expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome)
})

it('names an adopting chat after its source conversation', async () => {
const params = await adoptedParams()
expect(
await rig.host.create(CALLER, {
...params,
adopt: params.adopt && { ...params.adopt, conversationName: ' Fix the flaky\nlogin test ' }
})
).toMatchObject({ ok: true })
expect(rig.store.getRecord(SESSION)?.conversationName).toBe('Fix the flaky login test')
})

it('refuses concurrent adoption of a provider conversation by another session', async () => {
const params = await adoptedParams()
const results = await Promise.all([
Expand Down
4 changes: 4 additions & 0 deletions src/main/runtime/agent-session-record-founding.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { AGENT_CHAT_PERMISSION_MODE_OPTION_ID } from '../../shared/agent-chat-permission-mode'
import { normalizeAgentSessionConversationName } from '../../shared/agent-session-conversation-name'
import {
AGENT_SESSION_RECORD_SCHEMA_VERSION,
type AgentSessionRecord
Expand All @@ -14,6 +15,7 @@ export type AgentSessionRecordIdentity = Pick<
| 'options'
| 'launchArgs'
| 'launchDirectory'
| 'conversationName'
>

export function agentSessionRecordIdentityFields(
Expand All @@ -23,6 +25,7 @@ export function agentSessionRecordIdentityFields(
// Callers name the mode among the options, as the wire does; the record keeps it as its own field.
const { [AGENT_CHAT_PERMISSION_MODE_OPTION_ID]: permissionMode, ...options } =
identity.options ?? {}
const conversationName = normalizeAgentSessionConversationName(identity.conversationName)
return {
schemaVersion: AGENT_SESSION_RECORD_SCHEMA_VERSION,
sessionId: identity.sessionId,
Expand All @@ -34,6 +37,7 @@ export function agentSessionRecordIdentityFields(
...(identity.launchArgs ? { launchArgs: [...identity.launchArgs] } : {}),
// A /clear continues in the same tab, so it stays in the folder the chat ran in.
...(identity.launchDirectory ? { launchDirectory: identity.launchDirectory } : {}),
...(conversationName ? { conversationName } : {}),
createdAt: now,
updatedAt: now
}
Expand Down
2 changes: 2 additions & 0 deletions src/main/runtime/agent-session-reservation-admission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export type AgentSessionReserveRequest = {
/** 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
/** Names the new record; only an adopting create has a name before its first message. */
conversationName?: string
/** Null when the session does not exist yet; otherwise the fence the caller last observed. */
expectedFence: number | null
/** A supplier is invoked only when this operation wins a new reservation. */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { agentSessionRefusalError } from '../../shared/agent-session-wire-refusa
import { OrcaRuntimeWithGetWorktreePs } from './orca-runtime-get-worktree-ps'
import { getStructuredAgentSessionHost } from '../native-chat/agent-session-wire/structured-agent-session-registry'
import {
resolveAdoptedConversationName,
resolveCommittedStructuredAgentSessionAdoptionIntent,
resolveStructuredAgentSessionAdoptionForCreate
} from './structured-agent-session-create-adoption'
Expand Down Expand Up @@ -239,6 +240,14 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR
workspaceCwd: (await this.resolveRuntimeFileTarget(input.worktree)).worktree.path
})
: null
const conversationName =
input.resumeFrom && adoption
? await resolveAdoptedConversationName({
agent: input.agent,
providerSessionId: input.resumeFrom.providerSessionId,
transcriptPath: adoption.transcriptPath
})
: null
return {
envelope: {
sessionId: input.envelope.sessionId,
Expand All @@ -262,7 +271,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR
agent: definition.agent,
nativeId: input.resumeFrom.providerSessionId
},
transcriptPath: adoption.transcriptPath
transcriptPath: adoption.transcriptPath,
...(conversationName ? { conversationName } : {})
}
}
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const applyAgentWorkspaceTrust = vi.hoisted(() => vi.fn(async () => ({})))
vi.mock('../agent-workspace-trust', () => ({ applyAgentWorkspaceTrust }))
const resolveLocalAiVaultSessionTitles = vi.hoisted(() => vi.fn())
vi.mock('../ai-vault/session-title-resolver', () => ({ resolveLocalAiVaultSessionTitles }))

import { OrcaRuntimeService } from './orca-runtime'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants'
import { attachFingerprintFields } from '../native-chat/agent-session-wire/structured-agent-session-attach'

beforeEach(() => {
applyAgentWorkspaceTrust.mockClear()
resolveLocalAiVaultSessionTitles.mockReset()
})

afterEach(() => {
Expand Down Expand Up @@ -383,6 +386,77 @@ describe('adopting a conversation recorded outside Orca', () => {
}
})

describe('naming the adopted chat', () => {
const threadId = '0198c2a4-7d1e-7c3a-9a4b-1f2e3d4c5b6a'

async function adoptCodexConversation(lookup: string | null | Error) {
const root = await mkdtemp(join(tmpdir(), 'orca-codex-create-intent-'))
try {
const accountHome = join(root, 'home')
vi.stubEnv('ORCA_USER_DATA_PATH', root)
const sessionsDir = join(accountHome, 'sessions', '2026', '10', '14')
await mkdir(sessionsDir, { recursive: true })
await writeFile(
join(sessionsDir, `rollout-2026-10-14T00-00-00-${threadId}.jsonl`),
`${JSON.stringify({ type: 'session_meta', payload: { id: threadId, cwd: root } })}\n`
)
if (lookup instanceof Error) {
resolveLocalAiVaultSessionTitles.mockRejectedValue(lookup)
} else {
resolveLocalAiVaultSessionTitles.mockResolvedValue({
titles: lookup ? [{ agent: 'codex', sessionId: threadId, title: lookup }] : []
})
}
const runtime = new OrcaRuntimeService(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store.
{ getSettings: () => ({ agentDefaultEnv: { codex: {} } }) } as never,
undefined,
{ prepareCodexStructuredLaunch: vi.fn(() => accountHome) }
)
vi.spyOn(runtime, 'getStructuredAgentSessionCreateSupport').mockResolvedValue({
supported: true
})
Object.assign(runtime, {
resolveStructuredAgentSessionLocation: vi.fn(async () => ({
executionHostId: 'local',
wslDistro: null,
workspaceId: 'workspace-1',
workspaceKind: 'git-worktree' as const
})),
resolveRuntimeFileTarget: vi.fn(async () => ({ worktree: { path: root } }))
})
return await runtime.resolveStructuredAgentSessionCreateIntent({
envelope: { sessionId: 'chat-1', clientOperationId: 'operation-1' },
worktree: 'id:workspace-1',
agent: 'codex',
resumeFrom: { providerSessionId: threadId }
})
} finally {
await rm(root, { recursive: true, force: true })
}
}

it('names the chat after the conversation title Session History shows', async () => {
const intent = await adoptCodexConversation('Fix the flaky login test')

expect(intent.adopt?.conversationName).toBe('Fix the flaky login test')
})

it('leaves a conversation with no title unnamed rather than seeding History placeholder', async () => {
const intent = await adoptCodexConversation(`Codex ${threadId.slice(0, 8)}`)

expect(intent.adopt).toBeDefined()
expect(intent.adopt?.conversationName).toBeUndefined()
})

it('still adopts with the default name when the title lookup fails', async () => {
const intent = await adoptCodexConversation(new Error('AI Vault service timed out'))

expect(intent.adopt?.providerHandle).toMatchObject({ agent: 'codex', nativeId: threadId })
expect(intent.adopt?.conversationName).toBeUndefined()
})
})

it('refuses an agent registered with no transcript importer before any adoption work', async () => {
const runtime = new OrcaRuntimeService(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: create intent only reads getSettings from the store.
Expand Down
32 changes: 32 additions & 0 deletions src/main/runtime/structured-agent-session-create-adoption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import {
resolveStructuredAgentSessionAdoption,
structuredAdoptionConflictError
} from '../native-chat/structured-agent-session-history-adoption'
import { isAiVaultTitleAgent } from '../../shared/ai-vault-session-title'
import { generatedSessionTitle } from '../ai-vault/session-scanner-accumulator'
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
import type {
StructuredAgentTranscriptImport,
StructuredAgentTranscriptImportSettings
Expand Down Expand Up @@ -88,3 +91,32 @@ export async function resolveStructuredAgentSessionAdoptionForCreate(input: {
workspaceCwd: input.workspaceCwd
})
}

/** The title Session History shows for the conversation, so the adopting chat is named after it
* until the user renames it. Null for an agent History does not title, or a conversation with
* nothing to title it by. */
export async function resolveAdoptedConversationName(input: {
agent: StructuredAgentId
providerSessionId: string
transcriptPath: string
}): Promise<string | null> {
if (!isAiVaultTitleAgent(input.agent)) {
return null
}
// Naming is cosmetic: a failed or timed-out lookup must not fail the resume.
const titles = await resolveLocalAiVaultSessionTitles([
{
agent: input.agent,
sessionId: input.providerSessionId,
transcriptPath: input.transcriptPath
}
]).then(
(result) => result.titles,
() => []
)
const title = titles[0]?.title
// History falls back to "<Agent> <id prefix>" for an untitled conversation; that names nothing.
return title && title !== generatedSessionTitle(input.agent, input.providerSessionId)
? title
: null
}