perf(ai-vault): reuse session list identity on reminted scannedAt (#14245)

* perf(ai-vault): reuse session list identity on reminted scannedAt

Refocus after the host-cache TTL remints scannedAt even when every
session row is unchanged, and all-host merge always stamped Date.now().
The panel only skipped apply on scannedAt equality, so Agent History
rebuilt sessionProjectById and the worktree path map on every alt-tab.

Reconcile nested session rows structurally before setState, and keep
the latest input scannedAt on merge so a cache-hit all-host result
stays a no-op.

Co-authored-by: Orca <help@stably.ai>

* perf(ai-vault): clamp merged scannedAt and derive sessions from scanResult

A remote leg whose clock is ahead, or whose stamp is not ISO, would pin
the all-host max above every local rescan and freeze the renderer's
scannedAt equality guard — including a manual force refresh. Ignore
future and unparsable stamps.

Also drop the duplicate sessions useState so apply walks the list once
and sessions === scanResult.sessions again.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Neil
2026-08-13 01:55:44 -07:00
committed by GitHub
co-authored by Orca
parent 3ab8b6a117
commit 0add035784
6 changed files with 378 additions and 8 deletions
+46 -1
View File
@@ -1,4 +1,4 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type {
AiVaultListResult,
AiVaultScanIssue,
@@ -46,6 +46,51 @@ const SCOPE_TRUNCATION: AiVaultScanIssue = {
}
describe('mergeAiVaultListResults', () => {
afterEach(() => {
vi.useRealTimers()
})
it('keeps the latest input scannedAt instead of restamping the merge', () => {
const merged = mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:00.000Z' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
],
undefined
)
expect(merged.scannedAt).toBe('2026-08-02T00:00:05.000Z')
})
it('ignores a future or unparsable remote stamp so the merge cannot pin the renderer guard', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-02T00:00:10.000Z'))
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: '2026-08-02T00:00:20.000Z' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:05.000Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:05.000Z')
expect(
mergeAiVaultListResults(
[
{ ...listResult([]), scannedAt: 'scan-A' },
{ ...listResult([]), scannedAt: '2026-08-02T00:00:04.000Z' }
],
undefined
).scannedAt
).toBe('2026-08-02T00:00:04.000Z')
expect(
mergeAiVaultListResults([{ ...listResult([]), scannedAt: 'scan-A' }], undefined).scannedAt
).toBe('2026-08-02T00:00:10.000Z')
})
it('does not cap an Unlimited all-host merge', () => {
const sessions = Array.from({ length: 1001 }, (_, index) => session(index))
const merged = mergeAiVaultListResults([{ ...listResult([]), sessions }], undefined, true)
+22 -1
View File
@@ -75,6 +75,27 @@ export function mergeAiVaultListResults(
.sort((left, right) => sessionSortTime(right) - sessionSortTime(left))
.slice(0, limit),
issues,
scannedAt: new Date().toISOString()
// Why: a merge is not a new scan. Reminting here made every all-host cache
// hit look fresh to the renderer, which only skipped apply when scannedAt
// matched. Keep the latest input stamp so identical legs stay a no-op.
scannedAt: latestAiVaultScannedAt(results)
}
}
function latestAiVaultScannedAt(results: readonly AiVaultListResult[]): string {
const now = new Date().toISOString()
let latest: string | undefined
for (const result of results) {
const stamp = result.scannedAt
// Remote legs carry their own clock and only `z.string()` validation. An
// unparsable or future stamp would pin the merged stamp above every local
// rescan and silently freeze the renderer's scannedAt equality guard.
if (Number.isNaN(Date.parse(stamp)) || stamp > now) {
continue
}
if (latest === undefined || stamp > latest) {
latest = stamp
}
}
return latest ?? now
}
@@ -0,0 +1,134 @@
import { describe, expect, it } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
import { reuseAiVaultListResult } from './ai-vault-session-identity'
// Why: production rows carry nested previewMessages + subagent. A scalar
// {id, title} fixture reconciles even when the walker is broken, which is how
// a scannedAt-only or Object.is fix stays green.
function makeProductionSession(index: number, title = `session-${index}`): AiVaultSession {
const id = `local:codex:session-${index}:/sessions/session-${index}.jsonl`
const timestamp = new Date(Date.UTC(2026, 6, 1, 0, 0, index)).toISOString()
return {
id,
executionHostId: 'local',
executionHostPlatform: 'darwin',
agent: 'codex',
sessionId: `session-${index}`,
title,
cwd: '/Users/ada/orca',
branch: 'nwparker/ai-vault-session-list-identity',
model: 'gpt-5',
filePath: `/sessions/session-${index}.jsonl`,
codexHome: '/Users/ada/.codex',
createdAt: timestamp,
updatedAt: timestamp,
modifiedAt: timestamp,
messageCount: 4,
totalTokens: 1800,
previewMessages: [
{
role: 'user',
text: 'keep session list identity on reminted scannedAt',
timestamp
},
{
role: 'assistant',
text: 'reuse previous row refs when the transcript did not change',
timestamp
}
],
previewMessagesTruncated: true,
lastUserPrompt: 'keep session list identity on reminted scannedAt',
queuedMessageCount: 0,
subagentTranscriptCount: 1,
resumeCommand: `codex resume session-${index}`,
subagent: {
parentSessionId: `session-${index}`,
agentType: 'Explore',
status: 'completed'
}
}
}
function cloneResult(result: AiVaultListResult, scannedAt: string): AiVaultListResult {
const cloned = structuredClone(result)
cloned.scannedAt = scannedAt
return cloned
}
describe('reuseAiVaultListResult', () => {
it('keeps the result, rows, and nested previewMessages across independently cloned remints', () => {
const current: AiVaultListResult = {
sessions: [makeProductionSession(1), makeProductionSession(2)],
issues: [
{
executionHostId: 'ssh:dev-box',
agent: 'codex',
kind: 'scope',
path: '/home/ada',
message: 'Only the first 64 project paths were scanned.'
}
],
scannedAt: '2026-07-01T00:00:00.000Z'
}
const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z')
expect(incoming).not.toBe(current)
expect(incoming.sessions).not.toBe(current.sessions)
expect(incoming.sessions[0]).not.toBe(current.sessions[0])
expect(incoming.sessions[0]?.previewMessages).not.toBe(current.sessions[0]?.previewMessages)
expect(incoming.sessions[0]?.subagent).not.toBe(current.sessions[0]?.subagent)
expect(incoming.issues).not.toBe(current.issues)
const reused = reuseAiVaultListResult(current, incoming)
expect(reused).toBe(current)
expect(reused.sessions).toBe(current.sessions)
expect(reused.sessions[0]).toBe(current.sessions[0])
expect(reused.sessions[0]?.previewMessages).toBe(current.sessions[0]?.previewMessages)
expect(reused.issues).toBe(current.issues)
})
it('replaces a changed row but reuses the unchanged sibling', () => {
const current: AiVaultListResult = {
sessions: [makeProductionSession(1), makeProductionSession(2)],
issues: [],
scannedAt: '2026-07-01T00:00:00.000Z'
}
const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z')
const changed = incoming.sessions[1]
if (!changed?.previewMessages[0]) {
throw new Error('expected a nested preview message')
}
changed.previewMessages[0] = { ...changed.previewMessages[0], text: 'a new user turn' }
const reused = reuseAiVaultListResult(current, incoming)
expect(reused).not.toBe(current)
expect(reused).not.toBe(incoming)
expect(reused.sessions[0]).toBe(current.sessions[0])
expect(reused.sessions[1]).not.toBe(current.sessions[1])
expect(reused.sessions[1]?.previewMessages[0]?.text).toBe('a new user turn')
expect(reused.scannedAt).toBe('2026-07-01T00:00:15.000Z')
})
it('replaces issues when sessions are unchanged', () => {
const hostIssue = {
executionHostId: 'ssh:dev-box' as const,
agent: 'codex' as const,
kind: 'host' as const,
path: 'dev-box',
message: 'Remote connection dropped.'
}
const current: AiVaultListResult = {
sessions: [makeProductionSession(1)],
issues: [hostIssue],
scannedAt: '2026-07-01T00:00:00.000Z'
}
const incoming = cloneResult(current, '2026-07-01T00:00:15.000Z')
incoming.issues = []
const reused = reuseAiVaultListResult(current, incoming)
expect(reused).not.toBe(current)
expect(reused.sessions).toBe(current.sessions)
expect(reused.issues).toEqual([])
expect(reused.issues).toBe(incoming.issues)
})
})
@@ -0,0 +1,44 @@
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
import { areValuesEqual } from '@/store/slices/repo-identity-reconcile'
import { reuseEqualCatalogRows } from '@/store/slices/worktree-catalog-reconciliation'
export const EMPTY_AI_VAULT_SESSIONS: AiVaultSession[] = []
// Why: listSessions always structured-clones nested session rows (previewMessages,
// subagent). A TTL miss remints scannedAt even when the disk contents did not
// change, and all-host merge used to remint it even on cache-hit legs. The panel
// only skipped apply when scannedAt matched, so alt-tab after 15s rebuilt
// sessionProjectById + the worktree path map for every row. Reuse previous row
// and result identity when the payload is structurally unchanged so those memos
// stay cold. Reference compare is inert here — IPC clones never match.
export function reuseAiVaultListResult(
current: AiVaultListResult | null,
incoming: AiVaultListResult
): AiVaultListResult {
if (current === incoming) {
return current
}
if (!current) {
return incoming
}
const sessions = reuseEqualCatalogRows(current.sessions, incoming.sessions)
const issues = areValuesEqual(current.issues, incoming.issues) ? current.issues : incoming.issues
if (
sessions === current.sessions &&
issues === current.issues &&
current.cancelled === incoming.cancelled
) {
return current
}
if (sessions === incoming.sessions && issues === incoming.issues) {
return incoming
}
return { ...incoming, sessions, issues }
}
export function applyPublishedAiVaultList(
published: AiVaultListResult,
setScanResult: (updater: (prev: AiVaultListResult | null) => AiVaultListResult) => void
): void {
setScanResult((prev) => reuseAiVaultListResult(prev, published))
}
@@ -431,13 +431,140 @@ describe('useAiVaultSessionRefresh refocus behavior', () => {
await fireWindowFocused()
expect(latest?.scanResult).toBe(firstResult)
// A reminted stamp with the same empty body is still the snapshot on screen.
listSessionsMock.mockResolvedValueOnce({
...EMPTY_RESULT,
scannedAt: '2026-07-01T00:00:02.000Z'
})
await advance(THROTTLE_MS + 1)
await fireWindowFocused()
expect(latest?.scanResult).not.toBe(firstResult)
expect(latest?.scanResult).toBe(firstResult)
})
it('keeps session row identity when a reminted scan is a structuredClone of the same nested rows', async () => {
const session = makeVaultSession(1)
const first: AiVaultListResult = {
sessions: [
{
...session,
previewMessages: [
{
role: 'user',
text: 'keep session list identity on reminted scannedAt',
timestamp: session.modifiedAt
},
{
role: 'assistant',
text: 'reuse previous row refs when the transcript did not change',
timestamp: session.modifiedAt
}
],
previewMessagesTruncated: true,
lastUserPrompt: 'keep session list identity on reminted scannedAt',
subagent: {
parentSessionId: session.sessionId,
agentType: 'Explore',
status: 'completed'
}
}
],
issues: [],
scannedAt: '2026-07-01T00:00:00.000Z'
}
listSessionsMock.mockResolvedValueOnce(first)
await renderHook()
await flushMicrotasks()
const appliedSessions = latest?.sessions
const appliedResult = latest?.scanResult
expect(appliedSessions?.[0]?.previewMessages).toHaveLength(2)
const reminted = structuredClone(first)
reminted.scannedAt = '2026-07-01T00:00:15.000Z'
expect(reminted.sessions).not.toBe(first.sessions)
expect(reminted.sessions[0]).not.toBe(first.sessions[0])
expect(reminted.sessions[0]?.previewMessages).not.toBe(first.sessions[0]?.previewMessages)
listSessionsMock.mockResolvedValueOnce(reminted)
await advance(THROTTLE_MS + 1)
await fireWindowFocused()
expect(latest?.sessions).toBe(appliedSessions)
expect(latest?.sessions[0]).toBe(appliedSessions?.[0])
expect(latest?.sessions[0]?.previewMessages).toBe(appliedSessions?.[0]?.previewMessages)
expect(latest?.scanResult).toBe(appliedResult)
})
it('replaces the changed row when a reminted scan edits nested preview text', async () => {
const session = makeVaultSession(1)
const sibling = makeVaultSession(2)
const first: AiVaultListResult = {
sessions: [
{
...session,
previewMessages: [
{ role: 'user', text: 'original ask', timestamp: session.modifiedAt }
]
},
sibling
],
issues: [],
scannedAt: '2026-07-01T00:00:00.000Z'
}
listSessionsMock.mockResolvedValueOnce(first)
await renderHook()
await flushMicrotasks()
const appliedSessions = latest?.sessions
expect(appliedSessions).toHaveLength(2)
const reminted = structuredClone(first)
reminted.scannedAt = '2026-07-01T00:00:15.000Z'
const changed = reminted.sessions[0]
const preview = changed?.previewMessages[0]
if (!changed || !preview) {
throw new Error('expected a nested preview message')
}
reminted.sessions[0] = {
...changed,
previewMessages: [{ ...preview, text: 'follow-up ask' }]
}
listSessionsMock.mockResolvedValueOnce(reminted)
await advance(THROTTLE_MS + 1)
await fireWindowFocused()
expect(latest?.sessions).not.toBe(appliedSessions)
expect(latest?.sessions[0]).not.toBe(appliedSessions?.[0])
expect(latest?.sessions[0]?.previewMessages[0]?.text).toBe('follow-up ask')
expect(latest?.sessions[1]).toBe(appliedSessions?.[1])
})
it('appends a new session on refocus and keeps the surviving row identity', async () => {
const first: AiVaultListResult = {
sessions: [makeVaultSession(1)],
issues: [],
scannedAt: '2026-07-01T00:00:00.000Z'
}
listSessionsMock.mockResolvedValueOnce(first)
await renderHook()
await flushMicrotasks()
const surviving = latest?.sessions[0]
const previous = first.sessions[0]
expect(surviving?.id).toBe('session-1')
if (!previous) {
throw new Error('expected the first scan to include a session')
}
listSessionsMock.mockResolvedValueOnce({
sessions: [structuredClone(previous), makeVaultSession(2)],
issues: [],
scannedAt: '2026-07-01T00:00:15.000Z'
})
await advance(THROTTLE_MS + 1)
await fireWindowFocused()
expect(latest?.sessions).toHaveLength(2)
expect(latest?.sessions[0]).toBe(surviving)
expect(latest?.sessions[1]?.id).toBe('session-2')
})
it('keeps the current list when a superseded scan resolves cancelled', async () => {
@@ -8,6 +8,7 @@ import type { ExecutionHostScope } from '../../../../shared/execution-host'
import { useAppStore } from '@/store'
import type { AiVaultSessionLimit } from './ai-vault-session-limit'
import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate'
import { applyPublishedAiVaultList, EMPTY_AI_VAULT_SESSIONS } from './ai-vault-session-identity'
import {
aiVaultSessionResultCacheKey,
cacheAiVaultSessionResult,
@@ -42,8 +43,8 @@ export function useAiVaultSessionRefresh(
scanResult: AiVaultListResult | null
sessions: AiVaultSession[]
} {
const [sessions, setSessions] = useState<AiVaultSession[]>([])
const [scanResult, setScanResult] = useState<AiVaultListResult | null>(null)
const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestTokenRef = useRef(crypto.randomUUID())
@@ -92,8 +93,7 @@ export function useAiVaultSessionRefresh(
lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt }
setError(null)
publicationGateRef.current.publish(cachedResult, (published) => {
setScanResult(published)
setSessions(published.sessions)
applyPublishedAiVaultList(published, setScanResult)
})
setLoading(false)
return
@@ -161,8 +161,7 @@ export function useAiVaultSessionRefresh(
})
publicationGateRef.current.publish(result, (published) => {
if (mountedRef.current && scanKey === currentScanScopeKey()) {
setScanResult(published)
setSessions(published.sessions)
applyPublishedAiVaultList(published, setScanResult)
}
})
} catch (err) {