mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user