refactor(ai-vault): improve first user prompt capture robustness and per

- Add 15s timeout to full-prompt load to prevent indefinite loading states
- Extract seedFullFirstUserPrompt helper for reuse across parsers
- Prevent AI-generated summaries from becoming the copyable first prompt
- Fix truncation detection in OpenCode SQLite by probing for N+1 rows
- Optimize text bounding to apply safety limit before toLowerCase
- Gate synthetic OpenCode path detection on agent type, not just # presence
- Add test coverage for remote execution host handling
This commit is contained in:
Jinjing
2026-08-02 11:27:15 -07:00
parent 0a987e8f0a
commit d12f825511
8 changed files with 125 additions and 58 deletions
@@ -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 })
})
})
@@ -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<FileWithMtime | null> {
async function fileWithMtimeForPath(
filePath: string,
agent: AiVaultAgent
): Promise<FileWithMtime | null> {
// 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,
@@ -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
@@ -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('<user_info>') && !lower.includes('<user_query>')) {
return null
}
return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT)
return bounded
}
function isSuppressedFullFirstUserPrompt(value: string): boolean {
@@ -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')
@@ -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.
@@ -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) ||
@@ -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<AiVaultSession, 'executionHostId' | 'filePath'>
): 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<null>((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
})