Add first user prompt to AI Vault session history row (#12006)

* Add first user prompt to AI Vault session history rows

Re-parse transcripts on demand to extract and display the untruncated first
user prompt for copy/reuse. List scans omit the body (payload/perf); UI loads
it when session details expand. Grok sessions extract the typed ask from
<user_query> envelope, skipping injected <user_info> bootstrap rows. Supports
Claude, Codex, Grok, and OpenCode agents.

* fix(ai-vault): split SessionTime out to pass max-lines lint

AiVaultSessionDetails exceeded the 400-line oxlint limit after adding
first-prompt UI; move SessionTime into its own module.

* fix(ai-vault): handle corrupt transcripts and fix OpenCode prompt captur

Corrupt transcripts now resolve null instead of rejecting the IPC call, matching behavior for other unavailable cases. OpenCode SQLite parsing now correctly captures all text parts from the earliest user message only, fixing truncation of large prompts and padding of small ones. Add stale-response guard in the UI to prevent late results from overwriting the current session when tabs switch. Consolidate text slicing via `sliceAtCodeUnitLimit` to avoid surrogate-pair splits across all callers.

* test(ai-vault): add first-user-prompt UTF-16 safety tests

Ensure truncation at safety limits doesn't split UTF-16 surrogate pairs,
preventing corruption of astral characters in captured prompts.

* fix(ai-vault): key first-prompt-card by session.id

Remounting the card on session switches prevents late responses from
a previous load from writing stale data into the component's refs.
Also improves conversation-turn key stability.
This commit is contained in:
Jinjing 2026-08-01 18:38:49 -07:00 committed by GitHub
parent e0f597a351
commit dbfffa6530
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1533 additions and 238 deletions

View File

@ -76,6 +76,7 @@ const aiVaultListResultSchema = z.object({
totalTokens: z.number(),
previewMessages: z.array(aiVaultSessionPreviewMessageSchema),
// Optional keeps paired hosts on older builds compatible.
firstUserPrompt: z.string().nullable().optional(),
lastUserPrompt: z.string().nullable().optional(),
// Default keeps remote hosts running an older build (no recoverable-signal
// fields) parseable; they simply report no recoverable-empty sessions.

View File

@ -0,0 +1,137 @@
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { readAiVaultFirstUserPrompt } from './session-first-user-prompt-read'
const tempRoots: string[] = []
afterEach(async () => {
// Best-effort cleanup; tests are sandboxed under mkdtemp.
const { rm } = await import('node:fs/promises')
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('readAiVaultFirstUserPrompt', () => {
it('returns the full first user prompt without preview truncation', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-'))
tempRoots.push(root)
const projectDir = join(root, 'project')
await mkdir(projectDir, { recursive: true })
const longPrompt = `Please implement the full vault first-prompt copy path.\n\n${'detail '.repeat(80).trimEnd()}`
const filePath = join(projectDir, 'session.jsonl')
await writeFile(
filePath,
[
JSON.stringify({
type: 'user',
sessionId: 'full-prompt-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
isMeta: false,
message: { role: 'user', content: longPrompt }
}),
JSON.stringify({
type: 'assistant',
sessionId: 'full-prompt-session',
timestamp: '2026-05-01T10:01:00.000Z',
message: { role: 'assistant', content: 'Working on it.', model: 'claude-sonnet-4-5' }
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'claude',
filePath
})
expect(result.prompt).toBe(longPrompt)
expect(result.prompt?.includes('\n\n')).toBe(true)
expect(result.prompt?.length).toBeGreaterThan(220)
})
it('extracts full Codex input_text content blocks (not preview-capped)', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-codex-'))
tempRoots.push(root)
const sessionPath = join(root, 'sessions', '2026', '07', '21', 'rollout-full.jsonl')
await mkdir(join(root, 'sessions', '2026', '07', '21'), { recursive: true })
const longPrompt = `Review the PR and fix real regressions.\n\n${'context '.repeat(60).trimEnd()}`
await writeFile(
sessionPath,
[
JSON.stringify({
timestamp: '2026-07-21T10:00:00.000Z',
type: 'session_meta',
payload: { id: 'codex-full-prompt', cwd: '/repo/app' }
}),
JSON.stringify({
timestamp: '2026-07-21T10:00:01.000Z',
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: longPrompt }]
}
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'codex',
filePath: sessionPath,
codexHome: root
})
expect(result.prompt).toBe(longPrompt)
expect(result.prompt?.length).toBeGreaterThan(220)
})
it('skips meta/harness user turns and returns the first real ask', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-meta-'))
tempRoots.push(root)
const projectDir = join(root, 'project')
await mkdir(projectDir, { recursive: true })
const filePath = join(projectDir, 'session.jsonl')
await writeFile(
filePath,
[
JSON.stringify({
type: 'user',
sessionId: 'meta-then-real',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: '/repo/app',
isMeta: true,
message: { role: 'user', content: 'Base directory for this skill: /tmp/skills' }
}),
JSON.stringify({
type: 'user',
sessionId: 'meta-then-real',
timestamp: '2026-05-01T10:00:01.000Z',
cwd: '/repo/app',
message: { role: 'user', content: 'Ship the first-prompt copy button' }
})
].join('\n')
)
const result = await readAiVaultFirstUserPrompt({
agent: 'claude',
filePath
})
expect(result.prompt).toBe('Ship the first-prompt copy button')
})
it('resolves null instead of rejecting when the transcript is corrupt', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-first-prompt-corrupt-'))
tempRoots.push(root)
const sessionDir = join(root, 'session-1')
await mkdir(sessionDir, { recursive: true })
// Grok's parser JSON.parses summary.json eagerly, so truncated JSON throws.
const summaryPath = join(sessionDir, 'summary.json')
await writeFile(summaryPath, '{"info": {"id": "session-1", "cwd": "/repo/a')
await expect(
readAiVaultFirstUserPrompt({ agent: 'grok', filePath: summaryPath })
).resolves.toEqual({ prompt: null })
})
})

View File

@ -0,0 +1,144 @@
import { stat } from 'node:fs/promises'
import type {
AiVaultAgent,
AiVaultFirstUserPromptArgs,
AiVaultFirstUserPromptResult,
AiVaultSession
} from '../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../shared/execution-host'
import { parseAgentSessionFile } from './session-scanner-agent-parser'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { splitOpenCodeSqliteCandidate } from './session-scanner-opencode-sqlite-paths'
import type { FileWithMtime } from './session-scanner-types'
export type ReadAiVaultFirstUserPromptArgs = {
agent: AiVaultAgent
filePath: string
sessionId?: string
executionHostId?: ExecutionHostId
codexHome?: string | null
}
export type ReadAiVaultFirstUserPromptResult = AiVaultFirstUserPromptResult
/** IPC-safe entry: validates untyped payload then reads the full first prompt. */
export async function handleAiVaultGetFirstUserPrompt(
args?: AiVaultFirstUserPromptArgs
): Promise<AiVaultFirstUserPromptResult> {
if (!args || typeof args.filePath !== 'string' || typeof args.agent !== 'string') {
return { prompt: null }
}
return readAiVaultFirstUserPrompt({
agent: args.agent,
filePath: args.filePath,
sessionId: typeof args.sessionId === 'string' ? args.sessionId : undefined,
executionHostId: args.executionHostId,
codexHome: args.codexHome
})
}
/**
* Re-parse one session transcript under full first-prompt capture and return
* the untruncated first real user ask for copy/reuse.
*/
export async function readAiVaultFirstUserPrompt(
args: ReadAiVaultFirstUserPromptArgs
): Promise<ReadAiVaultFirstUserPromptResult> {
const filePath = args.filePath.trim()
if (!filePath || !args.agent) {
return { prompt: null }
}
// Why: transcript bodies live on the session host. Remote rows are skipped
// (same posture as listSubagentSessions); UI falls back to preview text.
const executionHostId = args.executionHostId ?? LOCAL_EXECUTION_HOST_ID
if (executionHostId !== LOCAL_EXECUTION_HOST_ID) {
return { prompt: null }
}
// Why: partial/corrupt transcripts make parsers throw. Resolve null like every
// other unavailable case instead of rejecting the IPC call.
let session: AiVaultSession | null
try {
session = await withFullFirstUserPromptCapture(() =>
parseSessionForFullFirstUserPrompt({
agent: args.agent,
filePath,
sessionId: args.sessionId?.trim() || undefined,
codexHome: args.codexHome ?? null
})
)
} catch {
return { prompt: null }
}
const prompt = session?.firstUserPrompt?.trim() || null
return { prompt }
}
async function parseSessionForFullFirstUserPrompt(args: {
agent: AiVaultAgent
filePath: string
sessionId?: string
codexHome: string | null
}): Promise<AiVaultSession | null> {
// Why: OpenCode SQLite sessions store filePath as the db path (not db#id).
// Re-parse in-process under full capture so ALS applies and we can read the
// earliest user row (worker list-scan path only joins newest messages).
if (args.agent === 'opencode') {
const fromSynthetic = splitOpenCodeSqliteCandidate(args.filePath)
if (fromSynthetic) {
return parseOpenCodeSqliteSession({
dbPath: fromSynthetic.dbPath,
sessionId: fromSynthetic.sessionId,
platform: process.platform
})
}
if (args.sessionId) {
return parseOpenCodeSqliteSession({
dbPath: args.filePath,
sessionId: args.sessionId,
platform: process.platform
})
}
}
const file = await fileWithMtimeForPath(args.filePath)
if (!file) {
return null
}
return parseAgentSessionFile(
{
agent: args.agent,
file,
codexHome: args.codexHome
},
process.platform
)
}
async function fileWithMtimeForPath(filePath: string): 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('#')) {
return {
path: filePath,
mtimeMs: 0,
modifiedAt: new Date(0).toISOString()
}
}
try {
const info = await stat(filePath)
return {
path: filePath,
mtimeMs: info.mtimeMs,
modifiedAt: info.mtime.toISOString(),
sizeBytes: info.size
}
} catch {
return null
}
}

View File

@ -12,6 +12,11 @@ import type {
ResumableSessionParseState,
SessionAccumulator
} from './session-scanner-types'
import {
extractFullFirstUserPromptText,
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import {
extractPreviewContentText,
extractString,
@ -41,6 +46,7 @@ export function createAccumulator(args: {
messageCount: 0,
totalTokens: 0,
previewMessages: [],
firstUserPrompt: null,
lastUserPrompt: null,
queuedMessageCount: 0,
subagentTranscriptCount: 0,
@ -113,6 +119,7 @@ export function finalizeSession(
messageCount: accumulator.messageCount,
totalTokens: accumulator.totalTokens,
previewMessages: accumulator.previewMessages,
...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}),
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
queuedMessageCount: accumulator.queuedMessageCount,
subagentTranscriptCount: accumulator.subagentTranscriptCount,
@ -149,6 +156,9 @@ export function addPreviewMessage(
role: AiVaultSessionPreviewMessage['role']
text: string | null
timestamp?: unknown
// Why: Claude meta/injected turns still preview, but must not seed the
// copyable first-prompt row.
seedFirstUserPrompt?: boolean
}
): void {
const text = normalizePreviewText(args.text ?? '')
@ -163,18 +173,40 @@ export function addPreviewMessage(
if (accumulator.previewMessages.length > SESSION_PREVIEW_MESSAGE_LIMIT) {
accumulator.previewMessages.shift()
}
// 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(
accumulator: SessionAccumulator,
role: AiVaultSessionPreviewMessage['role'],
content: unknown,
timestamp?: unknown
timestamp?: unknown,
options?: { seedFirstUserPrompt?: boolean }
): void {
if (
role === 'user' &&
options?.seedFirstUserPrompt !== false &&
!accumulator.firstUserPrompt &&
shouldCaptureFullFirstUserPrompt()
) {
accumulator.firstUserPrompt = extractFullFirstUserPromptText(content)
}
addPreviewMessage(accumulator, {
role,
text: extractPreviewContentText(content),
timestamp
timestamp,
// Content path already seeded above when capture is enabled.
seedFirstUserPrompt: false
})
}

View File

@ -0,0 +1,15 @@
import { AsyncLocalStorage } from 'node:async_hooks'
// Why: list scans must not carry full first-prompt bodies (up to 500 sessions
// per refresh). On-demand copy re-parses one transcript under `full` mode.
export type FirstUserPromptCaptureMode = 'none' | 'full'
const firstUserPromptCaptureStorage = new AsyncLocalStorage<FirstUserPromptCaptureMode>()
export function getFirstUserPromptCaptureMode(): FirstUserPromptCaptureMode {
return firstUserPromptCaptureStorage.getStore() ?? 'none'
}
export function withFullFirstUserPromptCapture<T>(fn: () => Promise<T>): Promise<T> {
return firstUserPromptCaptureStorage.run('full', fn)
}

View File

@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { normalizeFullFirstUserPromptText } from './session-scanner-first-user-prompt'
// Mirrors FULL_FIRST_USER_PROMPT_SAFETY_LIMIT in the module under test.
const SAFETY_LIMIT = 256 * 1024
describe('AI Vault full first-user-prompt normalization', () => {
it('drops an astral char straddling the safety limit instead of splitting it', () => {
const result = normalizeFullFirstUserPromptText(`${'a'.repeat(SAFETY_LIMIT - 1)}😀tail`)
expect(result).toHaveLength(SAFETY_LIMIT - 1)
expect(result?.endsWith('a')).toBe(true)
expect(hasUnpairedSurrogate(result ?? '')).toBe(false)
})
it('keeps a prompt shorter than the safety limit intact', () => {
expect(normalizeFullFirstUserPromptText('ship it 😀')).toBe('ship it 😀')
})
})
function hasUnpairedSurrogate(value: string): boolean {
for (let index = 0; index < value.length; index += 1) {
const code = value.charCodeAt(index)
const isHigh = code >= 0xd800 && code <= 0xdbff
const isLow = code >= 0xdc00 && code <= 0xdfff
if (isHigh) {
const next = value.charCodeAt(index + 1)
if (!(next >= 0xdc00 && next <= 0xdfff)) {
return true
}
index += 1
continue
}
if (isLow) {
return true
}
}
return false
}

View File

@ -0,0 +1,104 @@
import { isKnownHarnessInjectedUserTurnText } from '../../shared/harness-injected-user-turns'
import { getFirstUserPromptCaptureMode } from './session-scanner-first-user-prompt-capture'
import { stripGrokUserQueryEnvelope } from './session-scanner-grok-user-text'
// Direct import: session-scanner-values re-exports this module, so going through
// it here would close an import cycle.
import { sliceAtCodeUnitLimit } from './session-scanner-text-normalization'
// Why: safety only for pathological multi-MB pastes. Copy path must not use the
// 220-char list preview cap.
const FULL_FIRST_USER_PROMPT_SAFETY_LIMIT = 256 * 1024
// Codex uses input_text; most others use text. Never treat tool/image blocks as
// the written first ask.
const TEXT_LIKE_BLOCK_TYPES = new Set(['text', 'input_text', 'output_text'])
/** True only while an on-demand first-prompt read is re-parsing one transcript. */
export function shouldCaptureFullFirstUserPrompt(): boolean {
return getFirstUserPromptCaptureMode() === 'full'
}
/**
* Extract the written first-user ask for copy/reuse. Preserves newlines and does
* not apply list-preview caps. Returns null for non-text / harness / empty.
*/
export function extractFullFirstUserPromptText(value: unknown): string | null {
if (typeof value === 'string') {
return finalizeFullFirstUserPrompt(value)
}
// Single content block object (not wrapped in an array).
if (value && typeof value === 'object' && !Array.isArray(value)) {
const blockText = firstUserPromptContentItemText(value)
return blockText != null ? finalizeFullFirstUserPrompt(blockText) : null
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
const text = firstUserPromptContentItemText(item)
if (text != null) {
parts.push(text)
}
}
if (parts.length === 0) {
return null
}
return finalizeFullFirstUserPrompt(parts.join('\n'))
}
export function normalizeFullFirstUserPromptText(value: string): string | null {
return finalizeFullFirstUserPrompt(value)
}
function finalizeFullFirstUserPrompt(value: string): string | null {
// Why: Grok (and some pasted transcripts) wrap the real ask in <user_query>;
// strip that before copy so the clipboard is the typed prompt, not user_info.
const unwrapped = stripGrokUserQueryEnvelope(value.replace(/^\uFEFF/, ''))
const trimmed = unwrapped.trim()
if (!trimmed) {
return null
}
if (isSuppressedFullFirstUserPrompt(trimmed)) {
return null
}
if (isKnownHarnessInjectedUserTurnText(trimmed)) {
return null
}
// Reject pure Grok bootstrap dumps even when they arrived via a non-Grok path.
const lower = trimmed.toLowerCase()
if (lower.startsWith('<user_info>') && !lower.includes('<user_query>')) {
return null
}
return sliceAtCodeUnitLimit(trimmed, FULL_FIRST_USER_PROMPT_SAFETY_LIMIT)
}
function isSuppressedFullFirstUserPrompt(value: string): boolean {
const head = value.slice(0, 64).toLowerCase()
return head.startsWith('# agents.md instructions') || head.startsWith('<instructions>')
}
function firstUserPromptContentItemText(item: unknown): string | null {
if (typeof item === 'string') {
return item
}
if (!item || typeof item !== 'object' || Array.isArray(item)) {
return null
}
const record = item as Record<string, unknown>
const type = typeof record.type === 'string' ? record.type : null
if (type != null && !TEXT_LIKE_BLOCK_TYPES.has(type)) {
return null
}
if (typeof record.text === 'string' && record.text.length > 0) {
return record.text
}
// Some providers put the body on `content` for text-shaped blocks.
if (typeof record.content === 'string' && record.content.length > 0) {
return record.content
}
return null
}

View File

@ -1,5 +1,16 @@
import { describe, expect, it, vi } from 'vitest'
import { extractGrokContentText } from './session-scanner-grok-parser'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import { extractGrokContentText, parseGrokSessionFile } from './session-scanner-grok-parser'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('AI Vault Grok session parser', () => {
it('extracts bounded user_query text without trimming the full body', () => {
@ -8,23 +19,94 @@ describe('AI Vault Grok session parser', () => {
`<USER_INFO>context</USER_INFO><USER_QUERY>\n${'Grok prompt '.repeat(400)}</USER_QUERY>`
)
const trimCalls = trimSpy.mock.calls.length
trimSpy.mockRestore()
expect(trimCalls).toBe(0)
// stripGrokUserQueryEnvelope trims the body once; the fold must never trim
// per character (the input here is 4800+ chars).
expect(trimCalls).toBeGreaterThan(0)
expect(trimCalls).toBeLessThan(20)
expect(result?.startsWith('Grok prompt Grok prompt')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
expect(result).not.toContain('USER_QUERY')
expect(result).not.toContain('USER_INFO')
})
it('folds Grok array content without joining all text parts', () => {
const joinSpy = vi.spyOn(Array.prototype, 'join')
const result = extractGrokContentText([
{ type: 'text', text: 'Grok array '.repeat(80) },
{ type: 'text', text: 'tail' }
])
const joinCalls = joinSpy.mock.calls.length
expect(joinCalls).toBe(0)
expect(result?.startsWith('Grok array Grok array')).toBe(true)
expect(result?.endsWith('...')).toBe(true)
})
it('drops an astral char straddling the preview scan cap instead of splitting it', () => {
// Hidden context is skipped by the fold, so the 4096-code-unit scan cap lands
// mid-emoji while the visible text stays well under the 220-char preview cap.
const hidden = `<system-reminder>${'x'.repeat(4057)}</system-reminder>`
const result = extractGrokContentText(`${hidden}ask😀tail`)
expect(hidden).toHaveLength(4092)
expect(result).toBe('ask')
})
it('stores the unwrapped user_query as firstUserPrompt under full capture', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-grok-first-'))
tempRoots.push(root)
const sessionDir = join(root, 'session-1')
await mkdir(sessionDir, { recursive: true })
const summaryPath = join(sessionDir, 'summary.json')
await writeFile(
summaryPath,
JSON.stringify({
info: { id: 'session-1', cwd: '/repo/app' },
generated_title: 'Grok title',
created_at: '2026-05-01T10:00:00.000Z',
updated_at: '2026-05-01T10:05:00.000Z'
})
)
const realAsk = 'fix i18n keep ko workspace worktree and primary'
await writeFile(
join(sessionDir, 'chat_history.jsonl'),
[
JSON.stringify({
type: 'user',
content: [
{
type: 'text',
text: [
'<user_info>',
'OS Version: macos',
'Shell: /opt/homebrew/bin/bash',
'Workspace Path: /Users/ada/repo',
"Today's date: 2026-08-01",
'Note: Prefer using relative paths over absolute paths as tool call args when possible.',
'</user_info>',
`<user_query>\n${realAsk}\n</user_query>`
].join('\n')
}
],
timestamp: '2026-05-01T10:00:01.000Z'
}),
JSON.stringify({
type: 'assistant',
content: 'On it.',
timestamp: '2026-05-01T10:00:02.000Z'
})
].join('\n')
)
const session = await withFullFirstUserPromptCapture(() =>
parseGrokSessionFile({
path: summaryPath,
mtimeMs: Date.now(),
modifiedAt: new Date().toISOString()
})
)
expect(session?.firstUserPrompt).toBe(realAsk)
expect(session?.firstUserPrompt).not.toContain('user_info')
expect(session?.firstUserPrompt).not.toContain('OS Version')
})
})

View File

@ -11,6 +11,14 @@ import {
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import {
extractGrokFirstUserPromptText,
stripGrokUserQueryEnvelope
} from './session-scanner-grok-user-text'
import {
asRecord,
extractPreviewContentText,
@ -18,7 +26,8 @@ import {
normalizePreviewText,
normalizeTitleText,
numberValue,
parseJsonObject
parseJsonObject,
sliceAtCodeUnitLimit
} from './session-scanner-values'
const GROK_USER_QUERY_PREVIEW_SCAN_LIMIT = 4096
@ -68,14 +77,38 @@ async function consumeGrokChatHistory(
if (role !== 'user' && role !== 'assistant') {
continue
}
const text = extractGrokContentText(record.content)
if (role === 'user') {
accumulator.title ??= normalizeTitleText(text ?? '')
// Why: first-prompt copy must be the typed ask inside <user_query>, never
// the injected <user_info> bootstrap row.
const firstPromptBody = extractGrokFirstUserPromptText(record.content)
const text = firstPromptBody
? normalizePreviewText(capGrokPreviewSource(firstPromptBody))
: null
if (firstPromptBody) {
accumulator.title ??= normalizeTitleText(firstPromptBody)
if (shouldCaptureFullFirstUserPrompt() && !accumulator.firstUserPrompt) {
accumulator.firstUserPrompt = normalizeFullFirstUserPromptText(firstPromptBody)
}
}
if (text) {
addPreviewMessage(accumulator, {
role: 'user',
text,
timestamp: extractString(record.timestamp),
seedFirstUserPrompt: false
})
}
continue
}
addPreviewMessage(accumulator, {
role,
text,
timestamp: extractString(record.timestamp)
role: 'assistant',
text: extractGrokContentText(record.content),
timestamp: extractString(record.timestamp),
seedFirstUserPrompt: false
})
}
} catch {
@ -90,45 +123,11 @@ export function extractGrokContentText(value: unknown): string | null {
return extractPreviewContentText(value)
}
function capGrokPreviewSource(text: string): string {
return sliceAtCodeUnitLimit(text, GROK_USER_QUERY_PREVIEW_SCAN_LIMIT)
}
function extractGrokStringContentText(text: string): string | null {
const bounds = grokUserQueryEnvelopeBounds(text)
if (!bounds) {
return normalizePreviewText(text)
}
const boundedEnd = Math.min(bounds.end, bounds.start + GROK_USER_QUERY_PREVIEW_SCAN_LIMIT)
return normalizePreviewText(text.slice(bounds.start, boundedEnd)) ?? normalizePreviewText(text)
}
function grokUserQueryEnvelopeBounds(text: string): { start: number; end: number } | null {
const opener = '<user_query>'
const startIndex = indexOfAsciiIgnoreCase(text, opener, 0)
if (startIndex === -1) {
return null
}
const bodyStartIndex = startIndex + opener.length
const endIndex = indexOfAsciiIgnoreCase(text, '</user_query>', bodyStartIndex)
if (endIndex === -1) {
return null
}
return { start: bodyStartIndex, end: endIndex }
}
function indexOfAsciiIgnoreCase(value: string, search: string, fromIndex: number): number {
const lastStart = value.length - search.length
for (let index = Math.max(0, fromIndex); index <= lastStart; index++) {
let matches = true
for (let offset = 0; offset < search.length; offset++) {
const code = value.charCodeAt(index + offset)
const normalizedCode = code >= 65 && code <= 90 ? code + 32 : code
if (normalizedCode !== search.charCodeAt(offset)) {
matches = false
break
}
}
if (matches) {
return index
}
}
return -1
const unwrapped = stripGrokUserQueryEnvelope(text)
return normalizePreviewText(capGrokPreviewSource(unwrapped))
}

View File

@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest'
import {
extractGrokFirstUserPromptText,
isGrokBootstrapContextText,
stripGrokUserQueryEnvelope
} from './session-scanner-grok-user-text'
describe('Grok first-user prompt text', () => {
it('unwraps user_query and drops the user_info bootstrap envelope', () => {
const raw = [
'<user_info>',
'OS Version: macos',
'Shell: /opt/homebrew/bin/bash',
'Workspace Path: /Users/ada/repo',
"Today's date: 2026-08-01",
'Note: Prefer using relative paths',
'</user_info>',
'<user_query>',
'fix i18n keep ko workspace worktree and primary',
'</user_query>'
].join('\n')
expect(extractGrokFirstUserPromptText(raw)).toBe(
'fix i18n keep ko workspace worktree and primary'
)
expect(stripGrokUserQueryEnvelope(raw)).toBe('fix i18n keep ko workspace worktree and primary')
})
it('unwraps user_query even when the closing tag is missing', () => {
const raw = '<user_info>context</user_info><user_query>\nShip the full first prompt copy path'
expect(extractGrokFirstUserPromptText(raw)).toBe('Ship the full first prompt copy path')
})
it('rejects pure user_info bootstrap rows', () => {
const bootstrap = [
'<user_info>',
'OS Version: macos',
'Note: Prefer using relative paths over absolute paths',
'</user_info>'
].join('\n')
expect(isGrokBootstrapContextText(bootstrap)).toBe(true)
expect(extractGrokFirstUserPromptText(bootstrap)).toBeNull()
})
it('keeps ordinary user prompts', () => {
expect(extractGrokFirstUserPromptText('fix the flaky vault tests')).toBe(
'fix the flaky vault tests'
)
})
})

View File

@ -0,0 +1,104 @@
// Shared Grok user-turn text helpers for AI Vault (list preview + full first-prompt
// copy). Kept out of the native-chat decoder so vault scanners stay free of that
// dependency while matching its bootstrap / user_query rules.
/**
* Prefer the body of a Grok `<user_query>` envelope when present (closing tag
* optional). Bootstrap `<user_info>`-only rows return null so a later real ask
* can seed the first prompt.
*/
export function extractGrokFirstUserPromptText(value: unknown): string | null {
const raw = flattenGrokUserContent(value)
if (!raw) {
return null
}
if (isGrokBootstrapContextText(raw)) {
return null
}
const unwrapped = stripGrokUserQueryEnvelope(raw)
const trimmed = unwrapped.trim()
if (!trimmed || isGrokBootstrapContextText(trimmed)) {
return null
}
// Why: if the turn is still just a user_info dump (no query envelope), it is
// not the prompt the user typed and must not be copied as "first prompt".
if (
startsWithIgnoreCaseTag(trimmed, 'user_info') &&
!containsIgnoreCaseTag(trimmed, 'user_query')
) {
return null
}
return trimmed
}
function flattenGrokUserContent(value: unknown): string | null {
if (typeof value === 'string') {
return value
}
if (!Array.isArray(value)) {
return null
}
const parts: string[] = []
for (const item of value) {
if (typeof item === 'string') {
parts.push(item)
continue
}
if (!item || typeof item !== 'object') {
continue
}
const record = item as Record<string, unknown>
if (typeof record.type === 'string' && record.type !== 'text') {
continue
}
if (typeof record.text === 'string') {
parts.push(record.text)
}
}
return parts.length > 0 ? parts.join('\n') : null
}
export function stripGrokUserQueryEnvelope(text: string): string {
const opener = '<user_query>'
const closer = '</user_query>'
const lower = text.toLowerCase()
const start = lower.indexOf(opener)
if (start === -1) {
return text
}
const bodyStart = start + opener.length
const end = lower.indexOf(closer, bodyStart)
// Why: incomplete closing tag still holds the real ask after the opener.
if (end === -1) {
return text.slice(bodyStart).trim()
}
return text.slice(bodyStart, end).trim()
}
export function isGrokBootstrapContextText(text: string): boolean {
const normalized = text.trim().toLowerCase()
if (!normalized.startsWith('<user_info>')) {
return false
}
const userInfoEnd = normalized.indexOf('</user_info>')
if (userInfoEnd === -1) {
// Open-ended user_info dump with no query: treat as bootstrap noise.
return !normalized.includes('<user_query>')
}
const remainder = normalized.slice(userInfoEnd + '</user_info>'.length).trim()
// Why: Grok appends a git snapshot to the bootstrap row; reject that envelope
// so real prompts mentioning either tag still count as user asks.
return (
remainder.length === 0 ||
(remainder.startsWith('<git_status>') && remainder.endsWith('</git_status>'))
)
}
function startsWithIgnoreCaseTag(text: string, tagName: string): boolean {
const lower = text.trimStart().toLowerCase()
return lower.startsWith(`<${tagName}>`) || lower.startsWith(`<${tagName} `)
}
function containsIgnoreCaseTag(text: string, tagName: string): boolean {
return text.toLowerCase().includes(`<${tagName}>`)
}

View File

@ -134,5 +134,7 @@ describe('scanAiVaultSessions harness-injected title seeding', () => {
expect(result.issues).toEqual([])
expect(result.sessions[0]?.lastUserPrompt).toBe('Fix the zoom behavior in a separate PR')
// Meta skill preamble must not become the copyable first prompt.
expect(result.sessions[0]?.firstUserPrompt).toBeUndefined()
})
})

View File

@ -6,6 +6,7 @@ import Database from '../sqlite/sync-database'
import { buildOpenCodeSqliteCandidatePath } from './session-scanner-opencode-sqlite-paths'
import { listOpenCodeSqliteSessions } from './session-scanner-opencode-sqlite-discovery'
import { parseOpenCodeSqliteSession } from './session-scanner-opencode-sqlite'
import { withFullFirstUserPromptCapture } from './session-scanner-first-user-prompt-capture'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
let tempDirs: string[] = []
@ -496,4 +497,107 @@ describe('parseOpenCodeSqliteSession', () => {
expect(session).not.toBeNull()
expect(session!.model).toBe('claude-sonnet-4-5')
})
it('captures every text part of the earliest user message and no later turn', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_fp',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertPart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 10,
text: 'first ask line one'
})
insertPart(db, {
id: 'part_1b',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 20,
text: 'first ask line two'
})
// Non-text parts of the same message must not leak into the copied prompt.
insertPart(db, {
id: 'part_1c',
messageId: 'msg_1',
sessionId: 'ses_fp',
timeCreated: 30,
type: 'tool',
text: 'tool output blob'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertPart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp',
timeCreated: 40,
text: 'a later ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('first ask line one\nfirst ask line two')
})
it('skips an earliest user message that has no text parts', async () => {
const { db, path } = createTempDb()
applyOpenCodeSchema(db)
insertSession(db, {
id: 'ses_fp2',
timeCreated: 1_777_634_000_000,
timeUpdated: 1_777_634_900_000
})
insertMessage(db, {
id: 'msg_1',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_000_000
})
insertPart(db, {
id: 'part_1a',
messageId: 'msg_1',
sessionId: 'ses_fp2',
timeCreated: 10,
type: 'tool',
text: 'tool only'
})
insertMessage(db, {
id: 'msg_2',
sessionId: 'ses_fp2',
role: 'user',
timeCreated: 1_777_634_500_000
})
insertPart(db, {
id: 'part_2a',
messageId: 'msg_2',
sessionId: 'ses_fp2',
timeCreated: 40,
text: 'the real typed ask'
})
db.close()
const session = await withFullFirstUserPromptCapture(() =>
parseOpenCodeSqliteSession({ dbPath: path, sessionId: 'ses_fp2', platform: 'darwin' })
)
expect(session!.firstUserPrompt).toBe('the real typed ask')
})
})

View File

@ -5,6 +5,10 @@ import {
finalizeSession,
updateTimeline
} from './session-scanner-accumulator'
import {
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
import { normalizeTitleText } from './session-scanner-values'
import SyncDatabase from '../sqlite/sync-database'
import { columnExists, tableExists } from '../opencode-usage/schema-helpers'
@ -22,6 +26,8 @@ const OPENCODE_SQLITE_PREVIEW_LIMIT = 5
// id) index. Bounds the read to those messages' parts; the 15 s parse timeout
// caps the residual for a single pathological giant part.
const OPENCODE_SQLITE_PREVIEW_MESSAGE_WINDOW = 100
// Bounds a pathological single message; a real typed prompt is a handful of parts.
const FIRST_USER_PROMPT_PART_LIMIT = 512
type SessionRow = {
id: string
@ -154,6 +160,58 @@ function extractPartText(partData: string): string | null {
}
}
function readFirstUserPromptFromOpenCodeDb(db: SyncDatabase, sessionId: string): string | null {
if (
!canCountOpenCodeMessages(db) ||
!tableExists(db, 'part') ||
!columnExists(db, 'message', 'id') ||
!columnExists(db, 'part', 'message_id') ||
!columnExists(db, 'part', 'time_created') ||
!columnExists(db, 'part', 'data')
) {
return null
}
try {
// Why: pin to the single earliest user message that actually has text parts,
// then take all of its parts. Ordering parts across every user message would
// pad a short first prompt with later turns and truncate a long one.
const rows = db
.prepare(
`SELECT p.data AS part_data
FROM part p
WHERE p.message_id = (
SELECT m.id
FROM message m
JOIN part fp ON fp.message_id = m.id
WHERE m.session_id = ?
AND json_extract(m.data, '$.role') = 'user'
AND json_extract(fp.data, '$.type') = 'text'
ORDER BY m.time_created ASC, m.id ASC
LIMIT 1
)
AND json_extract(p.data, '$.type') = 'text'
ORDER BY p.time_created ASC, p.rowid ASC
LIMIT ${FIRST_USER_PROMPT_PART_LIMIT}`
)
.all(sessionId) as { part_data: string }[]
const parts: string[] = []
for (const row of rows) {
const text = extractPartText(row.part_data)
if (text) {
parts.push(text)
}
}
if (parts.length === 0) {
return null
}
return normalizeFullFirstUserPromptText(parts.join('\n'))
} catch {
return null
}
}
function buildPreviewQuery(db: SyncDatabase): string | null {
if (
!canCountOpenCodeMessages(db) ||
@ -254,7 +312,9 @@ export async function parseOpenCodeSqliteSession(args: {
addPreviewMessage(accumulator, {
role: mapPreviewRole(previewRow.role),
text,
timestamp: previewRow.time_created
timestamp: previewRow.time_created,
// Preview window is newest-N; first-prompt is loaded separately below.
seedFirstUserPrompt: false
})
if (previewRow.role === 'user' && !accumulator.title) {
accumulator.title =
@ -264,6 +324,12 @@ export async function parseOpenCodeSqliteSession(args: {
}
}
// Why: list preview only joins the newest messages. On-demand copy needs the
// session's earliest real user text part, not a later turn still in the window.
if (shouldCaptureFullFirstUserPrompt()) {
accumulator.firstUserPrompt = readFirstUserPromptFromOpenCodeDb(db, sessionId)
}
return finalizeSession(accumulator, platform)
} finally {
db?.close()

View File

@ -127,13 +127,17 @@ export function consumeClaudeSessionLine(state: ClaudeSessionParseState, line: s
if (record.type === 'user') {
accumulator.messageCount++
const title = extractMessageText(record.message)
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp)
// Meta prompts (injected context) only seed the last-resort title. Some
// injected turns (task notifications) carry no isMeta, so also gate on
// the known-tag classifier — a real prompt pasting a custom `<my-element>`
// must seed the primary title, not be demoted as machinery.
const isMetaUserTurn =
record.isMeta === true || (title != null && isKnownHarnessInjectedUserTurnText(title))
addPreviewContent(accumulator, 'user', asRecord(record.message)?.content, record.timestamp, {
seedFirstUserPrompt: !isMetaUserTurn
})
if (title) {
// Meta prompts (injected context) only seed the last-resort title. Some
// injected turns (task notifications) carry no isMeta, so also gate on
// the known-tag classifier — a real prompt pasting a custom `<my-element>`
// must seed the primary title, not be demoted as machinery.
if (record.isMeta === true || isKnownHarnessInjectedUserTurnText(title)) {
if (isMetaUserTurn) {
state.metaTitle ??= title
} else {
state.firstUserTitle ??= title

View File

@ -18,13 +18,13 @@ import {
sessionIdFromFileName,
updateTimeline
} from './session-scanner-accumulator'
import { extractFullFirstUserPromptText } from './session-scanner-first-user-prompt'
import {
arrayValue,
asRecord,
copilotModelMetricsTotal,
extractContentText,
extractMessageText,
extractPreviewContentText,
extractString,
extractTrustedFolder,
findOpenCodeStorageRoot,
@ -253,10 +253,12 @@ export async function consumeOpenCodeMessages(
accumulator.title ??= extractString(asRecord(message.summary)?.title)
accumulator.title ??= extractString(asRecord(message.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).
addPreviewMessage(accumulator, {
role,
text:
extractPreviewContentText(message.content) ??
extractFullFirstUserPromptText(message.content) ??
extractString(asRecord(message.summary)?.body) ??
extractString(asRecord(message.summary)?.title),
timestamp: timeObjectValue(message.time, 'created')

View File

@ -42,6 +42,15 @@ export function normalizePreviewText(value: string): string | null {
return finalizeNormalizedText(normalizeStringText(value, SESSION_PREVIEW_TEXT_LIMIT))
}
/** Cut to `limit` UTF-16 code units without splitting a trailing surrogate pair. */
export function sliceAtCodeUnitLimit(value: string, limit: number): string {
if (value.length <= limit) {
return value
}
const end = limit > 0 && isHighSurrogate(value.charCodeAt(limit - 1)) ? limit - 1 : limit
return value.slice(0, end)
}
function normalizeContentText(value: unknown, limit: number): string | null {
if (typeof value === 'string') {
return finalizeNormalizedText(normalizeStringText(value, limit))
@ -112,15 +121,18 @@ function appendInterPartSpace(builder: TextBuilder): void {
}
}
function appendNormalizedString(builder: TextBuilder, value: string): void {
function appendNormalizedString(builder: TextBuilder, value: string, maxScanLength?: number): void {
const scanEnd = maxScanLength == null ? value.length : Math.min(value.length, maxScanLength)
let index = 0
while (index < value.length && !builder.truncated) {
while (index < scanEnd && !builder.truncated) {
const hiddenBlockEnd = hiddenTextBlockEnd(value, index)
if (hiddenBlockEnd !== null) {
if (builder.text.length > 0) {
builder.pendingSpace = true
}
index = hiddenBlockEnd
// Why: hidden blocks may jump past the scan budget; clamp so multi-MB
// suppressed context cannot keep the first-prompt path busy.
index = Math.min(hiddenBlockEnd, scanEnd)
continue
}
@ -142,9 +154,17 @@ function appendNormalizedString(builder: TextBuilder, value: string): void {
}
const charLength = codePointLength(value, index)
// Why: do not read past scanEnd mid code-point when the budget lands inside
// a surrogate pair — drop the incomplete char instead.
if (index + charLength > scanEnd) {
break
}
appendVisibleText(builder, value.slice(index, index + charLength))
index += charLength
}
if (!builder.truncated && scanEnd < value.length && builder.text.length > 0) {
builder.truncated = true
}
}
function appendVisibleText(builder: TextBuilder, value: string): void {
@ -230,9 +250,7 @@ function isSuppressedContextPrefix(value: string): boolean {
}
function truncateWithEllipsis(value: string, limit: number): string {
const end = Math.max(0, limit - ELLIPSIS.length)
const safeEnd = end > 0 && isHighSurrogate(value.charCodeAt(end - 1)) ? end - 1 : end
return `${value.slice(0, safeEnd)}${ELLIPSIS}`
return `${sliceAtCodeUnitLimit(value, Math.max(0, limit - ELLIPSIS.length))}${ELLIPSIS}`
}
function objectRecord(value: unknown): Record<string, unknown> | null {

View File

@ -110,6 +110,7 @@ export type SessionAccumulator = {
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
firstUserPrompt: string | null
lastUserPrompt: string | null
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
queuedMessageCount: number

View File

@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import {
extractFullFirstUserPromptText,
extractPreviewContentText,
normalizeAgentSessionsDir,
normalizeFullFirstUserPromptText,
normalizePreviewText,
normalizeTitleText
} from './session-scanner-values'
@ -53,6 +55,23 @@ describe('AI Vault session scanner text values', () => {
expect(result).toBe(`${'a'.repeat(216)}...`)
})
it('preserves full first-prompt text including newlines for copy', () => {
const body = `First prompt line one\n\nline two ${'word '.repeat(100)}`
expect(normalizeFullFirstUserPromptText(body)).toBe(body.trim())
expect(extractFullFirstUserPromptText([{ type: 'text', text: body }])).toBe(body.trim())
})
it('reads Codex input_text blocks and ignores tool blocks', () => {
const body = `Review the PR\n\n${'detail '.repeat(50).trimEnd()}`
expect(extractFullFirstUserPromptText([{ type: 'input_text', text: body }])).toBe(body)
expect(
extractFullFirstUserPromptText([
{ type: 'tool_result', content: 'src/main/window.ts was updated' },
{ type: 'text', text: 'Please continue the editor refactor' }
])
).toBe('Please continue the editor refactor')
})
it('expands Pi and OMP agent homes to their session directories', () => {
expect(normalizeAgentSessionsDir('/agents/.pi', '.pi')).toBe('/agents/.pi/agent/sessions')
expect(normalizeAgentSessionsDir('/agents/.pi/agent', '.pi')).toBe('/agents/.pi/agent/sessions')

View File

@ -55,8 +55,14 @@ export {
extractMessageText,
extractPreviewContentText,
normalizePreviewText,
normalizeTitleText
normalizeTitleText,
sliceAtCodeUnitLimit
} from './session-scanner-text-normalization'
export {
extractFullFirstUserPromptText,
normalizeFullFirstUserPromptText,
shouldCaptureFullFirstUserPrompt
} from './session-scanner-first-user-prompt'
export function extractGitBranch(value: unknown): string | null {
const git = asRecord(value)

View File

@ -174,6 +174,8 @@ describe('scanAiVaultSessions', () => {
totalTokens: 155,
resumeCommand: "cd '/repo/app' && claude --resume 'claude-session'"
})
// Why: list scans omit firstUserPrompt so the vault payload stays bounded.
expect(claude?.firstUserPrompt).toBeUndefined()
const codex = result.sessions.find((session) => session.agent === 'codex')
expect(codex).toMatchObject({
@ -185,6 +187,7 @@ describe('scanAiVaultSessions', () => {
totalTokens: 625,
resumeCommand: `cd '/repo/app/packages/web' && CODEX_HOME='${root}' codex resume '019f0000-1111-7222-8333-444444444444'`
})
expect(codex?.firstUserPrompt).toBeUndefined()
})
it('indexes Codex sessions from Orca runtime homes with resumable commands', async () => {

View File

@ -13,11 +13,13 @@ import { claudeProjectsRootDirs } from '../ai-vault/session-scanner-source-disco
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import type {
AiVaultFirstUserPromptArgs,
AiVaultListArgs,
AiVaultListResult,
AiVaultSubagentListArgs,
AiVaultSubagentListResult
} from '../../shared/ai-vault-types'
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-read'
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
import {
LOCAL_EXECUTION_HOST_ID,
@ -286,6 +288,9 @@ export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): vo
(_event, args?: AiVaultSubagentListArgs): Promise<AiVaultSubagentListResult> =>
listAiVaultSubagentSessions(args)
)
ipcMain.handle('aiVault:getFirstUserPrompt', (_event, args?: AiVaultFirstUserPromptArgs) =>
handleAiVaultGetFirstUserPrompt(args)
)
// DOM focus/visibility events don't fire in the renderer on macOS app
// activation, so refresh-on-refocus needs this main-process signal.
app.on('browser-window-focus', (_event, window) => {

View File

@ -481,6 +481,8 @@ import type {
OpenCodeUsageSummary
} from '../shared/opencode-usage-types'
import type {
AiVaultFirstUserPromptArgs,
AiVaultFirstUserPromptResult,
AiVaultListArgs,
AiVaultListResult,
AiVaultSubagentListArgs,
@ -884,6 +886,8 @@ export type AiVaultApi = {
) => Promise<AiVaultPrepareSessionResumeResult>
/** Lists the Task subagent transcripts of one session, on demand. */
listSubagentSessions: (args: AiVaultSubagentListArgs) => Promise<AiVaultSubagentListResult>
/** Full first user prompt for copy/reuse (re-parses one transcript). */
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs) => Promise<AiVaultFirstUserPromptResult>
/** Fires when any app window regains OS focus; returns an unsubscribe. */
onWindowFocused: (callback: () => void) => () => void
}

View File

@ -230,7 +230,11 @@ import type {
AutomationUpdateInput
} from '../shared/automations-types'
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
import type { AiVaultListArgs, AiVaultSubagentListArgs } from '../shared/ai-vault-types'
import type {
AiVaultFirstUserPromptArgs,
AiVaultListArgs,
AiVaultSubagentListArgs
} from '../shared/ai-vault-types'
import type { AiVaultPrepareSessionResumeArgs } from '../shared/ai-vault-resume-preparation'
import type { AgentType } from '../shared/native-chat-types'
import {
@ -4163,6 +4167,8 @@ const api = {
ipcRenderer.invoke('aiVault:prepareSessionResume', args),
listSubagentSessions: (args: AiVaultSubagentListArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:listSubagentSessions', args),
getFirstUserPrompt: (args: AiVaultFirstUserPromptArgs): Promise<unknown> =>
ipcRenderer.invoke('aiVault:getFirstUserPrompt', args),
onWindowFocused: (callback: () => void): (() => void) => {
const listener = (_event: Electron.IpcRendererEvent) => callback()
ipcRenderer.on('aiVault:windowFocused', listener)

View File

@ -1,5 +1,12 @@
import type React from 'react'
import { FileJson, FolderGit2, MessageSquare, MessageSquarePlus, Play } from 'lucide-react'
import {
FileJson,
FolderGit2,
MessageSquare,
MessageSquarePlus,
Play,
TextCursorInput
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
@ -9,7 +16,8 @@ import {
type AiVaultSession
} from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { sessionDetailConversationTurns } from './ai-vault-session-display'
import { FirstPromptCard } from './ai-vault-first-prompt-card'
import { sessionDetailConversationTurns, sessionFirstPrompt } from './ai-vault-session-display'
import { SessionSubagentsSection } from './AiVaultSessionSubagents'
import { SessionUnsavedConversationNotice } from './AiVaultSessionUnsavedNotice'
import {
@ -51,6 +59,7 @@ export function SessionInlineDetails({
const showResumeInNewTab =
hasResumableContent &&
(!resumeActions.worktree.worktreeId || Boolean(resumeActions.newTab.worktreeId))
const firstPromptPreview = sessionFirstPrompt(session)
const detailTurns = sessionDetailConversationTurns(session, 3)
const worktreeDisplay = worktreeInfo
@ -66,78 +75,8 @@ export function SessionInlineDetails({
event.stopPropagation()
}}
>
<div className="space-y-3 p-3">
{hasResumableContent ? (
<SessionReceiptSection
icon={<MessageSquare className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
'Latest turns'
)}
>
{detailTurns.length > 0 ? (
<div className="space-y-1.5">
{detailTurns.map((turn, index) => (
<ConversationTurnCard
key={`${turn.timestamp ?? 'turn'}-${index}`}
role={turn.role}
text={turn.text}
/>
))}
</div>
) : (
<SessionDetailEmptyState
message={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
'No conversation preview available'
)}
/>
)}
</SessionReceiptSection>
) : (
// An unsaved session has no turns to show; the notice replaces the
// preview section instead of stacking a second empty state under it.
<SessionUnsavedConversationNotice session={session} logAvailable={Boolean(onOpenLog)} />
)}
<SessionSubagentsSection session={session} />
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, {
vaultScope
}) ? (
<SessionReceiptSection
icon={<FolderGit2 className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.worktree',
'Worktree'
)}
>
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} vaultScope={vaultScope} />
</SessionReceiptSection>
) : null}
</div>
{showResumeInWorktree || showResumeInNewTab || onContinueInNewSession || onOpenLog ? (
<div className="flex flex-wrap items-center gap-1.5 border-t border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{onContinueInNewSession ? (
<Button
type="button"
variant="secondary"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onContinueInNewSession()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<MessageSquarePlus className="size-3.5" />
{translate(
'components.agentSessionContinuation.continueInNewSession',
'Continue in New Session…'
)}
</Button>
) : null}
<div className="flex flex-wrap items-center gap-1.5 border-b border-sidebar-border/80 bg-sidebar-accent/15 px-3 py-2">
{showResumeInWorktree ? (
<Button
type="button"
@ -178,6 +117,25 @@ export function SessionInlineDetails({
)}
</Button>
) : null}
{onContinueInNewSession ? (
<Button
type="button"
variant="secondary"
size="xs"
draggable={false}
onClick={(event) => {
event.stopPropagation()
onContinueInNewSession()
}}
className="h-7 shrink-0 px-2.5 text-[11px]"
>
<MessageSquarePlus className="size-3.5" />
{translate(
'components.agentSessionContinuation.continueInNewSession',
'Continue in New Session…'
)}
</Button>
) : null}
{onOpenLog ? (
<Button
type="button"
@ -196,6 +154,72 @@ export function SessionInlineDetails({
) : null}
</div>
) : null}
<div className="space-y-3 p-3">
{hasResumableContent ? (
<>
<SessionReceiptSection
icon={<TextCursorInput className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPrompt',
'First prompt'
)}
>
<FirstPromptCard
key={session.id}
session={session}
previewText={firstPromptPreview ?? ''}
/>
</SessionReceiptSection>
<SessionReceiptSection
icon={<MessageSquare className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.latestTurns',
'Latest turns'
)}
>
{detailTurns.length > 0 ? (
<div className="space-y-1.5">
{detailTurns.map((turn) => (
<ConversationTurnCard
key={`${turn.role}:${turn.timestamp ?? ''}:${turn.text}`}
role={turn.role}
text={turn.text}
/>
))}
</div>
) : (
<SessionDetailEmptyState
message={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noPreviewAvailable',
'No conversation preview available'
)}
/>
)}
</SessionReceiptSection>
</>
) : (
// An unsaved session has no turns to show; the notice replaces the
// preview section instead of stacking a second empty state under it.
<SessionUnsavedConversationNotice session={session} logAvailable={Boolean(onOpenLog)} />
)}
<SessionSubagentsSection session={session} />
{shouldShowAiVaultSessionWorktreeLine(worktreeDisplay, {
vaultScope
}) ? (
<SessionReceiptSection
icon={<FolderGit2 className="size-3" />}
label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.worktree',
'Worktree'
)}
>
<WorktreeMetadataLines worktreeInfo={worktreeDisplay} vaultScope={vaultScope} />
</SessionReceiptSection>
) : null}
</div>
</div>
)
}
@ -241,7 +265,7 @@ function ConversationTurnCard({
<div className="mb-1 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
{conversationRoleLabel(role)}
</div>
<p className="line-clamp-4 text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
<p className="line-clamp-4 select-text text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{text}
</p>
</div>
@ -313,77 +337,6 @@ function SessionDetailEmptyState({ message }: { message: string }): React.JSX.El
)
}
export function SessionTime({
value,
className
}: {
value: string
className?: string
}): React.JSX.Element {
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp)) {
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime',
'Unknown time'
)}
</span>
)
}
const date = new Date(timestamp)
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
<time dateTime={date.toISOString()}>{formatTimeAgo(timestamp)}</time>
</span>
)
}
function formatTimeAgo(timestamp: number): string {
const diffMs = Date.now() - timestamp
if (diffMs < 60_000) {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now')
}
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 60) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo',
'{{value0}}m ago',
{ value0: minutes }
)
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo',
'{{value0}}h ago',
{ value0: hours }
)
}
const days = Math.floor(hours / 24)
if (days < 30) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo',
'{{value0}}d ago',
{ value0: days }
)
}
const months = Math.floor(days / 30)
if (months < 12) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo',
'{{value0}}mo ago',
{ value0: months }
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo',
'{{value0}}y ago',
{ value0: Math.floor(months / 12) }
)
}
function conversationRoleLabel(role: AiVaultSession['previewMessages'][number]['role']): string {
if (role === 'user') {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')

View File

@ -87,11 +87,6 @@ export function VaultSessionRow({
const startResumeDrag = useCallback(
(event: React.DragEvent<HTMLElement>): void => {
event.stopPropagation()
const target = event.target
if (target instanceof Element && target.closest('[data-ai-vault-session-actions]')) {
event.preventDefault()
return
}
if (resumeDisabled) {
event.preventDefault()
return
@ -122,27 +117,35 @@ export function VaultSessionRow({
<ContextMenuTrigger asChild className="block w-full min-w-0">
<div
className={cn(
'group/session-row flex w-full min-w-0 flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
resumeDisabled ? 'cursor-pointer' : 'cursor-grab active:cursor-grabbing',
'group/session-row flex w-full min-w-0 cursor-pointer flex-col border-b border-sidebar-border px-3 py-2 text-left transition-colors hover:bg-sidebar-accent/55',
!detailsExpanded && 'min-h-[98px]'
)}
// Why: users naturally drag the session row itself; matching that
// gesture avoids hidden affordances and text-selection false starts.
draggable={!resumeDisabled}
onClick={() => {
onToggleDetails()
}}
onDragStart={startResumeDrag}
onDragEnd={() => {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}}
>
<div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-1">
<div
className={cn(
'min-w-0 text-[13px] font-medium leading-5 text-foreground',
// Why: only the title is the resume drag handle — expanded
// details/preview need text selection and a normal pointer.
!resumeDisabled && 'cursor-grab active:cursor-grabbing',
detailsExpanded ? 'line-clamp-2 [overflow-wrap:anywhere]' : 'line-clamp-1'
)}
draggable={!resumeDisabled}
title={
resumeDisabled
? undefined
: translate(
'auto.components.right.sidebar.AiVaultSessionRow.dragToResume',
'Drag to resume in a new tab'
)
}
onDragStart={startResumeDrag}
onDragEnd={() => {
window.dispatchEvent(new Event(AI_VAULT_SESSION_DRAG_END_EVENT))
}}
>
{session.title}
</div>

View File

@ -0,0 +1,188 @@
import type React from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Check, Copy, LoaderCircle } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { translate } from '@/i18n/i18n'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
function canLoadFullFirstPrompt(
session: Pick<AiVaultSession, 'executionHostId' | 'filePath'>
): boolean {
return (
session.executionHostId === LOCAL_EXECUTION_HOST_ID &&
Boolean(session.filePath.trim()) &&
typeof window.api.aiVault.getFirstUserPrompt === 'function'
)
}
export function FirstPromptCard({
session,
previewText
}: {
session: AiVaultSession
/** Short preview from list scan; replaced by the full on-demand body when available. */
previewText: string
}): React.JSX.Element {
// Loading starts true when an on-demand re-parse is possible so the mount effect
// does not need a sync setState (react-doctor: no-adjust-state-on-prop-change).
const [fullText, setFullText] = useState<string | null>(null)
const [loading, setLoading] = useState(() => canLoadFullFirstPrompt(session))
const [copied, setCopied] = useState(false)
const [copying, setCopying] = useState(false)
const fullTextRef = useRef<string | null>(null)
const loadPromiseRef = useRef<Promise<string | null> | null>(null)
// Bumped on unmount/cleanup so a late response cannot write into a dead card.
const generationRef = useRef(0)
const { agent, codexHome, executionHostId, filePath, sessionId } = session
const loadFullPrompt = useCallback((): Promise<string | null> => {
if (fullTextRef.current != null) {
return Promise.resolve(fullTextRef.current)
}
if (loadPromiseRef.current) {
return loadPromiseRef.current
}
if (!canLoadFullFirstPrompt({ executionHostId, filePath })) {
return Promise.resolve(null)
}
const getFirstUserPrompt = window.api.aiVault.getFirstUserPrompt
const generation = generationRef.current
const isStale = (): boolean => generationRef.current !== generation
const promise = getFirstUserPrompt({
agent,
filePath,
sessionId,
executionHostId,
codexHome
})
.then((result) => {
if (isStale()) {
return null
}
const prompt = result.prompt?.trim() || null
fullTextRef.current = prompt
setFullText(prompt)
return prompt
})
.catch(() => {
if (isStale()) {
return null
}
fullTextRef.current = null
setFullText(null)
return null
})
.finally(() => {
// A stale settle must not clear the live request's dedupe handle.
if (isStale()) {
return
}
setLoading(false)
loadPromiseRef.current = null
})
loadPromiseRef.current = promise
return promise
}, [agent, codexHome, executionHostId, filePath, sessionId])
// Why: list rows never carry the full first prompt (payload/perf). Load the
// untruncated body once when this details card mounts. Parent keys this card by
// session.id so session switches remount with fresh state.
useEffect(() => {
void loadFullPrompt()
return () => {
generationRef.current += 1
}
}, [loadFullPrompt])
const displayText = (fullText ?? previewText).trim()
const showEmpty = !loading && !displayText
const copyFirstPrompt = (): void => {
setCopying(true)
// Why: never copy the 220-char list preview when the full body is still
// in flight — wait for the on-demand re-parse, then fall back only if null.
void loadFullPrompt()
.then((loaded) => {
const copyText = (loaded ?? previewText).trim()
if (!copyText) {
return
}
return window.api.ui.writeClipboardText(copyText).then(() => {
setCopied(true)
toast.success(
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.firstPromptCopied',
'First prompt copied'
)
)
window.setTimeout(() => {
setCopied(false)
}, 1400)
})
})
.catch(() => {
// Clipboard / load failures leave the button idle so the user can retry.
})
.finally(() => {
setCopying(false)
})
}
return (
<div className="rounded-md border border-border/70 bg-foreground/[0.04] px-2.5 py-2">
<div className="mb-1 flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5 text-[10px] font-semibold uppercase tracking-[0.05em] text-muted-foreground">
<span>
{translate('auto.components.right.sidebar.AiVaultSessionDetails.userRole', 'You')}
</span>
{loading || copying ? (
<LoaderCircle className="size-3 shrink-0 animate-spin text-muted-foreground/70" />
) : null}
</div>
<Button
type="button"
variant="ghost"
size="xs"
draggable={false}
disabled={copying || (!displayText && !loading)}
onClick={(event) => {
event.stopPropagation()
copyFirstPrompt()
}}
className="h-6 shrink-0 gap-1 px-1.5 text-[10px] text-muted-foreground"
aria-label={translate(
'auto.components.right.sidebar.AiVaultSessionDetails.copyFirstPrompt',
'Copy first prompt'
)}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
{copied
? translate('auto.components.right.sidebar.AiVaultSessionDetails.copied', 'Copied')
: translate('auto.components.right.sidebar.AiVaultSessionDetails.copy', 'Copy')}
</Button>
</div>
{showEmpty ? (
<p className="text-[11px] leading-4 text-muted-foreground">
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.noFirstPromptAvailable',
'No first prompt available'
)}
</p>
) : (
<p className="scrollbar-sleek max-h-48 select-text overflow-y-auto whitespace-pre-wrap text-[12px] leading-[1.35] text-foreground/90 [overflow-wrap:anywhere]">
{displayText ||
translate(
'auto.components.right.sidebar.AiVaultSessionDetails.loadingFirstPrompt',
'Loading first prompt…'
)}
</p>
)}
</div>
)
}

View File

@ -4,6 +4,7 @@ import {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionFirstPrompt,
sessionModelLabel,
sessionPreviewSearchText
} from './ai-vault-session-display'
@ -99,4 +100,27 @@ describe('ai vault session display', () => {
expect(sessionModelLabel(baseSession)).toBe('gpt-5.5')
expect(sessionModelLabel({ ...baseSession, model: null })).toBeNull()
})
it('prefers the stored firstUserPrompt over sliding preview turns', () => {
expect(
sessionFirstPrompt({
...baseSession,
firstUserPrompt: 'Original long first prompt that scrolled out of preview',
previewMessages: [
{ role: 'user', text: 'Later user turn still in the preview window', timestamp: null },
{ role: 'assistant', text: 'Later reply', timestamp: null }
]
})
).toBe('Original long first prompt that scrolled out of preview')
})
it('falls back to the earliest user preview turn when firstUserPrompt is absent', () => {
expect(sessionFirstPrompt(baseSession)).toBe('Please fix the flaky golden tests')
expect(
sessionFirstPrompt({
...baseSession,
previewMessages: [{ role: 'assistant', text: 'Only agent text', timestamp: null }]
})
).toBeNull()
})
})

View File

@ -5,6 +5,7 @@ export {
latestSessionConversationTurn,
recentSessionConversationTurns,
sessionDetailConversationTurns,
sessionFirstPrompt,
sessionModelLabel,
sessionPreviewSearchText
} from '../../../../shared/ai-vault-session-display'

View File

@ -13,7 +13,7 @@ import {
type AiVaultSession
} from '../../../../shared/ai-vault-types'
import { translate } from '@/i18n/i18n'
import { SessionTime } from './AiVaultSessionDetails'
import { SessionTime } from './ai-vault-session-time'
import { sessionModelLabel } from './ai-vault-session-display'
import { agentLabel } from './ai-vault-session-filters'
import {
@ -119,7 +119,7 @@ export function SessionWorktreeLine({
const repo = useRepoById(repoId)
return (
<div className="flex min-w-0 flex-wrap items-center gap-1.5 pl-5">
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{shouldShowAiVaultWorktreeStatusBadge(worktreeInfo.status, { vaultScope }) ? (
<span className="shrink-0 rounded-sm border border-sidebar-border bg-sidebar-accent/45 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{worktreeStatusLabel(worktreeInfo.status)}

View File

@ -0,0 +1,74 @@
import type React from 'react'
import { cn } from '@/lib/utils'
import { translate } from '@/i18n/i18n'
export function SessionTime({
value,
className
}: {
value: string
className?: string
}): React.JSX.Element {
const timestamp = Date.parse(value)
if (!Number.isFinite(timestamp)) {
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
{translate(
'auto.components.right.sidebar.AiVaultSessionDetails.unknownTime',
'Unknown time'
)}
</span>
)
}
const date = new Date(timestamp)
return (
<span className={cn('shrink-0 text-[11px] text-muted-foreground', className)}>
<time dateTime={date.toISOString()}>{formatTimeAgo(timestamp)}</time>
</span>
)
}
function formatTimeAgo(timestamp: number): string {
const diffMs = Date.now() - timestamp
if (diffMs < 60_000) {
return translate('auto.components.right.sidebar.AiVaultSessionDetails.justNow', 'Just now')
}
const minutes = Math.floor(diffMs / 60_000)
if (minutes < 60) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.minutesAgo',
'{{value0}}m ago',
{ value0: minutes }
)
}
const hours = Math.floor(minutes / 60)
if (hours < 24) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.hoursAgo',
'{{value0}}h ago',
{ value0: hours }
)
}
const days = Math.floor(hours / 24)
if (days < 30) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.daysAgo',
'{{value0}}d ago',
{ value0: days }
)
}
const months = Math.floor(days / 30)
if (months < 12) {
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.monthsAgo',
'{{value0}}mo ago',
{ value0: months }
)
}
return translate(
'auto.components.right.sidebar.AiVaultSessionDetails.yearsAgo',
'{{value0}}y ago',
{ value0: Math.floor(months / 12) }
)
}

View File

@ -11264,7 +11264,14 @@
"sessionRole": "Session",
"jumpToOriginalPane": "Jump to Original Pane",
"worktree": "Worktree",
"jumpToWorktree": "Jump to Worktree"
"jumpToWorktree": "Jump to Worktree",
"firstPrompt": "First prompt",
"firstPromptCopied": "First prompt copied",
"copyFirstPrompt": "Copy first prompt",
"copied": "Copied",
"copy": "Copy",
"noFirstPromptAvailable": "No first prompt available",
"loadingFirstPrompt": "Loading first prompt…"
},
"AiVaultSessionRow": {
"noPreviewAvailable": "No conversation preview available",

View File

@ -11188,7 +11188,14 @@
"recoverableEmptyOpenLogHint": "Abre el registro para recuperarlos.",
"emptyConversationDetail": "Esta sesión no tiene conversación guardada y no se puede reanudar.",
"queuedMessages": "{{value0}} mensaje(s) en cola",
"subagentTranscripts": "{{value0}} transcripción(es) de subagente"
"subagentTranscripts": "{{value0}} transcripción(es) de subagente",
"firstPrompt": "Primer prompt",
"firstPromptCopied": "Primer prompt copiado",
"copyFirstPrompt": "Copiar primer prompt",
"copied": "Copiado",
"copy": "Copiar",
"noFirstPromptAvailable": "No hay primer prompt disponible",
"loadingFirstPrompt": "Cargando primer prompt…"
},
"AiVaultSessionRow": {
"resumeAgentSession": "Reanudar sesión de {{value0}}",

View File

@ -11188,7 +11188,14 @@
"recoverableEmptyOpenLogHint": "ログを開いて復元できます。",
"emptyConversationDetail": "このセッションには保存された会話がなく、再開できません。",
"queuedMessages": "キュー内のメッセージ {{value0}} 件",
"subagentTranscripts": "サブエージェントの履歴 {{value0}} 件"
"subagentTranscripts": "サブエージェントの履歴 {{value0}} 件",
"firstPrompt": "最初のプロンプト",
"firstPromptCopied": "最初のプロンプトをコピーしました",
"copyFirstPrompt": "最初のプロンプトをコピー",
"copied": "コピーしました",
"copy": "コピー",
"noFirstPromptAvailable": "最初のプロンプトはありません",
"loadingFirstPrompt": "最初のプロンプトを読み込み中…"
},
"AiVaultSessionRow": {
"resumeAgentSession": "{{value0}} セッションを再開",

View File

@ -11188,7 +11188,14 @@
"recoverableEmptyOpenLogHint": "로그를 열어 복구하세요.",
"emptyConversationDetail": "이 세션에는 저장된 대화가 없어 재개할 수 없습니다.",
"queuedMessages": "대기 중인 메시지 {{value0}}개",
"subagentTranscripts": "서브에이전트 대화 기록 {{value0}}개"
"subagentTranscripts": "서브에이전트 대화 기록 {{value0}}개",
"firstPrompt": "첫 프롬프트",
"firstPromptCopied": "첫 프롬프트를 복사했습니다",
"copyFirstPrompt": "첫 프롬프트 복사",
"copied": "복사됨",
"copy": "복사",
"noFirstPromptAvailable": "첫 프롬프트를 사용할 수 없습니다",
"loadingFirstPrompt": "첫 프롬프트를 불러오는 중…"
},
"AiVaultSessionRow": {
"resumeAgentSession": "{{value0}} 세션 재개",

View File

@ -11188,7 +11188,14 @@
"recoverableEmptyOpenLogHint": "打开日志以恢复它们。",
"emptyConversationDetail": "此会话没有已保存的对话,无法恢复。",
"queuedMessages": "{{value0}} 条排队消息",
"subagentTranscripts": "{{value0}} 个子智能体记录"
"subagentTranscripts": "{{value0}} 个子智能体记录",
"firstPrompt": "首次提示",
"firstPromptCopied": "已复制首次提示",
"copyFirstPrompt": "复制首次提示",
"copied": "已复制",
"copy": "复制",
"noFirstPromptAvailable": "没有可用的首次提示",
"loadingFirstPrompt": "正在加载首次提示…"
},
"AiVaultSessionRow": {
"resumeAgentSession": "恢复 {{value0}} 会话",

View File

@ -1534,6 +1534,8 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
callRuntimeResult<AiVaultPrepareSessionResumeResult>('aiVault.prepareSessionResume', args),
// Why: no server-side RPC for subagent transcript listing yet, so report an empty (not erroring) result.
listSubagentSessions: () => Promise.resolve({ sessions: [], issues: [] }),
// Why: full first-prompt re-parse is local-FS only; web/runtime falls back to preview text.
getFirstUserPrompt: () => Promise.resolve({ prompt: null }),
onWindowFocused: () => noopUnsubscribe
}
}

View File

@ -44,6 +44,30 @@ export function sessionDetailConversationTurns(
return dedupeAdjacentConversationTurns(turns).slice(-limit)
}
/**
* Placeholder text for the first-prompt row while the full body loads on demand.
* List scans no longer store firstUserPrompt (payload/perf); this is preview-only.
*/
export function sessionFirstPrompt(session: AiVaultSession): string | null {
// Prefer a stored full body when present (on-demand re-parse / tests).
const stored = session.firstUserPrompt?.trim()
if (stored) {
return stored
}
for (const message of session.previewMessages) {
if (message.role !== 'user') {
continue
}
const text = message.text.trim()
if (text) {
return text
}
}
return null
}
function turnTextMatchesSessionTitle(title: string, turnText: string): boolean {
const sessionText = normalizeSessionDisplayText(title)
const candidateText = normalizeSessionDisplayText(turnText)

View File

@ -91,6 +91,11 @@ export type AiVaultSession = {
messageCount: number
totalTokens: number
previewMessages: AiVaultSessionPreviewMessage[]
/**
* Full first non-injected user prompt. List scans omit this (payload/perf);
* populated only by on-demand `aiVault.getFirstUserPrompt` re-parses for copy.
*/
firstUserPrompt?: string | null
/** Latest provider-authenticated user prompt; absent when the transcript has no trustworthy signal. */
lastUserPrompt?: string | null
// Recoverable signal for sessions whose conversation transcript persisted zero
@ -118,6 +123,21 @@ export type AiVaultSubagentListResult = {
issues: AiVaultScanIssue[]
}
/** On-demand full first-prompt read for Agent Session History copy/reuse. */
export type AiVaultFirstUserPromptArgs = {
agent: AiVaultAgent
filePath: string
// Required for OpenCode SQLite rows (filePath is the db; session is a row id).
sessionId?: string
// Transcripts are local-FS only; non-local hosts resolve to null prompt.
executionHostId?: ExecutionHostId
codexHome?: string | null
}
export type AiVaultFirstUserPromptResult = {
prompt: string | null
}
// A session is only offered for normal resume when its transcript actually holds
// conversation turns; resuming a zero-turn transcript lands in an empty session.
// Conversation previews count as evidence too: some parsers (e.g. Grok, OpenCode

View File

@ -73,6 +73,12 @@ describe('isKnownHarnessInjectedUserTurnText', () => {
expect(isKnownHarnessInjectedUserTurnText(' ')).toBe(false)
})
it('classifies from a bounded head so multi-KB pastes stay cheap', () => {
const largePrompt = `<task-notification>done</task-notification>\n${'x'.repeat(20_000)}`
expect(isKnownHarnessInjectedUserTurnText(largePrompt)).toBe(true)
expect(isKnownHarnessInjectedUserTurnText(`fix login ${'y'.repeat(20_000)}`)).toBe(false)
})
it('keeps single-word tag pastes, custom elements, and underscore wrappers', () => {
// Grok wraps REAL typed prompts in <user_query> — never classify as noise.
expect(isKnownHarnessInjectedUserTurnText('<user_query>fix the bug</user_query>')).toBe(false)

View File

@ -49,10 +49,24 @@ const HARNESS_INJECTED_TURN_PREFIXES = [
'this session is being continued from a previous conversation'
]
// Why: classification only inspects leading tags/prefixes. Cap the toLowerCase
// copy so vault-scan / prompt-seed paths stay O(1) on multi-KB pastes.
const HARNESS_CLASSIFY_HEAD_LIMIT = 256
const HARNESS_CLASSIFY_LEADING_WS_LIMIT = 64
/** True only for observed harness shapes. Match on trimmed, lowercased text.
* Unknown kebab tags stay user turns only tags we have observed count. */
export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
const normalized = text.trim().toLowerCase()
let start = 0
const wsScanEnd = Math.min(text.length, HARNESS_CLASSIFY_LEADING_WS_LIMIT)
while (start < wsScanEnd && isAsciiWhitespace(text.charCodeAt(start))) {
start += 1
}
if (start >= text.length) {
return false
}
const headEnd = Math.min(text.length, start + HARNESS_CLASSIFY_HEAD_LIMIT)
const normalized = text.slice(start, headEnd).toLowerCase()
if (normalized.length === 0) {
return false
}
@ -62,3 +76,7 @@ export function isKnownHarnessInjectedUserTurnText(text: string): boolean {
}
return HARNESS_INJECTED_TURN_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
function isAsciiWhitespace(code: number): boolean {
return code === 32 || code === 9 || code === 10 || code === 13 || code === 12
}