mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -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
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user