diff --git a/src/main/ai-vault/session-first-user-prompt-read.test.ts b/src/main/ai-vault/session-first-user-prompt-read.test.ts index 5213c49ab3e..b6b8ee4eee5 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.test.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.test.ts @@ -134,4 +134,30 @@ describe('readAiVaultFirstUserPrompt', () => { readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath }) ).resolves.toEqual({ prompt: null }) }) + + // The web preload fallback and the renderer's canLoadFullFirstPrompt guard both + // rely on remote hosts never reading a local transcript body. + it('returns null for a non-local execution host', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-remote-')) + tempRoots.push(root) + const filePath = join(root, 'session.jsonl') + await writeFile( + filePath, + JSON.stringify({ + type: 'user', + sessionId: 'remote-session', + timestamp: '2026-05-01T10:00:00.000Z', + cwd: '/repo/app', + message: { role: 'user', content: 'Should not be returned' } + }) + ) + + await expect( + readAiVaultFirstUserPrompt({ + agent: 'claude', + filePath, + executionHostId: 'ssh:build-box' + }) + ).resolves.toEqual({ prompt: null }) + }) }) diff --git a/src/main/ai-vault/session-first-user-prompt-read.ts b/src/main/ai-vault/session-first-user-prompt-read.ts index 2023303e4e6..b656494f61c 100644 --- a/src/main/ai-vault/session-first-user-prompt-read.ts +++ b/src/main/ai-vault/session-first-user-prompt-read.ts @@ -104,7 +104,7 @@ async function parseSessionForFullFirstUserPrompt(args: { } } - const file = await fileWithMtimeForPath(args.filePath) + const file = await fileWithMtimeForPath(args.filePath, args.agent) if (!file) { return null } @@ -119,10 +119,14 @@ async function parseSessionForFullFirstUserPrompt(args: { ) } -async function fileWithMtimeForPath(filePath: string): Promise { +async function fileWithMtimeForPath( + filePath: string, + agent: AiVaultAgent +): Promise { // OpenCode SQLite candidates use a synthetic `dbPath#sessionId` path that is // not a real filesystem object; parsers that need it accept the path as-is. - if (filePath.includes('#')) { + // `#` is legal in real filenames, so gate on the agent and the synthetic shape. + if (agent === 'opencode' && splitOpenCodeSqliteCandidate(filePath)) { return { path: filePath, mtimeMs: 0, diff --git a/src/main/ai-vault/session-scanner-accumulator.ts b/src/main/ai-vault/session-scanner-accumulator.ts index ca5cd31144f..caf08f4259d 100644 --- a/src/main/ai-vault/session-scanner-accumulator.ts +++ b/src/main/ai-vault/session-scanner-accumulator.ts @@ -163,6 +163,14 @@ export function addPreviewMessage( seedFirstUserPrompt?: boolean } ): void { + // Seeded before the preview-empty return so the copy body never depends on + // preview-only normalization rules. + seedFullFirstUserPrompt( + accumulator, + args.role, + () => (args.text ? normalizeFullFirstUserPromptText(args.text) : null), + args.seedFirstUserPrompt + ) const text = normalizePreviewText(args.text ?? '') if (!text) { return @@ -176,28 +184,6 @@ export function addPreviewMessage( accumulator.previewMessages.shift() accumulator.previewMessagesTruncated = true } - // Why: list scans never store firstUserPrompt (payload/perf). Only the - // on-demand full-capture path seeds the untruncated copy body. - if ( - args.role === 'user' && - args.seedFirstUserPrompt !== false && - !accumulator.firstUserPrompt && - shouldCaptureFullFirstUserPrompt() && - args.text - ) { - accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text) - } - // Why: list scans never store firstUserPrompt (payload/perf). Only the - // on-demand full-capture path seeds the untruncated copy body. - if ( - args.role === 'user' && - args.seedFirstUserPrompt !== false && - !accumulator.firstUserPrompt && - shouldCaptureFullFirstUserPrompt() && - args.text - ) { - accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(args.text) - } } export function addPreviewContent( @@ -207,14 +193,12 @@ export function addPreviewContent( timestamp?: unknown, options?: { seedFirstUserPrompt?: boolean } ): void { - if ( - role === 'user' && - options?.seedFirstUserPrompt !== false && - !accumulator.firstUserPrompt && - shouldCaptureFullFirstUserPrompt() - ) { - accumulator.firstUserPrompt = extractFullFirstUserPromptText(content) - } + seedFullFirstUserPrompt( + accumulator, + role, + () => extractFullFirstUserPromptText(content), + options?.seedFirstUserPrompt + ) addPreviewMessage(accumulator, { role, text: extractPreviewContentText(content), @@ -224,6 +208,28 @@ export function addPreviewContent( }) } +/** + * Seed the copyable first prompt from the first real user turn. `fullText` is a + * thunk so list scans (capture mode `none`) never pay the extraction cost. + */ +export function seedFullFirstUserPrompt( + accumulator: SessionAccumulator, + role: AiVaultSessionPreviewMessage['role'], + fullText: () => string | null, + seedFirstUserPrompt?: boolean +): void { + // Why: list scans never store firstUserPrompt (payload/perf). Only the + // on-demand full-capture path seeds the untruncated copy body. + if ( + role === 'user' && + seedFirstUserPrompt !== false && + !accumulator.firstUserPrompt && + shouldCaptureFullFirstUserPrompt() + ) { + accumulator.firstUserPrompt = fullText() + } +} + export function timestampIso(value: unknown): string | null { const parsed = timestampMs(value) return Number.isFinite(parsed) ? new Date(parsed).toISOString() : null diff --git a/src/main/ai-vault/session-scanner-first-user-prompt.ts b/src/main/ai-vault/session-scanner-first-user-prompt.ts index 4228abc9aaa..f7ec1c72299 100644 --- a/src/main/ai-vault/session-scanner-first-user-prompt.ts +++ b/src/main/ai-vault/session-scanner-first-user-prompt.ts @@ -68,12 +68,14 @@ function finalizeFullFirstUserPrompt(value: string): string | null { if (isKnownHarnessInjectedUserTurnText(trimmed)) { return null } + // Bound before scanning so a multi-MB paste cannot force a full lowercase copy. + const bounded = sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) // Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path. - const lower = trimmed.toLowerCase() + const lower = bounded.toLowerCase() if (lower.startsWith('') && !lower.includes('')) { return null } - return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT) + return bounded } function isSuppressedFullFirstUserPrompt(value: string): boolean { diff --git a/src/main/ai-vault/session-scanner-injected-title.test.ts b/src/main/ai-vault/session-scanner-injected-title.test.ts index f0f566a79f5..a1bf6f799f8 100644 --- a/src/main/ai-vault/session-scanner-injected-title.test.ts +++ b/src/main/ai-vault/session-scanner-injected-title.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { scanAiVaultSessions } from './session-scanner' +import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture' import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures' let tempRoots: string[] = [] @@ -127,10 +128,14 @@ describe('scanAiVaultSessions harness-injected title seeding', () => { ]) ) - const result = await scanAiVaultSessions({ - ...roots, - platform: 'darwin' - }) + // Full capture, else firstUserPrompt is never populated and the assertion + // below would pass whether or not the meta preamble is excluded. + const result = await withFullFirstUserPromptCapture(() => + scanAiVaultSessions({ + ...roots, + platform: 'darwin' + }) + ) expect(result.issues).toEqual([]) expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR') diff --git a/src/main/ai-vault/session-scanner-opencode-sqlite.ts b/src/main/ai-vault/session-scanner-opencode-sqlite.ts index da9801ca5e9..c0ffdbfb894 100644 --- a/src/main/ai-vault/session-scanner-opencode-sqlite.ts +++ b/src/main/ai-vault/session-scanner-opencode-sqlite.ts @@ -294,14 +294,17 @@ export async function parseOpenCodeSqliteSession(args: { const previewSql = buildPreviewQuery(db) if (previewSql) { - const previewRows = db - .prepare(previewSql) - .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT) as PreviewRow[] // Why: SQL already dropped anything older than the newest-N window, so the - // accumulator never shifts and cannot detect the truncation itself. - if (previewRows.length >= OPENCODE_SQLITE_PREVIEW_LIMIT) { + // accumulator never shifts and cannot detect the truncation itself. Ask for + // one extra row so an exactly-full window is not mistaken for a trimmed one. + const probedRows = db + .prepare(previewSql) + .all(sessionId, OPENCODE_SQLITE_PREVIEW_LIMIT + 1) as PreviewRow[] + if (probedRows.length > OPENCODE_SQLITE_PREVIEW_LIMIT) { accumulator.previewMessagesTruncated = true } + // Newest-first, so the probe row to drop is the oldest one at the tail. + const previewRows = probedRows.slice(0, OPENCODE_SQLITE_PREVIEW_LIMIT) // Why: query returns newest-first; push in chronological order so the // accumulator's ring buffer keeps the newest OPENCODE_SQLITE_PREVIEW_LIMIT // messages. diff --git a/src/main/ai-vault/session-scanner-secondary-parsers.ts b/src/main/ai-vault/session-scanner-secondary-parsers.ts index cb472aaefe9..0abcdfb5052 100644 --- a/src/main/ai-vault/session-scanner-secondary-parsers.ts +++ b/src/main/ai-vault/session-scanner-secondary-parsers.ts @@ -15,6 +15,7 @@ import { addPreviewMessage, createAccumulator, finalizeSession, + seedFullFirstUserPrompt, sessionIdFromFileName, updateTimeline } from './session-scanner-accumulator' @@ -249,19 +250,22 @@ export async function consumeOpenCodeMessages( if (role === 'user' || role === 'assistant') { accumulator.messageCount++ updateTimeline(accumulator, timeObjectValue(message.time, 'created')) + const summary = asRecord(message.summary) if (role === 'user') { - accumulator.title ??= extractString(asRecord(message.summary)?.title) - accumulator.title ??= extractString(asRecord(message.summary)?.body) + accumulator.title ??= extractString(summary?.title) ?? extractString(summary?.body) } // Why: pass raw body text so full first-prompt capture is not stuck on the // 220-char preview fold (addPreviewMessage preview-caps for display). + const contentText = extractFullFirstUserPromptText(message.content) + // Why: the summary fallbacks are AI-generated. Seed the copyable first + // prompt from real typed content only, never from a summary of a + // harness-injected turn. + seedFullFirstUserPrompt(accumulator, role, () => contentText) addPreviewMessage(accumulator, { role, - text: - extractFullFirstUserPromptText(message.content) ?? - extractString(asRecord(message.summary)?.body) ?? - extractString(asRecord(message.summary)?.title), - timestamp: timeObjectValue(message.time, 'created') + text: contentText ?? extractString(summary?.body) ?? extractString(summary?.title), + timestamp: timeObjectValue(message.time, 'created'), + seedFirstUserPrompt: false }) accumulator.model = extractString(asRecord(message.model)?.modelID) || diff --git a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx index 5b6da922fd4..ecc8d932cc7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-first-prompt-card.tsx @@ -7,6 +7,10 @@ import { translate } from '@/i18n/i18n' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' +// Why: the main-process re-parse has no deadline of its own. Without this the +// card can sit in `loading` forever on a huge or stalled transcript. +const FULL_FIRST_PROMPT_LOAD_TIMEOUT_MS = 15_000 + function canLoadFullFirstPrompt( session: Pick ): boolean { @@ -57,18 +61,29 @@ export function FirstPromptCard({ const generation = generationRef.current const isStale = (): boolean => generationRef.current !== generation - const promise = getFirstUserPrompt({ - agent, - filePath, - sessionId, - executionHostId, - codexHome + let timeoutId: number | undefined + const deadline = new Promise((resolve) => { + timeoutId = window.setTimeout(() => { + resolve(null) + }, FULL_FIRST_PROMPT_LOAD_TIMEOUT_MS) }) + + const promise = Promise.race([ + getFirstUserPrompt({ + agent, + filePath, + sessionId, + executionHostId, + codexHome + }), + deadline + ]) .then((result) => { if (isStale()) { return null } - const prompt = result.prompt?.trim() || null + // A timed-out read lands here as null and falls back to the preview text. + const prompt = result?.prompt?.trim() || null fullTextRef.current = prompt setFullText(prompt) return prompt @@ -82,11 +97,13 @@ export function FirstPromptCard({ return null }) .finally(() => { + window.clearTimeout(timeoutId) // A stale settle must not clear the live request's dedupe handle. if (isStale()) { return } setLoading(false) + // Left null on timeout/failure so a later copy click can retry. loadPromiseRef.current = null })