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 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-17 01:22:15 -07:00
committed by GitHub
co-authored by Claude
parent ee4fd06593
commit 7adcefb078
5 changed files with 193 additions and 2 deletions
@@ -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
)
}
@@ -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'])
})
})
+2 -1
View File
@@ -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)
}
}
+4 -1
View File
@@ -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.
@@ -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()
}
})