From 92254dc754479f1311a3d9df2a06b2cd9dc96180 Mon Sep 17 00:00:00 2001 From: Erfan Samieyan <25950587+erfoon@users.noreply.github.com> Date: Wed, 14 Oct 2026 18:30:44 +0100 Subject: [PATCH] fix(native-chat): name a resumed chat after the conversation it resumes "Resume in New Native Chat" opened chats named "Claude Chat" or "Codex Chat", and first-message naming never fires for them because their journal already holds the imported history. When a create adopts an existing conversation, look up the title Session History shows for it and seed it as the new record's conversationName. The lookup is best-effort: a failure or History's placeholder title leaves the chat with the default name, as before. Fixes #28763 Co-Authored-By: Claude Opus 5.5 --- .../structured-agent-session-attach.ts | 4 + ...ured-agent-session-create-adoption.test.ts | 11 +++ .../runtime/agent-session-record-founding.ts | 4 + .../agent-session-reservation-admission.ts | 2 + ...structured-agent-session-create-support.ts | 12 ++- ...ctured-agent-session-create-intent.test.ts | 74 +++++++++++++++++++ ...tructured-agent-session-create-adoption.ts | 32 ++++++++ 7 files changed, 138 insertions(+), 1 deletion(-) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts index 54e7f61845d3..9747cd0f1ed8 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach.ts @@ -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 } } @@ -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 diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 60a160378803..81ecd41d9fab 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -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([ diff --git a/src/main/runtime/agent-session-record-founding.ts b/src/main/runtime/agent-session-record-founding.ts index 7c750ca3dc12..d0de70a80d85 100644 --- a/src/main/runtime/agent-session-record-founding.ts +++ b/src/main/runtime/agent-session-record-founding.ts @@ -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 @@ -14,6 +15,7 @@ export type AgentSessionRecordIdentity = Pick< | 'options' | 'launchArgs' | 'launchDirectory' + | 'conversationName' > export function agentSessionRecordIdentityFields( @@ -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, @@ -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 } diff --git a/src/main/runtime/agent-session-reservation-admission.ts b/src/main/runtime/agent-session-reservation-admission.ts index eb956e4b34c4..bf48ae73e80f 100644 --- a/src/main/runtime/agent-session-reservation-admission.ts +++ b/src/main/runtime/agent-session-reservation-admission.ts @@ -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. */ diff --git a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts index 42593b34fc85..8db3116b0380 100644 --- a/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts +++ b/src/main/runtime/orca-runtime-get-structured-agent-session-create-support.ts @@ -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' @@ -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, @@ -262,7 +271,8 @@ export class OrcaRuntimeWithGetStructuredAgentSessionCreateSupport extends OrcaR agent: definition.agent, nativeId: input.resumeFrom.providerSessionId }, - transcriptPath: adoption.transcriptPath + transcriptPath: adoption.transcriptPath, + ...(conversationName ? { conversationName } : {}) } } : {}), diff --git a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts index 27a986a51f70..b27dca8d1567 100644 --- a/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts +++ b/src/main/runtime/orca-runtime-structured-agent-session-create-intent.test.ts @@ -5,6 +5,8 @@ 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' @@ -12,6 +14,7 @@ import { attachFingerprintFields } from '../native-chat/agent-session-wire/struc beforeEach(() => { applyAgentWorkspaceTrust.mockClear() + resolveLocalAiVaultSessionTitles.mockReset() }) afterEach(() => { @@ -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. diff --git a/src/main/runtime/structured-agent-session-create-adoption.ts b/src/main/runtime/structured-agent-session-create-adoption.ts index 80ed28fd4024..8e59eca755b0 100644 --- a/src/main/runtime/structured-agent-session-create-adoption.ts +++ b/src/main/runtime/structured-agent-session-create-adoption.ts @@ -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 @@ -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 { + 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 " " for an untitled conversation; that names nothing. + return title && title !== generatedSessionTitle(input.agent, input.providerSessionId) + ? title + : null +}