From 7adcefb078b92b6f2626eeb34c4f009f3ab632cc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 17 Oct 2026 01:22:15 -0700 Subject: [PATCH] fix(ai-vault): hide Orca's chat-naming requests from Session History (#30029) Naming a chat runs a one-shot agent CLI request in the chat's folder, and the CLI saves it beside the user's chats. Session History listed it, and opening it from a phone resumed the naming request instead of the chat. Fixes #29973 Co-authored-by: Claude --- .../conversation-name-request-sessions.ts | 24 +++++ ...scanner-conversation-name-requests.test.ts | 98 +++++++++++++++++++ src/main/ai-vault/session-scanner.ts | 3 +- src/shared/conversation-name-generation.ts | 5 +- ...ve-session-history-naming-requests.spec.ts | 65 ++++++++++++ 5 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 src/main/ai-vault/conversation-name-request-sessions.ts create mode 100644 src/main/ai-vault/session-scanner-conversation-name-requests.test.ts create mode 100644 tests/e2e/headless-serve-session-history-naming-requests.spec.ts diff --git a/src/main/ai-vault/conversation-name-request-sessions.ts b/src/main/ai-vault/conversation-name-request-sessions.ts new file mode 100644 index 00000000000..96d11e44423 --- /dev/null +++ b/src/main/ai-vault/conversation-name-request-sessions.ts @@ -0,0 +1,24 @@ +import type { AiVaultSession } from '../../shared/ai-vault-types' +import { CONVERSATION_NAME_PROMPT_OPENING } from '../../shared/conversation-name-generation' + +// A naming request is one prompt and its one-line reply; a longer transcript is someone's chat. +const MAX_NAME_REQUEST_MESSAGES = 6 + +/** + * Whether a transcript is Orca's own chat-naming request rather than a conversation. The agent CLI + * saves that one-shot run beside the user's chats in the same project folder, so Session History + * listed it, and opening it resumed the naming request instead of the chat (#29973). A transcript + * someone kept typing into is theirs, so it needs the naming prompt as its only user turn. + */ +export function isConversationNameRequestSession(session: AiVaultSession): boolean { + if (session.previewMessagesTruncated || session.messageCount > MAX_NAME_REQUEST_MESSAGES) { + return false + } + const [request, ...followUps] = session.previewMessages.filter( + (message) => message.role === 'user' + ) + return ( + followUps.length === 0 && + request?.text.trimStart().startsWith(CONVERSATION_NAME_PROMPT_OPENING) === true + ) +} diff --git a/src/main/ai-vault/session-scanner-conversation-name-requests.test.ts b/src/main/ai-vault/session-scanner-conversation-name-requests.test.ts new file mode 100644 index 00000000000..de9fe0d1d41 --- /dev/null +++ b/src/main/ai-vault/session-scanner-conversation-name-requests.test.ts @@ -0,0 +1,98 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { buildConversationNamePrompt } from '../../shared/conversation-name-generation' +import { scanAiVaultSessions } from './session-scanner' +import { isolatedScanRoots, writeJsonlFile } from './session-scanner-test-fixtures' + +let tempRoots: string[] = [] + +afterEach(async () => { + await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true }))) + tempRoots = [] +}) + +function turn(sessionId: string, at: string, role: 'user' | 'assistant', content: string) { + return { + type: role, + sessionId, + timestamp: at, + cwd: '/tmp/worktree', + ...(role === 'user' ? { entrypoint: 'sdk-cli' } : {}), + message: { role, content } + } +} + +// #29973: naming a chat runs a one-shot `claude -p` in the chat's folder, and the CLI saves that +// run next to the chat. Listed, it read as the chat, and opening it resumed the naming request. +describe('scanAiVaultSessions chat-naming requests', () => { + it('lists the chat but not the request Orca ran to name it', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-name-request-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const projectDir = join(roots.claudeProjectsDir, '-tmp-worktree') + const request = 'Refactor the codebase into modules' + await writeJsonlFile(join(projectDir, 'chat.jsonl'), [ + turn('chat', '2026-10-17T03:13:49.000Z', 'user', request), + turn('chat', '2026-10-17T03:13:55.000Z', 'assistant', 'Starting on the refactor.') + ]) + await writeJsonlFile(join(projectDir, 'title.jsonl'), [ + turn( + 'title', + '2026-10-17T03:13:51.000Z', + 'user', + buildConversationNamePrompt({ firstPrompt: request }) + ), + turn('title', '2026-10-17T03:13:52.000Z', 'assistant', 'Refactor codebase into modules') + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.issues).toEqual([]) + expect(result.sessions.map((session) => session.sessionId)).toEqual(['chat']) + }) + + it('keeps a real conversation that only quotes the naming prompt later', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-name-request-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const projectDir = join(roots.claudeProjectsDir, '-tmp-worktree') + await writeJsonlFile(join(projectDir, 'chat.jsonl'), [ + turn('chat', '2026-10-17T03:13:49.000Z', 'user', 'Why are my chats named oddly?'), + turn('chat', '2026-10-17T03:13:50.000Z', 'assistant', 'Let me look.'), + turn( + 'chat', + '2026-10-17T03:13:51.000Z', + 'user', + buildConversationNamePrompt({ firstPrompt: 'x' }) + ) + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual(['chat']) + }) + + it('keeps a naming request someone opened and kept chatting in', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-name-request-')) + tempRoots.push(root) + const roots = isolatedScanRoots(root) + const projectDir = join(roots.claudeProjectsDir, '-tmp-worktree') + await writeJsonlFile(join(projectDir, 'continued.jsonl'), [ + turn( + 'continued', + '2026-10-17T03:13:51.000Z', + 'user', + buildConversationNamePrompt({ firstPrompt: 'x' }) + ), + turn('continued', '2026-10-17T03:13:52.000Z', 'assistant', 'A title'), + turn('continued', '2026-10-17T03:20:00.000Z', 'user', 'Now actually help me with x'), + turn('continued', '2026-10-17T03:20:05.000Z', 'assistant', 'Sure.') + ]) + + const result = await scanAiVaultSessions({ ...roots, platform: 'linux' }) + + expect(result.sessions.map((session) => session.sessionId)).toEqual(['continued']) + }) +}) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index b1f2a83b614..39c9adbee93 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -38,6 +38,7 @@ import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { noteAiVaultScanFilesRead, noteAiVaultScanReading } from './ai-vault-scan-progress' import { DEFAULT_AI_VAULT_SCAN_LIMIT } from '../../shared/ai-vault-session-depth' import { withDevinSessionsDbScan } from './session-scanner-devin-db' +import { isConversationNameRequestSession } from './conversation-name-request-sessions' import { withOpenCodeSqliteScanScope } from './session-scanner-opencode-sqlite-scan-scope' const SESSION_PARSE_CONCURRENCY = 8 @@ -256,7 +257,7 @@ async function parseSessionCandidates(args: { if (result.issue) { recordSessionScanIssue(args.issues, result.issue) } - if (result.session) { + if (result.session && !isConversationNameRequestSession(result.session)) { sessions.add(result.session) } } diff --git a/src/shared/conversation-name-generation.ts b/src/shared/conversation-name-generation.ts index 5657d15582f..cee71400def 100644 --- a/src/shared/conversation-name-generation.ts +++ b/src/shared/conversation-name-generation.ts @@ -11,12 +11,15 @@ export function clampConversationNameFirstPrompt(firstPrompt: string): string { return sliceAtCodeUnitLimit(firstPrompt.trim(), MAX_FIRST_PROMPT_LENGTH) } +/** The naming request's first line; Session History reads it to hide these one-shot runs. */ +export const CONVERSATION_NAME_PROMPT_OPENING = "Name this chat from the user's first request." + export function buildConversationNamePrompt( context: ConversationNameContext, customPrompt = '' ): string { const firstPrompt = clampConversationNameFirstPrompt(context.firstPrompt) - const basePrompt = `Name this chat from the user's first request. + const basePrompt = `${CONVERSATION_NAME_PROMPT_OPENING} Write a short, specific title of at most ${MAX_GENERATED_NAME_LENGTH} characters. Output only the title, with no heading, quotes, or explanation. diff --git a/tests/e2e/headless-serve-session-history-naming-requests.spec.ts b/tests/e2e/headless-serve-session-history-naming-requests.spec.ts new file mode 100644 index 00000000000..3ec8346bf25 --- /dev/null +++ b/tests/e2e/headless-serve-session-history-naming-requests.spec.ts @@ -0,0 +1,65 @@ +/** + * #29973: a phone paired straight to a headless `orca serve` sees the Claude chat in Session + * History, but not the one-shot request Orca ran in the same folder to name that chat. + */ +import { randomUUID } from 'node:crypto' +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' +import { pairMobileClient } from './helpers/paired-mobile-client' +import { buildConversationNamePrompt } from '../../src/shared/conversation-name-generation' + +function transcript(sessionId: string, cwd: string, turns: [string, string][]): string { + return turns + .map(([role, content], index) => + JSON.stringify({ + type: role, + sessionId, + timestamp: new Date(Date.UTC(2026, 9, 17, 3, 13, 49 + index)).toISOString(), + cwd, + entrypoint: 'sdk-cli', + message: { role, content } + }) + ) + .join('\n') + .concat('\n') +} + +test('Session History on a headless host hides chat-naming requests', async ({ testRepoPath }) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ pairingScope: 'mobile' }) + const phone = pairMobileClient(host.offer) + try { + const claudeHome = host.env.CLAUDE_CONFIG_DIR ?? path.join(host.env.HOME ?? '', '.claude') + const projectDir = path.join(claudeHome, 'projects', testRepoPath.replace(/[^\w]/g, '-')) + mkdirSync(projectDir, { recursive: true }) + const chatId = randomUUID() + const nameRequestId = randomUUID() + writeFileSync( + path.join(projectDir, `${chatId}.jsonl`), + transcript(chatId, testRepoPath, [ + ['user', 'Refactor the codebase'], + ['assistant', 'Starting on the refactor.'] + ]) + ) + writeFileSync( + path.join(projectDir, `${nameRequestId}.jsonl`), + transcript(nameRequestId, testRepoPath, [ + ['user', buildConversationNamePrompt({ firstPrompt: 'Refactor the codebase' })], + ['assistant', 'Refactor the codebase'] + ]) + ) + + const history = await phone.call<{ sessions: { sessionId: string }[] }>( + 'aiVault.listSessions', + { limit: 50, force: true } + ) + + const listedIds = history.sessions.map((row) => row.sessionId) + expect(listedIds).toContain(chatId) + expect(listedIds).not.toContain(nameRequestId) + } finally { + await host.dispose() + } +})