mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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'])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user