mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 00:02:29 +00:00
Continue Antigravity IDE and 2.0 history in new CLI conversations (#24692)
* feat(antigravity): bridge IDE history into new CLI conversations * fix(antigravity): preserve fresh-launch model and environment for IDE references * fix(antigravity): forward IDE history opt-in through desktop IPC * fix(antigravity): rebuild remote IDE reference startup on its host * fix(antigravity): register IDE continuation action labels * fix(antigravity): confine IDE references and bound metadata reads * fix(antigravity): localize IDE continuation badges * Preserve scanner service cache assertions and refresh Antigravity opening metadata * Preserve Antigravity opening joins and target folder runtime authority
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { normalizeTitleText } from './session-scanner-values'
|
||||
|
||||
export function antigravityHistoryPromptHash(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || value.length > 4096) {
|
||||
return null
|
||||
}
|
||||
const prompt = normalizeTitleText(value)
|
||||
return prompt && !prompt.endsWith('...')
|
||||
? createHash('sha256').update(prompt).digest('hex')
|
||||
: null
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
import { mkdtemp, mkdir, rm, writeFile, utimes } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots, writeAntigravityTranscript } from './session-scanner-test-fixtures'
|
||||
import { MemoryRemoteProvider, jsonLines } from './remote-session-scanner-test-fixtures'
|
||||
import { scanRemoteAiVaultSessions } from './remote-session-scanner'
|
||||
import { getRemoteHostPlatform } from '../ssh/ssh-remote-platform'
|
||||
import { dedupeScannedSessions } from './session-root-dedup'
|
||||
import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
|
||||
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
|
||||
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
|
||||
|
||||
const temporaryDirectories: string[] = []
|
||||
const conversationId = 'same-conversation-id'
|
||||
const origins = ['antigravity-cli', 'antigravity-ide', 'antigravity'] as const
|
||||
function records(prompt = 'Continue this verified example') {
|
||||
return [
|
||||
{
|
||||
source: 'USER_EXPLICIT',
|
||||
type: 'USER_INPUT',
|
||||
created_at: '2026-10-02T01:00:00Z',
|
||||
content: `<USER_REQUEST>${prompt}</USER_REQUEST>`
|
||||
}
|
||||
]
|
||||
}
|
||||
async function temporaryHome(): Promise<string> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'orca-agy-ide-'))
|
||||
temporaryDirectories.push(home)
|
||||
return home
|
||||
}
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryDirectories.splice(0).map((home) => rm(home, { recursive: true, force: true }))
|
||||
)
|
||||
})
|
||||
|
||||
describe('Antigravity IDE history discovery', () => {
|
||||
it('requires client opt-in and keeps equal IDs in separate origins while selecting the full transcript', async () => {
|
||||
const home = await temporaryHome()
|
||||
for (const origin of origins) {
|
||||
const path = await writeAntigravityTranscript(
|
||||
join(home, '.gemini', origin, 'brain'),
|
||||
conversationId,
|
||||
records(origin)
|
||||
)
|
||||
await writeFile(
|
||||
join(dirname(path), 'transcript_full.jsonl'),
|
||||
`${jsonLines(records(origin))}\n`
|
||||
)
|
||||
await writeFile(
|
||||
join(dirname(path), 'artifact.jsonl'),
|
||||
`${jsonLines(records('Not history'))}\n`
|
||||
)
|
||||
}
|
||||
const options = {
|
||||
...isolatedScanRoots(home),
|
||||
antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
|
||||
antigravityAppHome: home
|
||||
}
|
||||
const legacy = await scanAiVaultSessions(options)
|
||||
expect(legacy.sessions).toHaveLength(1)
|
||||
expect(antigravitySessionOrigin(legacy.sessions[0]!.filePath)).toBe('antigravity-cli')
|
||||
const result = await scanAiVaultSessions({ ...options, includeAntigravityIdeSessions: true })
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
expect(new Set(result.sessions.map((session) => session.id)).size).toBe(3)
|
||||
for (const session of result.sessions) {
|
||||
expect(session.filePath).toContain('transcript_full.jsonl')
|
||||
if (antigravitySessionOrigin(session.filePath) === 'antigravity-cli') {
|
||||
expect(session.resumeCommand).toContain('--conversation')
|
||||
} else {
|
||||
expect(session.resumeCommand).toContain('--prompt-interactive')
|
||||
expect(session.resumeCommand).not.toContain('--conversation')
|
||||
expect(session.resumeCommand).toContain(session.filePath)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('joins bounded metadata by exact conversation ID within each origin and refuses conflicting workspaces', async () => {
|
||||
const home = await temporaryHome()
|
||||
for (const origin of origins) {
|
||||
const brain = join(home, '.gemini', origin, 'brain')
|
||||
await writeAntigravityTranscript(brain, conversationId, records('Same title across origins'))
|
||||
const cache = join(dirname(brain), 'cache')
|
||||
await mkdir(cache, { recursive: true })
|
||||
await writeFile(
|
||||
join(cache, 'conversation_metadata.json'),
|
||||
JSON.stringify({
|
||||
conversations: { [conversationId]: { summary: { ProjectID: 'project-id' } } }
|
||||
})
|
||||
)
|
||||
await writeFile(
|
||||
join(cache, 'projects.json'),
|
||||
JSON.stringify({ 'project-id': `/projects/${origin}` })
|
||||
)
|
||||
if (origin === 'antigravity') {
|
||||
await writeFile(
|
||||
join(cache, 'last_conversations.json'),
|
||||
JSON.stringify({ '/conflicting-workspace': conversationId })
|
||||
)
|
||||
}
|
||||
}
|
||||
const result = await scanAiVaultSessions({
|
||||
...isolatedScanRoots(home),
|
||||
antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
|
||||
antigravityAppHome: home,
|
||||
includeAntigravityIdeSessions: true
|
||||
})
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
for (const session of result.sessions) {
|
||||
const origin = antigravitySessionOrigin(session.filePath)
|
||||
expect(session.cwd).toBe(origin === 'antigravity' ? null : `/projects/${origin}`)
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers IDE and 2.0 history in the selected WSL home without falling into sibling artifacts', async () => {
|
||||
const home = await temporaryHome()
|
||||
const wslHome = join(home, 'wsl-home')
|
||||
for (const origin of origins) {
|
||||
await writeAntigravityTranscript(
|
||||
join(wslHome, '.gemini', origin, 'brain'),
|
||||
conversationId,
|
||||
records(origin)
|
||||
)
|
||||
}
|
||||
const result = await scanAiVaultSessions({
|
||||
...isolatedScanRoots(home),
|
||||
wslHomeDirs: [wslHome],
|
||||
includeAntigravityIdeSessions: true,
|
||||
platform: 'linux'
|
||||
})
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
expect(result.sessions.every((session) => session.filePath.startsWith(wslHome))).toBe(true)
|
||||
})
|
||||
|
||||
it('selects an older full sibling even when the raw discovery cap only retained its compact file', async () => {
|
||||
const home = await temporaryHome()
|
||||
const brain = join(home, '.gemini', 'antigravity-ide', 'brain')
|
||||
const compact = await writeAntigravityTranscript(brain, conversationId, records())
|
||||
const full = join(dirname(compact), 'transcript_full.jsonl')
|
||||
await writeFile(full, `${jsonLines(records())}\n`)
|
||||
await utimes(full, new Date('2026-10-01T00:00:00Z'), new Date('2026-10-01T00:00:00Z'))
|
||||
const result = await scanAiVaultSessions({
|
||||
...isolatedScanRoots(home),
|
||||
antigravityAppHome: home,
|
||||
includeAntigravityIdeSessions: true,
|
||||
limit: 1,
|
||||
limitPerAgent: 1
|
||||
})
|
||||
expect(result.sessions).toHaveLength(1)
|
||||
expect(result.sessions[0]?.filePath).toBe(full)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'lists all origins despite a FIFO metadata cache and retains transcript workspace fallback',
|
||||
async () => {
|
||||
const home = await temporaryHome()
|
||||
for (const origin of origins) {
|
||||
const brain = join(home, '.gemini', origin, 'brain')
|
||||
await writeAntigravityTranscript(brain, conversationId, records(origin))
|
||||
const cache = join(dirname(brain), 'cache')
|
||||
await mkdir(cache, { recursive: true })
|
||||
const result = await runProcess({
|
||||
program: 'mkfifo',
|
||||
args: [join(cache, 'projects.json')],
|
||||
timeoutMs: 2000
|
||||
})
|
||||
expect(result.code).toBe(0)
|
||||
}
|
||||
const result = await scanAiVaultSessions({
|
||||
...isolatedScanRoots(home),
|
||||
antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
|
||||
antigravityAppHome: home,
|
||||
includeAntigravityIdeSessions: true
|
||||
})
|
||||
expect(result.sessions).toHaveLength(3)
|
||||
expect(result.issues).toEqual([])
|
||||
expect(result.sessions.every((session) => session.cwd === null)).toBe(true)
|
||||
},
|
||||
5000
|
||||
)
|
||||
|
||||
it('does not retain an oversized metadata file', async () => {
|
||||
const home = await temporaryHome()
|
||||
const file = join(home, 'oversized.json')
|
||||
await writeFile(file, ' '.repeat(ANTIGRAVITY_INDEX_MAX_BYTES + 1))
|
||||
expect(await readLocalAntigravityHistory(file)).toBeNull()
|
||||
})
|
||||
|
||||
it('reads only the execution host and never collapses equal origin IDs from different remote hosts', async () => {
|
||||
const provider = new MemoryRemoteProvider()
|
||||
const remoteHome = '/remote/home'
|
||||
for (const origin of origins) {
|
||||
const logs = `${remoteHome}/.gemini/${origin}/brain/${conversationId}/.system_generated/logs`
|
||||
provider.addFile(`${logs}/transcript_full.jsonl`, jsonLines(records(origin)), 10)
|
||||
provider.addFile(`${logs}/transcript.jsonl`, jsonLines(records(origin)), 11)
|
||||
}
|
||||
const read = vi.spyOn(provider, 'readFile')
|
||||
const options = {
|
||||
provider,
|
||||
remoteHome,
|
||||
hostPlatform: getRemoteHostPlatform('linux-x64'),
|
||||
includeAntigravityIdeSessions: true
|
||||
}
|
||||
const first = await scanRemoteAiVaultSessions({ ...options, executionHostId: 'ssh:first-host' })
|
||||
const second = await scanRemoteAiVaultSessions({
|
||||
...options,
|
||||
executionHostId: 'ssh:second-host'
|
||||
})
|
||||
expect(first.sessions).toHaveLength(3)
|
||||
expect(second.sessions).toHaveLength(3)
|
||||
expect(dedupeScannedSessions([...first.sessions, ...second.sessions])).toHaveLength(6)
|
||||
expect(read.mock.calls.every(([path]) => path.startsWith(remoteHome))).toBe(true)
|
||||
expect(provider.readDirPaths.every((path) => path.startsWith(remoteHome))).toBe(true)
|
||||
expect(
|
||||
first.sessions.every((session) => session.filePath.endsWith('transcript_full.jsonl'))
|
||||
).toBe(true)
|
||||
const legacy = await scanRemoteAiVaultSessions({
|
||||
...options,
|
||||
executionHostId: 'ssh:first-host',
|
||||
includeAntigravityIdeSessions: false
|
||||
})
|
||||
expect(legacy.sessions).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import type * as NodeFsPromises from 'node:fs/promises'
|
||||
import { constants } from 'node:fs'
|
||||
import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { runProcess } from '../../shared/child-process/run-process'
|
||||
|
||||
const { afterStat } = vi.hoisted(() => {
|
||||
const afterStat: { run: null | (() => Promise<void>) } = { run: null }
|
||||
return { afterStat }
|
||||
})
|
||||
vi.mock('node:fs/promises', async (original) => {
|
||||
const actual = await original<typeof NodeFsPromises>()
|
||||
return {
|
||||
...actual,
|
||||
stat: async (...args: Parameters<typeof actual.stat>) => {
|
||||
const result = await actual.stat(...args)
|
||||
await afterStat.run?.()
|
||||
return result
|
||||
}
|
||||
}
|
||||
})
|
||||
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { readLocalAntigravityHistory } from './session-scanner-antigravity-history'
|
||||
import { readRelayTranscriptBytes } from '../../relay/ai-vault-transcript-stream'
|
||||
|
||||
const roots: string[] = []
|
||||
async function filePath() {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-regular-index-'))
|
||||
roots.push(root)
|
||||
return join(root, 'projects.json')
|
||||
}
|
||||
async function fifo(path: string) {
|
||||
const result = await runProcess({ program: 'mkfifo', args: [path], timeoutMs: 2000 })
|
||||
if (result.code !== 0) {
|
||||
throw new Error('Could not create task-owned FIFO')
|
||||
}
|
||||
}
|
||||
afterEach(async () => {
|
||||
afterStat.run = null
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('bounded regular metadata reads', () => {
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects real FIFO paths and symlinks without waiting for a writer',
|
||||
async () => {
|
||||
const file = await filePath()
|
||||
await fifo(file)
|
||||
const alias = `${file}.link`
|
||||
await symlink(file, alias)
|
||||
for (const path of [file, alias]) {
|
||||
expect(await readLocalAntigravityHistory(path)).toBeNull()
|
||||
await expect(readNodeFileWithinLimit(path, 64, { regularFileOnly: true })).rejects.toThrow(
|
||||
'regular file'
|
||||
)
|
||||
await expect(
|
||||
readRelayTranscriptBytes(path, undefined, { regularFileOnly: true, maxBytes: 64 }).next()
|
||||
).rejects.toThrow('regular file')
|
||||
}
|
||||
},
|
||||
2000
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects a regular path replaced by a FIFO between stat and open',
|
||||
async () => {
|
||||
const file = await filePath()
|
||||
await writeFile(file, '{}')
|
||||
afterStat.run = async () => {
|
||||
afterStat.run = null
|
||||
await rm(file)
|
||||
await fifo(file)
|
||||
}
|
||||
await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
|
||||
'regular file'
|
||||
)
|
||||
expect(constants.O_NONBLOCK).toBeGreaterThan(0)
|
||||
},
|
||||
2000
|
||||
)
|
||||
|
||||
it('retains ordinary metadata and regular-file symlinks within the byte bound', async () => {
|
||||
const file = await filePath()
|
||||
await writeFile(file, '{"project":"/workspace"}')
|
||||
expect(await readLocalAntigravityHistory(file)).toBe('{"project":"/workspace"}')
|
||||
await expect(readNodeFileWithinLimit(file, 1, { regularFileOnly: true })).rejects.toThrow(
|
||||
'File too large'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects growth beyond the bound after admission', async () => {
|
||||
const file = await filePath()
|
||||
await writeFile(file, '{}')
|
||||
afterStat.run = async () => {
|
||||
afterStat.run = null
|
||||
await writeFile(file, 'x'.repeat(65))
|
||||
}
|
||||
await expect(readNodeFileWithinLimit(file, 64, { regularFileOnly: true })).rejects.toThrow(
|
||||
'File too large'
|
||||
)
|
||||
})
|
||||
|
||||
it('propagates cancellation before open and after admission', async () => {
|
||||
const file = await filePath()
|
||||
await writeFile(file, '{}')
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
await expect(readLocalAntigravityHistory(file, controller.signal)).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
const next = new AbortController()
|
||||
afterStat.run = async () => {
|
||||
afterStat.run = null
|
||||
next.abort()
|
||||
}
|
||||
await expect(readLocalAntigravityHistory(file, next.signal)).rejects.toMatchObject({
|
||||
name: 'AbortError'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
|
||||
import { limitRemoteScanFilesystemConcurrency } from './remote-session-scan-concurrency'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import { ANTIGRAVITY_INDEX_MAX_BYTES } from './session-scanner-antigravity-metadata'
|
||||
|
||||
function provider(): RemoteSessionFilesystemProvider {
|
||||
return {
|
||||
readDir: async () => [],
|
||||
stat: async () => ({ type: 'file', size: 2, mtime: 0 }),
|
||||
readFile: async () => ({ content: '{}', isBinary: false })
|
||||
}
|
||||
}
|
||||
|
||||
describe('execution-host Antigravity metadata reads', () => {
|
||||
it('forwards regular-file admission and byte limits through the concurrency wrapper', async () => {
|
||||
const host = provider()
|
||||
const read = vi.fn(async function* () {
|
||||
yield Buffer.from('{}')
|
||||
})
|
||||
host.readTranscriptBytes = read
|
||||
expect(
|
||||
await readRemoteAntigravityIndex(
|
||||
limitRemoteScanFilesystemConcurrency(host),
|
||||
'/host/projects.json'
|
||||
)
|
||||
).toBe('{}')
|
||||
expect(read).toHaveBeenCalledWith('/host/projects.json', undefined, {
|
||||
regularFileOnly: true,
|
||||
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves the existing bounded read-file options on legacy providers', async () => {
|
||||
const host = provider()
|
||||
const read = vi.spyOn(host, 'readFile')
|
||||
expect(
|
||||
await readRemoteAntigravityIndex(
|
||||
limitRemoteScanFilesystemConcurrency(host),
|
||||
'/host/projects.json'
|
||||
)
|
||||
).toBe('{}')
|
||||
expect(read).toHaveBeenCalledWith('/host/projects.json', {
|
||||
maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
})
|
||||
|
||||
it('does not read directories or symlinks from legacy providers', async () => {
|
||||
for (const type of ['directory', 'symlink'] as const) {
|
||||
const host = provider()
|
||||
host.stat = async () => ({ type, size: 0, mtime: 0 })
|
||||
const read = vi.spyOn(host, 'readFile')
|
||||
expect(await readRemoteAntigravityIndex(host, '/host/projects.json')).toBeNull()
|
||||
expect(read).not.toHaveBeenCalled()
|
||||
}
|
||||
})
|
||||
|
||||
it('stops waiting for a legacy request that cannot cancel on the wire', async () => {
|
||||
const host = provider()
|
||||
host.readFile = () => new Promise(() => {})
|
||||
const controller = new AbortController()
|
||||
const read = readRemoteAntigravityIndex(host, '/host/projects.json', controller.signal)
|
||||
await Promise.resolve()
|
||||
controller.abort()
|
||||
await expect(read).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ANTIGRAVITY_INDEX_MAX_BYTES,
|
||||
readBoundedAntigravityIndex
|
||||
} from './session-scanner-antigravity-metadata'
|
||||
import type { RemoteSessionFilesystemProvider } from './remote-session-scanner-types'
|
||||
import {
|
||||
abandonRemoteSessionScanOnCancel,
|
||||
throwIfAiVaultScanCancelled
|
||||
} from './ai-vault-scan-cancellation'
|
||||
|
||||
export async function readRemoteAntigravityIndex(
|
||||
provider: RemoteSessionFilesystemProvider,
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (provider.readTranscriptBytes) {
|
||||
return await abandonRemoteSessionScanOnCancel(
|
||||
readBoundedAntigravityIndex(
|
||||
provider.readTranscriptBytes(path, signal, {
|
||||
regularFileOnly: true,
|
||||
maxBytes: ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
})
|
||||
),
|
||||
signal
|
||||
)
|
||||
}
|
||||
const stat = await abandonRemoteSessionScanOnCancel(provider.stat(path), signal)
|
||||
if (stat.type !== 'file' || stat.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
|
||||
return null
|
||||
}
|
||||
const read = await abandonRemoteSessionScanOnCancel(
|
||||
provider.readFile(path, { maxTextBytes: ANTIGRAVITY_INDEX_MAX_BYTES }),
|
||||
signal
|
||||
)
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
return read.isBinary || Buffer.byteLength(read.content) > ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
? null
|
||||
: read.content
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
import {
|
||||
createAntigravitySessionResumeState,
|
||||
parseAntigravitySessionContent
|
||||
} from './session-scanner-antigravity-parser'
|
||||
import { jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
const openingTime = '2026-07-15T11:39:10.000Z'
|
||||
const historyPath = '/home/ada/.gemini/antigravity-cli/history.jsonl'
|
||||
const file = {
|
||||
path: '/home/ada/.gemini/antigravity-cli/brain/brain-id/.system_generated/logs/transcript.jsonl',
|
||||
mtimeMs: Date.parse(openingTime),
|
||||
modifiedAt: openingTime
|
||||
}
|
||||
|
||||
function user(prompt: string, timestamp: string | null = openingTime) {
|
||||
return {
|
||||
source: 'USER_EXPLICIT',
|
||||
type: 'USER_INPUT',
|
||||
created_at: timestamp,
|
||||
content: `<USER_REQUEST>${prompt}</USER_REQUEST>`
|
||||
}
|
||||
}
|
||||
|
||||
function openingRecords(prompt: string, timestamp: string | null = openingTime) {
|
||||
return [
|
||||
{
|
||||
source: 'SYSTEM',
|
||||
type: 'CHECKPOINT',
|
||||
created_at: '2026-07-15T11:38:00.000Z',
|
||||
content: 'Earlier system metadata is not the first prompt timestamp'
|
||||
},
|
||||
user(prompt, timestamp),
|
||||
...Array.from({ length: 7 }, (_, index) =>
|
||||
user(`Later request ${index}`, `2026-07-15T11:4${index}:10.000Z`)
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
function historyRow(display: string, workspace = '/repo/original', conversationId?: string) {
|
||||
return { display, workspace, timestamp: Date.parse(openingTime) / 1000, conversationId }
|
||||
}
|
||||
|
||||
function resolver(rows: ReturnType<typeof historyRow>[]) {
|
||||
return createAntigravityWorkspaceResolver(async (path) =>
|
||||
path === historyPath ? jsonLines(rows) : null
|
||||
)
|
||||
}
|
||||
|
||||
async function parse(prompt: string, timestamp: string | null = openingTime) {
|
||||
const session = await parseAntigravitySessionContent(
|
||||
file,
|
||||
jsonLines(openingRecords(prompt, timestamp)),
|
||||
'linux'
|
||||
)
|
||||
if (!session) {
|
||||
throw new Error('Missing parsed fixture session')
|
||||
}
|
||||
expect(session.previewMessagesTruncated).toBe(true)
|
||||
expect(session.previewMessages.every((message) => message.text !== prompt)).toBe(true)
|
||||
expect(session.firstUserPrompt).toBeUndefined()
|
||||
return session
|
||||
}
|
||||
|
||||
describe('Antigravity opening prompt workspace association', () => {
|
||||
it.each([undefined, 'history-id'])(
|
||||
'retains a long session workspace with row id %s',
|
||||
async (id) => {
|
||||
const prompt = 'Locate the original workspace'
|
||||
const session = await parse(prompt)
|
||||
expect(session.createdAt).not.toBe(openingTime)
|
||||
expect(
|
||||
(await resolver([historyRow(prompt, '/repo/original', id)]).enrich(session, historyPath))
|
||||
.cwd
|
||||
).toBe('/repo/original')
|
||||
}
|
||||
)
|
||||
|
||||
it('refuses fallback when unrelated asks share a truncated opening title', async () => {
|
||||
const prefix = 'A long instruction shared by several unrelated projects '.repeat(3)
|
||||
const prompt = `${prefix}original task`
|
||||
const session = await parse(prompt)
|
||||
expect(session.title.endsWith('...')).toBe(true)
|
||||
const rows = [historyRow(`${prefix}other task`, '/repo/other'), historyRow(prompt)]
|
||||
expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
|
||||
})
|
||||
|
||||
it('normalizes opening whitespace consistently with history display', async () => {
|
||||
const session = await parse('Locate\n the original workspace')
|
||||
expect(
|
||||
(await resolver([historyRow('Locate the original workspace')]).enrich(session, historyPath))
|
||||
.cwd
|
||||
).toBe('/repo/original')
|
||||
})
|
||||
|
||||
it('keeps the established title normalization of hidden opening context', async () => {
|
||||
const session = await parse('Locate the workspace<system-reminder>Hidden</system-reminder>')
|
||||
expect(session.title).toBe('Locate the workspace')
|
||||
expect(
|
||||
(await resolver([historyRow('Locate the workspace')]).enrich(session, historyPath)).cwd
|
||||
).toBe('/repo/original')
|
||||
})
|
||||
|
||||
it('refuses identical duplicate rows and keeps the two-second timestamp window', async () => {
|
||||
const prompt = 'Original request'
|
||||
const session = await parse(prompt)
|
||||
expect(
|
||||
(await resolver([historyRow(prompt), historyRow(prompt)]).enrich(session, historyPath)).cwd
|
||||
).toBeNull()
|
||||
for (const [offset, expected] of [
|
||||
[2, '/repo/original'],
|
||||
[2.001, null]
|
||||
] as const) {
|
||||
const row = historyRow(prompt)
|
||||
row.timestamp += offset
|
||||
expect((await resolver([row]).enrich(session, historyPath)).cwd).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves conflicting prompt/time matches unknown even when one row has an id', async () => {
|
||||
const prompt = 'Repeated request'
|
||||
const rows = [historyRow(prompt), historyRow(prompt, '/repo/other', 'other-id')]
|
||||
expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
|
||||
})
|
||||
|
||||
it('gives an exact id authority over a conflicting prompt match', async () => {
|
||||
const prompt = 'Repeated request'
|
||||
const rows = [historyRow(prompt), historyRow('Different display', '/repo/exact', 'brain-id')]
|
||||
expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBe('/repo/exact')
|
||||
})
|
||||
|
||||
it('does not rescue conflicting exact ids through the prompt fallback', async () => {
|
||||
const prompt = 'Repeated request'
|
||||
const rows = [
|
||||
historyRow('One', '/repo/one', 'brain-id'),
|
||||
historyRow('Two', '/repo/two', 'brain-id'),
|
||||
historyRow(prompt)
|
||||
]
|
||||
expect((await resolver(rows).enrich(await parse(prompt), historyPath)).cwd).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps ambiguous project metadata authoritative over history and prompt fallback', async () => {
|
||||
const prompt = 'Original request'
|
||||
const read = createAntigravityWorkspaceResolver(async (path) => {
|
||||
if (path === historyPath) {
|
||||
return jsonLines([historyRow(prompt, '/repo/history', 'brain-id')])
|
||||
}
|
||||
if (path.endsWith('projects.json')) {
|
||||
return JSON.stringify({ '/repo/one': 'project', '/repo/two': 'project' })
|
||||
}
|
||||
if (path.endsWith('conversation_metadata.json')) {
|
||||
return JSON.stringify({
|
||||
conversations: { 'brain-id': { summary: { ProjectID: 'project' } } }
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
expect((await read.enrich(await parse(prompt), historyPath)).cwd).toBeNull()
|
||||
})
|
||||
|
||||
it('never joins a later preview turn or a title-only history row', async () => {
|
||||
const session = await parse('Opening request')
|
||||
const rows = [historyRow('Later request 3'), historyRow(session.title, '/repo/wrong')]
|
||||
rows[1].timestamp -= 60
|
||||
expect((await resolver(rows).enrich(session, historyPath)).cwd).toBeNull()
|
||||
})
|
||||
|
||||
it.each([null, 'invalid timestamp'])(
|
||||
'does not substitute createdAt for opening time %s',
|
||||
async (time) => {
|
||||
const prompt = 'Opening request'
|
||||
const session = await parse(prompt, time)
|
||||
const row = historyRow(prompt)
|
||||
row.timestamp = Date.parse(session.createdAt ?? '') / 1000
|
||||
expect((await resolver([row]).enrich(session, historyPath)).cwd).toBeNull()
|
||||
}
|
||||
)
|
||||
|
||||
it('does not let a later short prompt replace an oversized opening prompt', async () => {
|
||||
const session = await parse('x'.repeat(4097))
|
||||
expect(
|
||||
(await resolver([historyRow('Later request 3')]).enrich(session, historyPath)).cwd
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the opening association across incremental clones and preview shifts', async () => {
|
||||
const prompt = 'Original incremental request'
|
||||
const state = createAntigravitySessionResumeState(file)
|
||||
state.consumeLine(JSON.stringify(user(prompt)))
|
||||
const clone = state.clone()
|
||||
for (const record of openingRecords('Unrelated later prompt')) {
|
||||
clone.consumeLine(JSON.stringify(record))
|
||||
}
|
||||
const session = await clone.finalize('linux')
|
||||
if (!session) {
|
||||
throw new Error('Missing cloned session')
|
||||
}
|
||||
expect(session.previewMessagesTruncated).toBe(true)
|
||||
expect((await resolver([historyRow(prompt)]).enrich(session, historyPath)).cwd).toBe(
|
||||
'/repo/original'
|
||||
)
|
||||
expect((await state.finalize('linux'))?.previewMessages).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { isolatedScanRoots } from './session-scanner-test-fixtures'
|
||||
import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
|
||||
|
||||
it.skipIf(process.env.ORCA_REAL_ANTIGRAVITY_HISTORY !== '1')(
|
||||
'scans bounded existing official artifacts without publishing their content',
|
||||
async () => {
|
||||
const sandbox = await mkdtemp(join(tmpdir(), 'orca-agy-recorded-'))
|
||||
try {
|
||||
const home = homedir()
|
||||
const rootPresence = await Promise.all(
|
||||
['antigravity-cli', 'antigravity-ide', 'antigravity'].map(async (origin) => {
|
||||
const present = await stat(join(home, '.gemini', origin, 'brain')).then(
|
||||
(value) => value.isDirectory(),
|
||||
() => false
|
||||
)
|
||||
return { origin, present }
|
||||
})
|
||||
)
|
||||
const result = await scanAiVaultSessions({
|
||||
...isolatedScanRoots(sandbox),
|
||||
antigravityBrainDir: join(home, '.gemini', 'antigravity-cli', 'brain'),
|
||||
antigravityAppHome: home,
|
||||
includeAntigravityIdeSessions: true,
|
||||
limit: 5,
|
||||
limitPerAgent: 10
|
||||
})
|
||||
expect(result.issues.length).toBe(0)
|
||||
expect(result.sessions.length).toBeGreaterThan(0)
|
||||
expect(result.sessions.length).toBeLessThanOrEqual(5)
|
||||
expect(result.sessions.some((session) => session.messageCount > 0)).toBe(true)
|
||||
const proof = {
|
||||
provenance:
|
||||
'Existing official recorded artifacts through production scanner; no live IDE/model claim',
|
||||
rootPresence,
|
||||
sessionCount: result.sessions.length,
|
||||
issueCount: result.issues.length,
|
||||
origins: result.sessions.map((session) => ({
|
||||
origin: antigravitySessionOrigin(session.filePath),
|
||||
fullTranscript: session.filePath.endsWith('transcript_full.jsonl'),
|
||||
messageCount: session.messageCount,
|
||||
hasWorkspace: session.cwd !== null,
|
||||
cliResume: session.resumeCommand.includes('--conversation')
|
||||
}))
|
||||
}
|
||||
if (process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH) {
|
||||
await writeFile(
|
||||
process.env.ORCA_ANTIGRAVITY_HISTORY_PROOF_PATH,
|
||||
`${JSON.stringify(proof, null, 2)}\n`
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import { dirname, join } from 'node:path'
|
||||
import { wslGatedLstat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { recordSessionScanIssue } from './session-scan-issues'
|
||||
import type { FileWithMtime, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
function transcriptDirectory(file: FileWithMtime): string {
|
||||
return file.path
|
||||
.split(/[\\/]+/)
|
||||
.slice(0, -1)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/** Keep the file's own stat intact; alias recency only drives discovery/cutoff. */
|
||||
export function prioritizeAntigravityTranscriptCandidates<T extends { file: FileWithMtime }>(
|
||||
candidates: T[],
|
||||
isAntigravity: (candidate: T) => boolean
|
||||
): T[] {
|
||||
const newest = new Map<string, number>()
|
||||
for (const candidate of candidates) {
|
||||
if (isAntigravity(candidate)) {
|
||||
const key = transcriptDirectory(candidate.file)
|
||||
newest.set(key, Math.max(newest.get(key) ?? 0, candidate.file.mtimeMs))
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
.map((candidate) =>
|
||||
isAntigravity(candidate)
|
||||
? {
|
||||
...candidate,
|
||||
file: {
|
||||
...candidate.file,
|
||||
aliasMtimeMs: newest.get(transcriptDirectory(candidate.file))
|
||||
}
|
||||
}
|
||||
: candidate
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const difference = (candidateFileTime(right.file) ?? 0) - (candidateFileTime(left.file) ?? 0)
|
||||
if (difference !== 0) {
|
||||
return difference
|
||||
}
|
||||
if (
|
||||
isAntigravity(left) &&
|
||||
isAntigravity(right) &&
|
||||
transcriptDirectory(left.file) === transcriptDirectory(right.file)
|
||||
) {
|
||||
return (
|
||||
Number(right.file.path.endsWith('transcript_full.jsonl')) -
|
||||
Number(left.file.path.endsWith('transcript_full.jsonl'))
|
||||
)
|
||||
}
|
||||
return 0
|
||||
})
|
||||
}
|
||||
|
||||
export function candidateFileTime(file: FileWithMtime | undefined): number | undefined {
|
||||
return file?.aliasMtimeMs ?? file?.mtimeMs
|
||||
}
|
||||
|
||||
/** The recency cap may retain a newer compact file but omit its older full sibling. */
|
||||
export async function completeAntigravityTranscriptPairs(
|
||||
discovery: SessionFileDiscovery,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery> {
|
||||
const files = new Map(discovery.files.map((file) => [file.path, file]))
|
||||
for (const file of discovery.files) {
|
||||
const sibling = join(
|
||||
dirname(file.path),
|
||||
file.path.endsWith('transcript_full.jsonl') ? 'transcript.jsonl' : 'transcript_full.jsonl'
|
||||
)
|
||||
if (files.has(sibling)) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const observed = await wslGatedLstat(sibling, 'scan')
|
||||
if (observed.isFile()) {
|
||||
files.set(sibling, {
|
||||
path: sibling,
|
||||
mtimeMs: observed.mtimeMs,
|
||||
modifiedAt: new Date(observed.mtimeMs).toISOString(),
|
||||
sizeBytes: observed.size,
|
||||
dev: observed.dev,
|
||||
ino: observed.ino,
|
||||
nlink: observed.nlink
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
recordSessionScanIssue(issues, {
|
||||
agent: 'antigravity',
|
||||
path: sibling,
|
||||
message: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...discovery, files: [...files.values()] }
|
||||
}
|
||||
@@ -85,7 +85,10 @@ export async function listAiVaultSessions(
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
const key = JSON.stringify({ scopePaths: [...new Set(args?.scopePaths ?? [])].sort() })
|
||||
const key = JSON.stringify({
|
||||
scopePaths: [...new Set(args?.scopePaths ?? [])].sort(),
|
||||
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
|
||||
})
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scanKey = JSON.stringify({ key, depth })
|
||||
const now = Date.now()
|
||||
@@ -113,6 +116,7 @@ export async function listAiVaultSessions(
|
||||
start: async (scanSignal) => {
|
||||
const result = await scanAiVaultSessionsInBackground(
|
||||
{
|
||||
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
scopePaths: args?.scopePaths,
|
||||
|
||||
@@ -16,11 +16,15 @@ export function limitRemoteScanFilesystemConcurrency(
|
||||
return {
|
||||
openCode: provider.openCode,
|
||||
readDir: (dirPath) => gate(() => provider.readDir(dirPath)),
|
||||
readFile: (filePath) => gate(() => provider.readFile(filePath)),
|
||||
readFile: (filePath, limits) => gate(() => provider.readFile(filePath, limits)),
|
||||
stat: (filePath) => gate(() => provider.stat(filePath)),
|
||||
...(provider.readTranscriptBytes
|
||||
? {
|
||||
readTranscriptBytes: async function* (path: string, signal?: AbortSignal) {
|
||||
readTranscriptBytes: async function* (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { regularFileOnly: true; maxBytes: number }
|
||||
) {
|
||||
let enter!: () => void
|
||||
let release!: () => void
|
||||
const entered = new Promise<void>((resolve) => {
|
||||
@@ -35,7 +39,7 @@ export function limitRemoteScanFilesystemConcurrency(
|
||||
})
|
||||
await entered
|
||||
try {
|
||||
yield* provider.readTranscriptBytes!(path, signal)
|
||||
yield* provider.readTranscriptBytes!(path, signal, options)
|
||||
} finally {
|
||||
release()
|
||||
await held
|
||||
|
||||
@@ -121,7 +121,11 @@ async function listRemoteFixedChildFiles(
|
||||
// serialized SSH readDir round trips for every conversation directory.
|
||||
return entries
|
||||
.filter((entry) => entry.isDirectory && !entry.isSymlink)
|
||||
.map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments))
|
||||
.flatMap((entry) =>
|
||||
[segments, ...(source.additionalFixedChildFileSegments ?? [])].map((fileSegments) =>
|
||||
joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...fileSegments)
|
||||
)
|
||||
)
|
||||
.filter((path) => source.filePredicate?.(path) ?? true)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
ANTIGRAVITY_HISTORY_ROOTS,
|
||||
type AntigravitySessionOrigin
|
||||
} from '../../shared/antigravity-session-origin'
|
||||
import { parseQoderSessionContent } from './session-scanner-qoder-parser'
|
||||
import { remoteSessionDocumentParsers } from './remote-session-document-parsers'
|
||||
import type { RemoteSessionContent } from './remote-session-content-lines'
|
||||
@@ -48,7 +52,8 @@ type RemoteContentParser<T = string> = (
|
||||
|
||||
export function remoteSessionSources(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
includeAntigravityIdeSessions = false
|
||||
): RemoteSessionSource[] {
|
||||
return [
|
||||
...remoteCodexSources(remoteHome, hostPlatform),
|
||||
@@ -88,7 +93,10 @@ export function remoteSessionSources(
|
||||
),
|
||||
partitionSubagentTranscripts: partitionSubagentTranscriptPaths
|
||||
},
|
||||
remoteAntigravitySource(remoteHome, hostPlatform),
|
||||
...(includeAntigravityIdeSessions
|
||||
? ANTIGRAVITY_HISTORY_ROOTS
|
||||
: (['antigravity-cli'] as const)
|
||||
).map((origin) => remoteAntigravitySource(remoteHome, hostPlatform, origin)),
|
||||
source(
|
||||
'gemini',
|
||||
remoteHome,
|
||||
@@ -168,9 +176,10 @@ export function remoteSessionSources(
|
||||
|
||||
function remoteAntigravitySource(
|
||||
remoteHome: string,
|
||||
hostPlatform: RemoteHostPlatform
|
||||
hostPlatform: RemoteHostPlatform,
|
||||
origin: AntigravitySessionOrigin
|
||||
): RemoteSessionSource {
|
||||
const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli')
|
||||
const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', origin)
|
||||
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
|
||||
const parse = async (
|
||||
file: FileWithMtime,
|
||||
@@ -192,6 +201,7 @@ function remoteAntigravitySource(
|
||||
extensions: ['.jsonl'],
|
||||
filePredicate: isAntigravityTranscriptPath,
|
||||
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
|
||||
additionalFixedChildFileSegments: [['.system_generated', 'logs', 'transcript_full.jsonl']],
|
||||
parse,
|
||||
parseLines: parse
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ export type RemoteSessionFilesystemProvider = Pick<
|
||||
'readDir' | 'readFile' | 'stat'
|
||||
> & {
|
||||
/** Available only beside the execution host's disk; never opens a client path. */
|
||||
readTranscriptBytes?: (path: string, signal?: AbortSignal) => AsyncIterable<Buffer>
|
||||
readTranscriptBytes?: (
|
||||
path: string,
|
||||
signal?: AbortSignal,
|
||||
options?: { regularFileOnly: true; maxBytes: number }
|
||||
) => AsyncIterable<Buffer>
|
||||
/** Execution-host database access; absent from remote filesystem RPC providers. */
|
||||
openCode?: RemoteOpenCodeSessionReader
|
||||
}
|
||||
@@ -55,6 +59,7 @@ export type RemoteSessionSource = {
|
||||
directoryPredicate?: (name: string, depth: number) => boolean
|
||||
// A canonical file directly beneath every top-level session directory.
|
||||
fixedChildFileSegments?: readonly string[]
|
||||
additionalFixedChildFileSegments?: readonly (readonly string[])[]
|
||||
// Sibling-subagent layouts (Claude `<session>/subagents/`, OMP's same-named
|
||||
// artifact dir): count subagent transcripts from the walked listing and drop
|
||||
// them from candidates instead of indexing them as sessions.
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
candidateFileTime,
|
||||
prioritizeAntigravityTranscriptCandidates
|
||||
} from './antigravity-transcript-candidates'
|
||||
import { readRemoteAntigravityIndex } from './antigravity-index-reader'
|
||||
import { parseRemoteSessionTranscript } from './remote-session-transcript-read'
|
||||
import { BinarySessionTranscriptError } from './remote-session-content-lines'
|
||||
import type {
|
||||
@@ -52,6 +57,7 @@ export async function scanRemoteAiVaultSessions(args: {
|
||||
executionHostId: ExecutionHostId
|
||||
remoteHome: string
|
||||
hostPlatform: RemoteHostPlatform
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
scopePaths?: readonly string[]
|
||||
@@ -69,25 +75,19 @@ export async function scanRemoteAiVaultSessions(args: {
|
||||
hostPlatform: args.hostPlatform,
|
||||
signal: args.signal,
|
||||
titleCaches: new Map(),
|
||||
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver(async (historyPath) => {
|
||||
try {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
const read = await provider.readFile(historyPath)
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
return read.isBinary ? null : read.content
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
})
|
||||
antigravityWorkspaceResolver: createAntigravityWorkspaceResolver((path) =>
|
||||
readRemoteAntigravityIndex(provider, path, args.signal)
|
||||
)
|
||||
}
|
||||
const candidates = dedupeCodexRolloutFileAliases(
|
||||
const discoveredCandidates = dedupeCodexRolloutFileAliases(
|
||||
(
|
||||
await mapRemoteScanBatches(
|
||||
[
|
||||
...remoteSessionSources(args.remoteHome, args.hostPlatform),
|
||||
...remoteSessionSources(
|
||||
args.remoteHome,
|
||||
args.hostPlatform,
|
||||
args.includeAntigravityIdeSessions
|
||||
),
|
||||
...remoteOpenCodeSources(
|
||||
provider.openCode,
|
||||
limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER +
|
||||
@@ -109,6 +109,10 @@ export async function scanRemoteAiVaultSessions(args: {
|
||||
}
|
||||
)
|
||||
|
||||
const candidates = prioritizeAntigravityTranscriptCandidates(
|
||||
discoveredCandidates,
|
||||
(candidate) => candidate.source.agent === 'antigravity'
|
||||
)
|
||||
const parsed = await parseRemoteSessionCandidates({
|
||||
candidates: candidates.slice(0, limit * REMOTE_PARSE_CANDIDATE_MULTIPLIER),
|
||||
context,
|
||||
@@ -153,7 +157,9 @@ async function parseRemoteSessionCandidates(args: {
|
||||
let index = 0
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
if (
|
||||
canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export async function scanRuntimeAiVaultSessions(
|
||||
environmentId,
|
||||
'aiVault.listSessions',
|
||||
{
|
||||
includeAntigravityIdeSessions: args.includeAntigravityIdeSessions,
|
||||
limit: args.limit,
|
||||
unlimited: args.unlimited,
|
||||
force: args.force,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import {
|
||||
ensureSessionParseCacheLoaded,
|
||||
flushSessionParseCachePersist,
|
||||
initSessionParseCachePersistence,
|
||||
resetSessionParseCachePersistenceForTests,
|
||||
scheduleSessionParseCachePersist
|
||||
} from './session-parse-cache-persistence'
|
||||
import {
|
||||
createSessionParseStats,
|
||||
parseAgentSessionFileCached,
|
||||
resetSessionParseCacheForTests,
|
||||
snapshotSessionParseCacheForPersistence
|
||||
} from './session-scanner-parse-cache'
|
||||
import { createAntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
|
||||
import { writeAntigravityTranscript } from './session-scanner-test-fixtures'
|
||||
import type { SessionFileCandidate } from './session-scanner-types'
|
||||
|
||||
let root: string | undefined
|
||||
afterEach(async () => {
|
||||
resetSessionParseCacheForTests()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
async function candidate(): Promise<SessionFileCandidate> {
|
||||
root = await mkdtemp(join(tmpdir(), 'orca-agy-cache-upgrade-'))
|
||||
const path = await writeAntigravityTranscript(
|
||||
join(root, 'brain'),
|
||||
'conversation',
|
||||
Array.from({ length: 8 }, (_, index) => ({
|
||||
source: 'USER',
|
||||
type: 'REQUEST',
|
||||
created_at: new Date(Date.parse('2026-07-15T11:39:10Z') + index * 60_000).toISOString(),
|
||||
content: index === 0 ? 'Original request' : `Follow-up ${index}`
|
||||
}))
|
||||
)
|
||||
const fileStat = await stat(path)
|
||||
return {
|
||||
agent: 'antigravity',
|
||||
codexHome: null,
|
||||
file: {
|
||||
path,
|
||||
mtimeMs: fileStat.mtimeMs,
|
||||
modifiedAt: fileStat.mtime.toISOString(),
|
||||
sizeBytes: fileStat.size
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
it('reparses an unchanged pre-fix cached Antigravity transcript after an update', async () => {
|
||||
const input = await candidate()
|
||||
await parseAgentSessionFileCached(input, 'linux')
|
||||
const entries = snapshotSessionParseCacheForPersistence().map(([path, entry]) => [
|
||||
path,
|
||||
{
|
||||
...entry,
|
||||
session: entry.session && { ...entry.session, antigravityOpeningPrompt: undefined }
|
||||
}
|
||||
])
|
||||
const cacheFile = join(root ?? '', 'cache.json')
|
||||
await writeFile(cacheFile, JSON.stringify({ schemaVersion: 3, appVersion: 'pre-fix', entries }))
|
||||
resetSessionParseCacheForTests()
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
|
||||
await ensureSessionParseCacheLoaded()
|
||||
const stats = createSessionParseStats()
|
||||
const session = await parseAgentSessionFileCached(input, 'linux', stats)
|
||||
expect(stats.fullParses).toBe(1)
|
||||
expect(stats.reused).toBe(0)
|
||||
if (!session) {
|
||||
throw new Error('Missing reparsed session')
|
||||
}
|
||||
expect(session.previewMessagesTruncated).toBe(true)
|
||||
const resolver = createAntigravityWorkspaceResolver(async (path) =>
|
||||
path === 'history.jsonl'
|
||||
? JSON.stringify({
|
||||
display: 'Original request',
|
||||
timestamp: Date.parse('2026-07-15T11:39:10Z'),
|
||||
workspace: '/repo/original'
|
||||
})
|
||||
: null
|
||||
)
|
||||
expect((await resolver.enrich(session, 'history.jsonl')).cwd).toBe('/repo/original')
|
||||
})
|
||||
|
||||
it('retains the opening identity in current caches without retaining full prompt text', async () => {
|
||||
const input = await candidate()
|
||||
const cacheFile = join(root ?? '', 'cache.json')
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'fixed' })
|
||||
const stats = createSessionParseStats()
|
||||
const original = await parseAgentSessionFileCached(input, 'linux', stats)
|
||||
scheduleSessionParseCachePersist(stats)
|
||||
await flushSessionParseCachePersist()
|
||||
resetSessionParseCacheForTests()
|
||||
resetSessionParseCachePersistenceForTests()
|
||||
initSessionParseCachePersistence({ filePath: cacheFile, appVersion: 'next-release' })
|
||||
await ensureSessionParseCacheLoaded()
|
||||
const reusedStats = createSessionParseStats()
|
||||
const reused = await parseAgentSessionFileCached(input, 'linux', reusedStats)
|
||||
expect(reused).toEqual(original)
|
||||
expect(reusedStats.reused).toBe(1)
|
||||
expect(reusedStats.fullParses).toBe(0)
|
||||
expect(reused?.antigravityOpeningPrompt?.timestamp).toBe('2026-07-15T11:39:10.000Z')
|
||||
expect(reused?.firstUserPrompt).toBeUndefined()
|
||||
})
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
SESSION_PARSE_CACHE_JSON_LIMITS,
|
||||
SESSION_PARSE_CACHE_MAX_BYTES,
|
||||
SESSION_PARSE_CACHE_SCHEMA_VERSION,
|
||||
serializeSessionParseCacheSnapshotPiecesCooperatively
|
||||
} from './session-parse-cache-snapshot-serialization'
|
||||
|
||||
@@ -59,7 +60,14 @@ it('loads the newest 4096 unique rows across duplicate-heavy input and keeps in-
|
||||
entries.push(...Array.from({ length: 5000 }, (_, index) => row('p4199', 5000 + index)))
|
||||
entries.push(row('in-process', 1))
|
||||
seedSessionParseCache([row('in-process', 20_000)])
|
||||
await writeFile(file, JSON.stringify({ schemaVersion: 3, appVersion: 'old-release', entries }))
|
||||
await writeFile(
|
||||
file,
|
||||
JSON.stringify({
|
||||
schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
|
||||
appVersion: 'old-release',
|
||||
entries
|
||||
})
|
||||
)
|
||||
await ensureSessionParseCacheLoaded()
|
||||
expect(snapshotSessionParseCacheForPersistence()).toHaveLength(4096)
|
||||
expect(getSessionParseCacheEntry('p4199')?.mtimeMs).toBe(9999)
|
||||
@@ -93,10 +101,10 @@ it('rejects an oversized sparse file before decoding and preserves resident work
|
||||
|
||||
it('rejects excessive tokens, depth, and malformed older rows before seeding any tail', async () => {
|
||||
for (const raw of [
|
||||
`{"schemaVersion":3,"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
|
||||
`{"schemaVersion":3,"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
|
||||
`{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"wide":[${'0,'.repeat(1_000_000)}0]}`,
|
||||
`{"schemaVersion":${SESSION_PARSE_CACHE_SCHEMA_VERSION},"appVersion":"old","entries":[],"deep":${'['.repeat(33)}0${']'.repeat(33)}}`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
schemaVersion: SESSION_PARSE_CACHE_SCHEMA_VERSION,
|
||||
appVersion: 'old',
|
||||
entries: [['bad', {}], ...Array.from({ length: 4200 }, (_, index) => row(`p${index}`, index))]
|
||||
})
|
||||
|
||||
@@ -162,6 +162,7 @@ const CACHED_SESSION_FIELDS = {
|
||||
totalTokens: true,
|
||||
previewMessages: true,
|
||||
previewMessagesTruncated: true,
|
||||
antigravityOpeningPrompt: true,
|
||||
firstUserPrompt: true,
|
||||
lastUserPrompt: true,
|
||||
queuedMessageCount: true,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '../../shared/node-bounded-json-stringify'
|
||||
import type { PersistedSessionParseCacheEntry } from './session-parse-cache-store'
|
||||
|
||||
export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 3
|
||||
export const SESSION_PARSE_CACHE_SCHEMA_VERSION = 4
|
||||
export const SESSION_PARSE_CACHE_MAX_BYTES = 64 * 1024 * 1024
|
||||
export const SESSION_PARSE_CACHE_JSON_LIMITS = {
|
||||
structuralTokens: 1_000_000,
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { antigravitySessionOrigin } from '../../shared/antigravity-session-origin'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { codexSessionAliasKey, codexSessionAliasBeats } from './codex-session-root-dedup'
|
||||
import { sessionSortTime } from './session-scanner-accumulator'
|
||||
|
||||
function sessionAliasKey(session: AiVaultSession): string | null {
|
||||
if (session.agent === 'antigravity') {
|
||||
const root = session.filePath
|
||||
.split(/[\\/]+/)
|
||||
.slice(0, -4)
|
||||
.join('/')
|
||||
return `antigravity\0${session.executionHostId}\0${antigravitySessionOrigin(session.filePath) ?? 'cli'}\0${root}\0${session.sessionId}`
|
||||
}
|
||||
if (session.agent !== 'devin') {
|
||||
const codexKey = codexSessionAliasKey(session)
|
||||
return codexKey ? `codex\0${codexKey}` : null
|
||||
@@ -13,6 +21,16 @@ function sessionAliasKey(session: AiVaultSession): string | null {
|
||||
}
|
||||
|
||||
function sessionAliasBeats(candidate: AiVaultSession, best: AiVaultSession): boolean {
|
||||
if (candidate.agent === 'antigravity') {
|
||||
if (candidate.messageCount > 0 !== best.messageCount > 0) {
|
||||
return candidate.messageCount > 0
|
||||
}
|
||||
const full = (path: string): boolean => path.split(/[\\/]+/).at(-1) === 'transcript_full.jsonl'
|
||||
if (full(candidate.filePath) !== full(best.filePath)) {
|
||||
return full(candidate.filePath)
|
||||
}
|
||||
return sessionSortTime(candidate) > sessionSortTime(best)
|
||||
}
|
||||
if (candidate.agent !== 'devin') {
|
||||
return codexSessionAliasBeats(candidate, best)
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ export function finalizeSession(
|
||||
totalTokens: accumulator.totalTokens,
|
||||
previewMessages: accumulator.previewMessages,
|
||||
...(accumulator.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}),
|
||||
...(accumulator.antigravityOpeningPrompt
|
||||
? { antigravityOpeningPrompt: accumulator.antigravityOpeningPrompt }
|
||||
: {}),
|
||||
...(accumulator.firstUserPrompt ? { firstUserPrompt: accumulator.firstUserPrompt } : {}),
|
||||
...(accumulator.lastUserPrompt ? { lastUserPrompt: accumulator.lastUserPrompt } : {}),
|
||||
queuedMessageCount: accumulator.queuedMessageCount,
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import {
|
||||
ANTIGRAVITY_INDEX_MAX_BYTES,
|
||||
antigravityCachePath,
|
||||
antigravityMetadataWorkspaces,
|
||||
readBoundedAntigravityIndex
|
||||
} from './session-scanner-antigravity-metadata'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import { wslGatedReadFile } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { openTranscriptReadStream, wslGatedStat } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { readNodeFileWithinLimit } from '../../shared/node-bounded-file-reader'
|
||||
import { isWslUncPath } from '../../shared/wsl-paths'
|
||||
import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation'
|
||||
import { WslTranscriptFsError } from '../native-chat/wsl-transcript-fs-gate'
|
||||
import { normalizeTitleText, parseJsonObject, timestampMs } from './session-scanner-values'
|
||||
import { parseJsonObject, timestampMs } from './session-scanner-values'
|
||||
import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
|
||||
|
||||
const HISTORY_MATCH_WINDOW_MS = 2_000
|
||||
|
||||
@@ -12,10 +22,39 @@ const HISTORY_MATCH_WINDOW_MS = 2_000
|
||||
* null lists the session with a missing cwd and no retry signal, and the
|
||||
* resolver's memo relies on the rejection to evict rather than pin a stall.
|
||||
*/
|
||||
export async function readLocalAntigravityHistory(path: string): Promise<string | null> {
|
||||
export async function readLocalAntigravityHistory(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
return await wslGatedReadFile(path, 'utf-8', 'scan')
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (!isWslUncPath(path)) {
|
||||
const read = await readNodeFileWithinLimit(path, ANTIGRAVITY_INDEX_MAX_BYTES, {
|
||||
regularFileOnly: true,
|
||||
signal
|
||||
})
|
||||
return read.buffer.toString('utf8')
|
||||
}
|
||||
const stats = await wslGatedStat(path, 'scan', signal)
|
||||
if (!stats.isFile() || stats.size > ANTIGRAVITY_INDEX_MAX_BYTES) {
|
||||
return null
|
||||
}
|
||||
const input = openTranscriptReadStream(
|
||||
path,
|
||||
{ end: ANTIGRAVITY_INDEX_MAX_BYTES },
|
||||
'scan',
|
||||
signal
|
||||
)
|
||||
try {
|
||||
return await readBoundedAntigravityIndex(input)
|
||||
} finally {
|
||||
input.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw error
|
||||
}
|
||||
if (error instanceof WslTranscriptFsError) {
|
||||
throw error
|
||||
}
|
||||
@@ -28,7 +67,10 @@ type AntigravityHistoryEntry = {
|
||||
workspace: string
|
||||
}
|
||||
|
||||
type AntigravityHistoryIndex = Map<string, AntigravityHistoryEntry[]>
|
||||
type AntigravityHistoryIndex = {
|
||||
byPrompt: Map<string, AntigravityHistoryEntry[]>
|
||||
byId: Map<string, string | null>
|
||||
}
|
||||
|
||||
export type AntigravityWorkspaceResolver = {
|
||||
enrich(session: AiVaultSession, historyPath: string): Promise<AiVaultSession>
|
||||
@@ -50,7 +92,27 @@ export function createAntigravityWorkspaceResolver(
|
||||
// so it must not be memoized — every later session under this history
|
||||
// file would inherit the rejection for the process lifetime.
|
||||
const pending: Promise<AntigravityHistoryIndex> = readHistory(historyPath)
|
||||
.then(indexAntigravityHistory)
|
||||
.then(async (history) => {
|
||||
const index = indexAntigravityHistory(history)
|
||||
const [metadata, projects, lastConversations] = await Promise.all(
|
||||
['conversation_metadata.json', 'projects.json', 'last_conversations.json'].map(
|
||||
(name) => readHistory(antigravityCachePath(historyPath, name))
|
||||
)
|
||||
)
|
||||
const paths = antigravityMetadataWorkspaces({
|
||||
metadata: metadata ?? null,
|
||||
projects: projects ?? null,
|
||||
lastConversations: lastConversations ?? null
|
||||
})
|
||||
for (const [id, path] of paths) {
|
||||
if (!index.byId.has(id)) {
|
||||
index.byId.set(id, path)
|
||||
} else if (index.byId.get(id) !== path) {
|
||||
index.byId.set(id, null)
|
||||
}
|
||||
}
|
||||
return index
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (indexes.get(historyPath) === pending) {
|
||||
indexes.delete(historyPath)
|
||||
@@ -67,18 +129,32 @@ export function createAntigravityWorkspaceResolver(
|
||||
}
|
||||
|
||||
function indexAntigravityHistory(content: string | null): AntigravityHistoryIndex {
|
||||
const index: AntigravityHistoryIndex = new Map()
|
||||
for (const line of content?.split(/\r?\n/) ?? []) {
|
||||
const index: AntigravityHistoryIndex = { byPrompt: new Map(), byId: new Map() }
|
||||
if (content && Buffer.byteLength(content) > ANTIGRAVITY_INDEX_MAX_BYTES) {
|
||||
return index
|
||||
}
|
||||
for (const line of content?.split(/\r?\n/).slice(0, 10_000) ?? []) {
|
||||
const record = parseJsonObject(line)
|
||||
const display = typeof record?.display === 'string' ? normalizeTitleText(record.display) : null
|
||||
const promptHash = antigravityHistoryPromptHash(record?.display)
|
||||
const workspace = typeof record?.workspace === 'string' ? record.workspace.trim() : ''
|
||||
const entryTimestampMs = timestampMs(record?.timestamp)
|
||||
if (!display || !workspace || !Number.isFinite(entryTimestampMs)) {
|
||||
const id = typeof record?.conversationId === 'string' ? record.conversationId : null
|
||||
if (!workspace || workspace.length > 4096 || !Number.isFinite(entryTimestampMs)) {
|
||||
continue
|
||||
}
|
||||
const entries = index.get(display) ?? []
|
||||
if (id) {
|
||||
if (!index.byId.has(id)) {
|
||||
index.byId.set(id, workspace)
|
||||
} else if (index.byId.get(id) !== workspace) {
|
||||
index.byId.set(id, null)
|
||||
}
|
||||
}
|
||||
if (!promptHash) {
|
||||
continue
|
||||
}
|
||||
const entries = index.byPrompt.get(promptHash) ?? []
|
||||
entries.push({ timestampMs: entryTimestampMs, workspace })
|
||||
index.set(display, entries)
|
||||
index.byPrompt.set(promptHash, entries)
|
||||
}
|
||||
return index
|
||||
}
|
||||
@@ -87,22 +163,18 @@ function findAntigravityWorkspace(
|
||||
session: AiVaultSession,
|
||||
index: AntigravityHistoryIndex
|
||||
): string | null {
|
||||
// Why: truncated titles are not prompt identities; long worker prompts often
|
||||
// share the same 96-character prefix across unrelated workspaces.
|
||||
if (session.title.endsWith('...')) {
|
||||
if (index.byId.has(session.sessionId)) {
|
||||
return index.byId.get(session.sessionId) ?? null
|
||||
}
|
||||
const opening = session.antigravityOpeningPrompt
|
||||
const promptTimestampMs = timestampMs(opening?.timestamp)
|
||||
// Titles, createdAt and rolling previews cannot identify the original prompt.
|
||||
if (!opening || !Number.isFinite(promptTimestampMs)) {
|
||||
return null
|
||||
}
|
||||
const firstTitledUserTimestamp = session.previewMessages.find(
|
||||
(message) => message.role === 'user' && normalizeTitleText(message.text) === session.title
|
||||
)?.timestamp
|
||||
const promptTimestampMs = timestampMs(firstTitledUserTimestamp ?? session.createdAt)
|
||||
if (!Number.isFinite(promptTimestampMs)) {
|
||||
return null
|
||||
}
|
||||
const matches = (index.get(session.title) ?? []).filter(
|
||||
const matches = (index.byPrompt.get(opening.hash) ?? []).filter(
|
||||
(entry) => Math.abs(entry.timestampMs - promptTimestampMs) <= HISTORY_MATCH_WINDOW_MS
|
||||
)
|
||||
// Why: history rows have no conversation id. A unique prompt/time match is
|
||||
// evidence for cwd; ambiguity must stay unknown instead of crossing projects.
|
||||
// Exact prompt/time fallback is valid only when a single history row matches.
|
||||
return matches.length === 1 ? (matches[0]?.workspace ?? null) : null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { parseJsonObject } from './session-scanner-values'
|
||||
|
||||
export const ANTIGRAVITY_INDEX_MAX_BYTES = 4 * 1024 * 1024
|
||||
const MAX_INDEX_ENTRIES = 10_000
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
function record(value: unknown): JsonRecord | null {
|
||||
return isRecord(value) ? value : null
|
||||
}
|
||||
function entries(value: unknown): [string, unknown][] {
|
||||
const object = record(value)
|
||||
return object ? Object.entries(object).slice(0, MAX_INDEX_ENTRIES) : []
|
||||
}
|
||||
function parse(content: string | null): JsonRecord | null {
|
||||
return content && Buffer.byteLength(content) <= ANTIGRAVITY_INDEX_MAX_BYTES
|
||||
? parseJsonObject(content)
|
||||
: null
|
||||
}
|
||||
function unique(map: Map<string, string | null>, id: string, path: string | null): void {
|
||||
if (!id || id.length > 512 || (path !== null && (!path || path.length > 4096))) {
|
||||
return
|
||||
}
|
||||
if (map.has(id) && map.get(id) !== path) {
|
||||
map.set(id, null)
|
||||
} else if (!map.has(id)) {
|
||||
map.set(id, path)
|
||||
}
|
||||
}
|
||||
|
||||
export function antigravityCachePath(historyPath: string, fileName: string): string {
|
||||
const separator = historyPath.includes('\\') ? '\\' : '/'
|
||||
return `${historyPath.replace(/[\\/]history\.jsonl$/, '') + separator}cache${separator}${fileName}`
|
||||
}
|
||||
|
||||
export function antigravityMetadataWorkspaces(contents: {
|
||||
metadata: string | null
|
||||
projects: string | null
|
||||
lastConversations: string | null
|
||||
}): Map<string, string | null> {
|
||||
const projectPaths = new Map<string, string | null>()
|
||||
for (const [key, value] of entries(parse(contents.projects))) {
|
||||
if (typeof value !== 'string') {
|
||||
continue
|
||||
}
|
||||
if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(key)) {
|
||||
unique(projectPaths, value, key)
|
||||
} else if (/^(?:[/\\]|[A-Za-z]:[/\\])/.test(value)) {
|
||||
unique(projectPaths, key, value)
|
||||
}
|
||||
}
|
||||
const paths = new Map<string, string | null>()
|
||||
for (const [path, id] of entries(parse(contents.lastConversations))) {
|
||||
if (typeof id === 'string' && /^(?:[/\\]|[A-Za-z]:[/\\])/.test(path)) {
|
||||
unique(paths, id, path)
|
||||
}
|
||||
}
|
||||
for (const [id, value] of entries(parse(contents.metadata)?.conversations)) {
|
||||
const summary = record(record(value)?.summary)
|
||||
const projectId = summary?.ProjectID
|
||||
const path = typeof projectId === 'string' ? projectPaths.get(projectId) : undefined
|
||||
if (path !== undefined) {
|
||||
unique(paths, id, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
export async function readBoundedAntigravityIndex(
|
||||
chunks: AsyncIterable<Buffer>
|
||||
): Promise<string | null> {
|
||||
const retained: Buffer[] = []
|
||||
let bytes = 0
|
||||
for await (const chunk of chunks) {
|
||||
bytes += chunk.length
|
||||
if (bytes > ANTIGRAVITY_INDEX_MAX_BYTES) {
|
||||
return null
|
||||
}
|
||||
retained.push(chunk)
|
||||
}
|
||||
return Buffer.concat(retained, bytes).toString('utf8')
|
||||
}
|
||||
@@ -2,17 +2,19 @@ import {
|
||||
remoteSessionContentLines,
|
||||
type RemoteSessionContent
|
||||
} from './remote-session-content-lines'
|
||||
import { openTranscriptReadStream } from '../native-chat/wsl-transcript-fs-access'
|
||||
import { createInterface } from 'node:readline'
|
||||
import { consumeCompleteJsonlLines } from './session-scanner-jsonl-reader'
|
||||
import { MAX_SESSION_TRANSCRIPT_RECORD_BYTES } from './session-transcript-record-budget'
|
||||
import type { AiVaultSession } from '../../shared/ai-vault-types'
|
||||
import type { ExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
accumulatorFoldResumeState,
|
||||
addPreviewMessage,
|
||||
createAccumulator,
|
||||
timestampIso,
|
||||
updateTimeline
|
||||
} from './session-scanner-accumulator'
|
||||
import { antigravityConversationIdFromTranscriptPath } from './session-scanner-antigravity-paths'
|
||||
import { antigravityHistoryPromptHash } from './antigravity-history-prompt'
|
||||
import type {
|
||||
FileWithMtime,
|
||||
ResumableSessionParseState,
|
||||
@@ -31,16 +33,16 @@ export async function parseAntigravitySessionFile(
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
messages?: TranscriptMessageSink
|
||||
): Promise<AiVaultSession | null> {
|
||||
const input = openTranscriptReadStream(file.path, { encoding: 'utf-8' }, 'scan')
|
||||
const lines = createInterface({ input, crlfDelay: Infinity })
|
||||
try {
|
||||
return await parseAntigravitySessionLines({ file, lines, platform, messages })
|
||||
} finally {
|
||||
// readline.close() leaves the underlying stream open; destroy it so a
|
||||
// mid-parse throw cannot leak the gated transcript handle.
|
||||
lines.close()
|
||||
input.destroy()
|
||||
const state = createAntigravitySessionResumeState(file, messages)
|
||||
const read = await consumeCompleteJsonlLines({
|
||||
path: file.path,
|
||||
start: 0,
|
||||
onLine: (line) => state.consumeLine(line)
|
||||
})
|
||||
if (read.trailingPartialLine) {
|
||||
state.consumeLine(read.trailingPartialLine)
|
||||
}
|
||||
return state.finalize(platform)
|
||||
}
|
||||
|
||||
export async function parseAntigravitySessionContent(
|
||||
@@ -86,6 +88,9 @@ async function parseAntigravitySessionLines(args: {
|
||||
}
|
||||
|
||||
function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: string): void {
|
||||
if (Buffer.byteLength(line) > MAX_SESSION_TRANSCRIPT_RECORD_BYTES) {
|
||||
return
|
||||
}
|
||||
const record = parseJsonObject(line)
|
||||
if (!record) {
|
||||
return
|
||||
@@ -105,6 +110,12 @@ function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: str
|
||||
return
|
||||
}
|
||||
accumulator.messageCount++
|
||||
if (accumulator.antigravityOpeningPrompt === undefined) {
|
||||
const hash = antigravityHistoryPromptHash(request)
|
||||
accumulator.antigravityOpeningPrompt = hash
|
||||
? { hash, timestamp: timestampIso(record.created_at) }
|
||||
: null
|
||||
}
|
||||
accumulator.title ??= normalizeTitleText(request)
|
||||
addPreviewMessage(accumulator, { role: 'user', text: request, timestamp: record.created_at })
|
||||
return
|
||||
|
||||
@@ -8,7 +8,9 @@ export function antigravityConversationIdFromTranscriptPath(filePath: string): s
|
||||
const segments = pathSegments(filePath)
|
||||
const transcriptIndex = segments.length - 1
|
||||
if (
|
||||
segments[transcriptIndex] !== ANTIGRAVITY_TRANSCRIPT_FILE ||
|
||||
![ANTIGRAVITY_TRANSCRIPT_FILE, 'transcript_full.jsonl'].includes(
|
||||
segments[transcriptIndex] ?? ''
|
||||
) ||
|
||||
segments[transcriptIndex - 1] !== ANTIGRAVITY_LOGS_DIR ||
|
||||
segments[transcriptIndex - 2] !== ANTIGRAVITY_SYSTEM_DIR
|
||||
) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { completeAntigravityTranscriptPairs } from './antigravity-transcript-candidates'
|
||||
import { ANTIGRAVITY_HISTORY_ROOTS } from '../../shared/antigravity-session-origin'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
@@ -8,17 +10,28 @@ import {
|
||||
import { discoverFiles } from './session-scanner-discovery'
|
||||
import type { AiVaultScanOptions, SessionFileDiscovery } from './session-scanner-types'
|
||||
|
||||
const ANTIGRAVITY_BRAIN_DIR = join(homedir(), '.gemini', 'antigravity-cli', 'brain')
|
||||
|
||||
export function antigravityDiscoveries(
|
||||
options: AiVaultScanOptions,
|
||||
wslHomeDirs: readonly string[],
|
||||
limit: number,
|
||||
issues: AiVaultScanIssue[]
|
||||
): Promise<SessionFileDiscovery>[] {
|
||||
const origins =
|
||||
options.includeAntigravityIdeSessions === true
|
||||
? ANTIGRAVITY_HISTORY_ROOTS
|
||||
: (['antigravity-cli'] as const)
|
||||
const home = options.antigravityAppHome ?? homedir()
|
||||
const rootDirs = [
|
||||
options.antigravityBrainDir ?? ANTIGRAVITY_BRAIN_DIR,
|
||||
...wslHomeDirs.map((homeDir) => join(homeDir, '.gemini', 'antigravity-cli', 'brain'))
|
||||
options.antigravityBrainDir ?? join(home, '.gemini', 'antigravity-cli', 'brain'),
|
||||
...(options.includeAntigravityIdeSessions === true &&
|
||||
(!options.antigravityBrainDir || options.antigravityAppHome)
|
||||
? origins
|
||||
.filter((origin) => origin !== 'antigravity-cli')
|
||||
.map((origin) => join(home, '.gemini', origin, 'brain'))
|
||||
: []),
|
||||
...wslHomeDirs.flatMap((homeDir) =>
|
||||
origins.map((origin) => join(homeDir, '.gemini', origin, 'brain'))
|
||||
)
|
||||
]
|
||||
return rootDirs.map((rootDir) =>
|
||||
discoverFiles({
|
||||
@@ -29,6 +42,6 @@ export function antigravityDiscoveries(
|
||||
extensions: ['.jsonl'],
|
||||
filePredicate: isAntigravityTranscriptPath,
|
||||
directoryPredicate: shouldDescendAntigravityBrainDirectory
|
||||
})
|
||||
}).then((discovery) => completeAntigravityTranscriptPairs(discovery, issues))
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prioritizeAntigravityTranscriptCandidates } from './antigravity-transcript-candidates'
|
||||
import { readCodexRolloutSessionMetaId } from '../codex/codex-rollout-session-meta'
|
||||
import { codexRolloutHardlinkIdentity, dedupeCodexRolloutAliases } from './codex-session-root-dedup'
|
||||
import { antigravityHistoryPathForBrainDir } from './session-scanner-antigravity-paths'
|
||||
@@ -14,7 +15,7 @@ export async function sessionCandidatesFromDiscoveries(
|
||||
discoveries: SessionFileDiscovery[],
|
||||
options: AiVaultScanOptions
|
||||
): Promise<SessionFileCandidate[]> {
|
||||
return dedupeCodexRolloutAliases(
|
||||
const candidates = await dedupeCodexRolloutAliases(
|
||||
discoveries
|
||||
.flatMap((discovery) =>
|
||||
discovery.files.map((file): SessionFileCandidate => ({
|
||||
@@ -43,4 +44,8 @@ export async function sessionCandidatesFromDiscoveries(
|
||||
(filePath) => readCodexRolloutSessionMetaId(filePath, options.signal, 'scan'),
|
||||
options.signal
|
||||
)
|
||||
return prioritizeAntigravityTranscriptCandidates(
|
||||
candidates,
|
||||
(candidate) => candidate.agent === 'antigravity'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,8 +89,21 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('session parsers that stop consuming a gated transcript early', () => {
|
||||
it('destroys the bounded Antigravity stream when its read fails', async () => {
|
||||
const stream = Readable.from(
|
||||
(async function* () {
|
||||
yield Buffer.from('{}\n')
|
||||
throw new Error(PARSE_FAILURE)
|
||||
})()
|
||||
)
|
||||
mocks.openStream.mockReturnValue(stream)
|
||||
await expect(
|
||||
parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')
|
||||
).rejects.toThrow(PARSE_FAILURE)
|
||||
expect(stream.destroyed).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['antigravity', () => parseAntigravitySessionFile(file('/w/conversation.jsonl'), 'linux')],
|
||||
['droid', () => parseDroidSessionFile(file('/w/session.jsonl'), 'linux')],
|
||||
['message graph', () => parseMessageGraphSessionFile('pi', file('/w/session.jsonl'), 'linux')]
|
||||
])('destroys the stream when the %s parse throws', async (_agent, parse) => {
|
||||
|
||||
@@ -25,6 +25,8 @@ export type AiVaultScanOptions = {
|
||||
wslOpenCodeReaders?: readonly OpenCodeWslRuntime[]
|
||||
geminiSessionsDir?: string
|
||||
antigravityBrainDir?: string
|
||||
antigravityAppHome?: string
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
copilotSessionsDir?: string
|
||||
cursorProjectsDir?: string
|
||||
opencodeStorageDir?: string
|
||||
@@ -61,6 +63,8 @@ export type AiVaultScanOptions = {
|
||||
}
|
||||
|
||||
export type FileWithMtime = {
|
||||
/** Antigravity alias observation, separate from the actual file stat/cache key. */
|
||||
aliasMtimeMs?: number
|
||||
path: string
|
||||
mtimeMs: number
|
||||
modifiedAt: string
|
||||
@@ -146,6 +150,7 @@ export type SessionAccumulator = {
|
||||
// True once an older message fell out of the newest-N preview window, so the
|
||||
// earliest preview turn is no longer the session's opening ask.
|
||||
previewMessagesTruncated: boolean
|
||||
antigravityOpeningPrompt?: AiVaultSession['antigravityOpeningPrompt'] | null
|
||||
firstUserPrompt: string | null
|
||||
lastUserPrompt: string | null
|
||||
// Recoverable signal for a zero-turn transcript (see AiVaultSession).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { candidateFileTime } from './antigravity-transcript-candidates'
|
||||
import type {
|
||||
AiVaultListResult,
|
||||
AiVaultScanIssue,
|
||||
@@ -78,8 +79,8 @@ export async function scanAiVaultSessions(
|
||||
const executionHostId = options.executionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const issues: AiVaultScanIssue[] = []
|
||||
const parseStats = createSessionParseStats()
|
||||
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver(
|
||||
readLocalAntigravityHistory
|
||||
const antigravityWorkspaceResolver = createAntigravityWorkspaceResolver((path) =>
|
||||
readLocalAntigravityHistory(path, options.signal)
|
||||
)
|
||||
// Why: persisted entries must be seeded before any candidate is parsed, or
|
||||
// the cold scan gains nothing from the cache file (#9210).
|
||||
@@ -233,7 +234,9 @@ async function parseSessionCandidates(args: {
|
||||
|
||||
while (index < args.candidates.length) {
|
||||
throwIfAiVaultScanCancelled(args.signal)
|
||||
if (canStopParsingSessions(sessions, args.limit, args.candidates[index]?.file.mtimeMs)) {
|
||||
if (
|
||||
canStopParsingSessions(sessions, args.limit, candidateFileTime(args.candidates[index]?.file))
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,9 @@ async function scanOneSshHost(
|
||||
const relayTimeoutMs = options.relayTimeoutMs ?? options.timeoutMs
|
||||
try {
|
||||
const params = {
|
||||
...(args?.includeAntigravityIdeSessions === true
|
||||
? { includeAntigravityIdeSessions: true }
|
||||
: {}),
|
||||
limit: args?.limit,
|
||||
...(args?.unlimited === true ? { unlimited: true } : {}),
|
||||
...(args?.force === true ? { force: true } : {}),
|
||||
@@ -108,8 +111,12 @@ async function scanOneSshHost(
|
||||
scanRemoteAiVaultSessions({
|
||||
provider,
|
||||
executionHostId,
|
||||
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
|
||||
remoteHome: hostInfo.remoteHome,
|
||||
hostPlatform: hostInfo.hostPlatform,
|
||||
...(args?.includeAntigravityIdeSessions === true
|
||||
? { includeAntigravityIdeSessions: true }
|
||||
: {}),
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
scopePaths,
|
||||
|
||||
@@ -80,6 +80,9 @@ function runtimeScanArgs(
|
||||
if (listArgs?.scopePaths !== undefined) {
|
||||
scanArgs.scopePaths = listArgs.scopePaths
|
||||
}
|
||||
if (listArgs?.includeAntigravityIdeSessions === true) {
|
||||
scanArgs.includeAntigravityIdeSessions = true
|
||||
}
|
||||
return scanArgs
|
||||
}
|
||||
|
||||
|
||||
@@ -112,11 +112,16 @@ beforeEach(() => {
|
||||
|
||||
describe('listAiVaultSessions host routing', () => {
|
||||
it('routes local scope to the local scanner', async () => {
|
||||
await _internals.listAiVaultSessions({ executionHostScope: 'local', scopePaths: ['/repo'] })
|
||||
await _internals.listAiVaultSessions({
|
||||
executionHostScope: 'local',
|
||||
scopePaths: ['/repo'],
|
||||
includeAntigravityIdeSessions: true
|
||||
})
|
||||
|
||||
expect(mocks.scanLocalSessions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scopePaths: ['/repo'],
|
||||
includeAntigravityIdeSessions: true,
|
||||
executionHostId: 'local'
|
||||
}),
|
||||
expect.any(AbortSignal)
|
||||
|
||||
@@ -86,24 +86,22 @@ async function listAiVaultSessions(
|
||||
options: { signal?: AbortSignal } = {}
|
||||
): Promise<AiVaultListResult> {
|
||||
const executionHostScope = requestedExecutionHostScope(args?.executionHostScope)
|
||||
// Scope paths change the result set, so they must be part of the cache key.
|
||||
// A scanner consumes at most 64 paths, so smaller equivalent workspace sets
|
||||
// can share a snapshot regardless of which worktree was selected first.
|
||||
// Canonicalize bounded workspace sets so equivalent scopes share a scan.
|
||||
const scopePaths = args?.scopePaths ?? []
|
||||
const key = JSON.stringify({
|
||||
scopePaths:
|
||||
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
|
||||
? [...new Set(scopePaths)].sort()
|
||||
: scopePaths,
|
||||
executionHostScope
|
||||
executionHostScope,
|
||||
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
|
||||
})
|
||||
const depth = requestedAiVaultSessionDepth(args)
|
||||
const scanKey = JSON.stringify({ key, depth })
|
||||
// Why: every renderer request carries its own cancellation signal, so
|
||||
// coalescing has to survive them — the coordinator hands all same-key callers
|
||||
// one scan and only aborts it once every one of them has cancelled.
|
||||
return scanCoordinator.run({
|
||||
key: scanKey,
|
||||
key: JSON.stringify({ key, depth }),
|
||||
force: args?.force,
|
||||
signal: options.signal,
|
||||
start: (scanSignal) => {
|
||||
@@ -252,15 +250,7 @@ async function scanLocalAiVaultSessions(
|
||||
// Why: the shared cache module owns codex-home/WSL sourcing and the local
|
||||
// scan cache, so the desktop IPC path and the runtime RPC method (mobile)
|
||||
// share one cache instance and one source of managed-Codex homes.
|
||||
return listCachedLocalAiVaultSessions(
|
||||
{
|
||||
limit: args?.limit,
|
||||
unlimited: args?.unlimited,
|
||||
force: args?.force,
|
||||
scopePaths: args?.scopePaths
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
return listCachedLocalAiVaultSessions(args, { signal })
|
||||
}
|
||||
|
||||
export function registerAiVaultHandlers(options: AiVaultHandlerOptions = {}): void {
|
||||
|
||||
@@ -299,6 +299,22 @@ describe('aiVault.listSessions handler + shared cache', () => {
|
||||
resetAiVaultSessionListCacheForTests()
|
||||
})
|
||||
|
||||
it('isolates IDE opt-in scans from legacy clients sharing the host cache', async () => {
|
||||
const dispatcher = makeDispatcher()
|
||||
await dispatcher.dispatch(
|
||||
makeRequest('aiVault.listSessions', { includeAntigravityIdeSessions: true })
|
||||
)
|
||||
await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
|
||||
await dispatcher.dispatch(makeRequest('aiVault.listSessions', {}))
|
||||
expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(2)
|
||||
expect(scanAiVaultSessionsInService.mock.calls[0]?.[0]).toMatchObject({
|
||||
includeAntigravityIdeSessions: true
|
||||
})
|
||||
expect(scanAiVaultSessionsInService.mock.calls[1]?.[0]?.includeAntigravityIdeSessions).not.toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('returns the AiVaultListResult unchanged', async () => {
|
||||
const dispatcher = makeDispatcher()
|
||||
const response = await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 }))
|
||||
|
||||
@@ -76,7 +76,8 @@ export const AI_VAULT_METHODS = [
|
||||
limit: params.unlimited ? undefined : params.limit,
|
||||
unlimited: params.unlimited,
|
||||
force: params.force,
|
||||
scopePaths: params.scopePaths
|
||||
scopePaths: params.scopePaths,
|
||||
includeAntigravityIdeSessions: params.includeAntigravityIdeSessions
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -97,6 +97,7 @@ export class AiVaultHandler {
|
||||
try {
|
||||
result = await this.scanCoordinator.run({
|
||||
key: JSON.stringify({
|
||||
includeAntigravityIdeSessions: params.includeAntigravityIdeSessions,
|
||||
limit: params.limit,
|
||||
unlimited: params.unlimited,
|
||||
scopePaths: params.scopePaths,
|
||||
@@ -194,6 +195,9 @@ export function normalizeSshAiVaultRelayListParams(
|
||||
params.scopePathsTruncated === true ||
|
||||
(Array.isArray(params.scopePaths) && params.scopePaths.length > AI_VAULT_SCOPE_PATHS_MAX_COUNT)
|
||||
return {
|
||||
...(params.includeAntigravityIdeSessions === true
|
||||
? { includeAntigravityIdeSessions: true }
|
||||
: {}),
|
||||
...(unlimited ? { unlimited: true } : {}),
|
||||
...(limit === undefined ? {} : { limit }),
|
||||
...(params.force === true ? { force: true } : {}),
|
||||
|
||||
@@ -53,6 +53,7 @@ async function execute(request: RelayAiVaultServiceRequest): Promise<void> {
|
||||
hostPlatform: init.hostPlatform,
|
||||
limit: request.params.limit,
|
||||
unlimited: request.params.unlimited,
|
||||
includeAntigravityIdeSessions: request.params.includeAntigravityIdeSessions,
|
||||
scopePaths: request.params.scopePaths,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
@@ -2,13 +2,27 @@ import { open } from 'node:fs/promises'
|
||||
import { throwIfAiVaultScanCancelled } from '../main/ai-vault/ai-vault-scan-cancellation'
|
||||
import { BinarySessionTranscriptError } from '../main/ai-vault/remote-session-content-lines'
|
||||
import { BINARY_PROBE_BYTES, isBinaryBuffer } from './fs-handler-utils'
|
||||
import { readNodeFileWithinLimit } from '../shared/node-bounded-file-reader'
|
||||
|
||||
/** The same open handle supplies the probe and stream, including across renames. */
|
||||
export async function* readRelayTranscriptBytes(
|
||||
path: string,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
options?: { regularFileOnly: true; maxBytes: number }
|
||||
): AsyncGenerator<Buffer> {
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
if (options?.regularFileOnly) {
|
||||
const read = await readNodeFileWithinLimit(path, options.maxBytes, {
|
||||
regularFileOnly: true,
|
||||
signal
|
||||
})
|
||||
if (isBinaryBuffer(read.buffer.subarray(0, BINARY_PROBE_BYTES))) {
|
||||
throw new BinarySessionTranscriptError()
|
||||
}
|
||||
throwIfAiVaultScanCancelled(signal)
|
||||
yield read.buffer
|
||||
return
|
||||
}
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
const probe = Buffer.alloc(BINARY_PROBE_BYTES)
|
||||
|
||||
@@ -68,6 +68,8 @@ export default function AiVaultPanel(): React.JSX.Element {
|
||||
const projectHostSetupProjection = useProjectHostSetupProjection()
|
||||
const resumeTargetState = useAppStore(
|
||||
useShallow((state) => ({
|
||||
projects: state.projects,
|
||||
settings: state.settings,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
projectGroups: state.projectGroups,
|
||||
repos: state.repos,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
|
||||
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
|
||||
import type React from 'react'
|
||||
import {
|
||||
@@ -59,6 +60,7 @@ export function SessionInlineDetails({
|
||||
}): React.JSX.Element {
|
||||
// A zero-turn transcript would resume into an empty conversation, so the plain
|
||||
// resume affordances are withheld and a distinct "not saved" state is shown.
|
||||
const referenceSession = isAntigravityReferenceSession(session)
|
||||
const hasResumableContent = isAiVaultSessionResumableContent(session)
|
||||
const showResumeInWorktree = hasResumableContent && Boolean(resumeActions.worktree.worktreeId)
|
||||
const showResumeInNewTab =
|
||||
@@ -100,10 +102,12 @@ export function SessionInlineDetails({
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
|
||||
'Resume in Worktree'
|
||||
)}
|
||||
{referenceSession
|
||||
? translate('aiVault.continueInCli', 'Continue in CLI')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionDetails.resumeInWorktree',
|
||||
'Resume in Worktree'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{showResumeInNewTab ? (
|
||||
@@ -120,10 +124,12 @@ export function SessionInlineDetails({
|
||||
className="h-7 shrink-0 px-2.5 text-[11px]"
|
||||
>
|
||||
<Play className="size-3.5" />
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
|
||||
'Resume in New Tab'
|
||||
)}
|
||||
{referenceSession
|
||||
? translate('aiVault.continueInCliNewTab', 'Continue in CLI in New Tab')
|
||||
: translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.resumeInNewTab',
|
||||
'Resume in New Tab'
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
{onResumeInNewChat ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
|
||||
import type { AiVaultSubagentResumeActions } from './AiVaultSessionSubagents'
|
||||
import { useCallback } from 'react'
|
||||
import type React from 'react'
|
||||
@@ -44,7 +45,7 @@ export function VaultSessionRow({
|
||||
onResume,
|
||||
onContinueInNewSession,
|
||||
onResumeInNewChat,
|
||||
resumeLabel,
|
||||
resumeLabel: defaultResumeLabel,
|
||||
resumeActions,
|
||||
onResumeInWorktree,
|
||||
onResumeInNewTab,
|
||||
@@ -88,6 +89,9 @@ export function VaultSessionRow({
|
||||
onRequestDelete?: (session: AiVaultSession) => void
|
||||
searchHit?: AiVaultSearchHit
|
||||
}) {
|
||||
const resumeLabel = isAntigravityReferenceSession(session)
|
||||
? translate('aiVault.continueInCli', 'Continue in CLI')
|
||||
: defaultResumeLabel
|
||||
const updatedAt = session.updatedAt ?? session.modifiedAt
|
||||
const detailsId = getSessionDetailsId(session.id)
|
||||
const latestTurn = latestSessionConversationTurn(session)
|
||||
|
||||
@@ -34,7 +34,7 @@ afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
function renderSession(session: AiVaultSession) {
|
||||
function renderSession(session: AiVaultSession, blocked = false) {
|
||||
const buildResumeStartup = vi.fn(() => ({ command: session.resumeCommand }))
|
||||
const onCopyResume = vi.fn()
|
||||
render(
|
||||
@@ -54,13 +54,13 @@ function renderSession(session: AiVaultSession) {
|
||||
getSessionLiveState={() => null}
|
||||
getWorktreeInfo={() => null}
|
||||
getSessionResumeState={() => ({
|
||||
blocked: false,
|
||||
worktreeId: 'worktree-1',
|
||||
blocked,
|
||||
worktreeId: blocked ? null : 'worktree-1',
|
||||
usesSessionWorktree: false
|
||||
})}
|
||||
getSessionResumeActions={() => ({
|
||||
worktree: { worktreeId: null, disabled: true },
|
||||
newTab: { worktreeId: 'worktree-1', disabled: false }
|
||||
newTab: { worktreeId: 'worktree-1', disabled: blocked }
|
||||
})}
|
||||
getSessionResumeInChat={() => ({ available: false, reason: 'already-structured' })}
|
||||
onToggleGroup={vi.fn()}
|
||||
@@ -84,6 +84,21 @@ function renderSession(session: AiVaultSession) {
|
||||
}
|
||||
|
||||
describe('AiVaultVirtualRow resume command actions', () => {
|
||||
it('renders blocked IDE references without preparing an unsafe startup command', () => {
|
||||
const { buildResumeStartup } = renderSession(
|
||||
{
|
||||
...cliSession,
|
||||
agent: 'antigravity',
|
||||
title: 'IDE reference on another host',
|
||||
filePath:
|
||||
'//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/id/transcript_full.jsonl'
|
||||
},
|
||||
true
|
||||
)
|
||||
expect(screen.getByText('IDE reference on another host')).toBeTruthy()
|
||||
expect(buildResumeStartup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps Copy Resume Command in overflow and context actions for CLI sessions', async () => {
|
||||
const { buildResumeStartup, onCopyResume } = renderSession(cliSession)
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
|
||||
import type { AiVaultScope, AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import type { AiVaultResumeStartup } from '@/lib/ai-vault-resume-command'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -135,10 +136,14 @@ export function AiVaultVirtualRow({
|
||||
const searchResumeAllowed = searchHit ? canResumeAiVaultSearchHit(searchHit) : true
|
||||
const searchPathAllowed = searchHit ? hasAiVaultSearchHitPath(searchHit) : true
|
||||
const usesLegacyResumeCommand = row.type === 'session' && !row.session.structuredSession
|
||||
const resumeStartup =
|
||||
row.type === 'session' && searchResumeAllowed && usesLegacyResumeCommand
|
||||
? buildResumeStartup(row.session, resumeState?.worktreeId)
|
||||
: { command: '' }
|
||||
const canBuildResumeStartup =
|
||||
row.type === 'session' &&
|
||||
searchResumeAllowed &&
|
||||
usesLegacyResumeCommand &&
|
||||
(!isAntigravityReferenceSession(row.session) || !resumeGating.resumeDisabled)
|
||||
const resumeStartup = canBuildResumeStartup
|
||||
? buildResumeStartup(row.session, resumeState?.worktreeId)
|
||||
: { command: '' }
|
||||
const visibleResumeActions =
|
||||
searchResumeAllowed && resumeActions
|
||||
? resumeActions
|
||||
@@ -170,7 +175,7 @@ export function AiVaultVirtualRow({
|
||||
liveState={getSessionLiveState(row.session)}
|
||||
resumeStartup={resumeStartup}
|
||||
realHomeResumeStartup={
|
||||
searchResumeAllowed && usesLegacyResumeCommand
|
||||
canBuildResumeStartup
|
||||
? buildResumeStartup({ ...row.session, codexHome: null }, resumeState?.worktreeId)
|
||||
: resumeStartup
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
@@ -60,7 +61,8 @@ export function resolveAiVaultSessionLaunchTarget(args: {
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
targetExecutionHostId,
|
||||
targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, targetWorktreeId)
|
||||
})
|
||||
) {
|
||||
return { status: 'unsupported', targetStatus }
|
||||
|
||||
@@ -174,6 +174,7 @@ export function useAiVaultSessionRefresh(
|
||||
const scanKey = `${baseKey}\n${selectedLimit}`
|
||||
try {
|
||||
const result = await window.api.aiVault.listSessions({
|
||||
includeAntigravityIdeSessions: true,
|
||||
limit,
|
||||
unlimited: selectedLimit === 'unlimited',
|
||||
scopePaths: scopePathsRef.current,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { isAntigravityReferenceSession } from '../../../../shared/antigravity-session-origin'
|
||||
// Whether an Agent Session History row can be resumed into a structured native chat, and where.
|
||||
//
|
||||
// Separate from `ai-vault-session-resume.ts` because the answer is not the same question: the
|
||||
@@ -70,7 +71,7 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: {
|
||||
structuredRouteAvailable: boolean
|
||||
}): AiVaultResumeInChatEligibility {
|
||||
const { session } = args
|
||||
if (!isAgentSessionHandleProvider(session.agent)) {
|
||||
if (isAntigravityReferenceSession(session) || !isAgentSessionHandleProvider(session.agent)) {
|
||||
return { available: false, reason: 'agent' }
|
||||
}
|
||||
// An already-adopted row reopens its own chat instead; offering a second resume of it would ask
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
|
||||
import type { Repo } from '../../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../../shared/worktree/types'
|
||||
import {
|
||||
@@ -22,7 +23,8 @@ import {
|
||||
export type AiVaultSessionResumeTargetState = Pick<
|
||||
AppState,
|
||||
'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'
|
||||
>
|
||||
> &
|
||||
Partial<Pick<AppState, 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings'>>
|
||||
|
||||
export type AiVaultSessionResumeState = {
|
||||
blocked: boolean
|
||||
@@ -201,7 +203,8 @@ function resolveSupportedResumeWorktreeId(args: {
|
||||
sessionFilePath: args.sessionFilePath,
|
||||
sessionExecutionHostId: args.sessionExecutionHostId,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
targetExecutionHostId,
|
||||
targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.targetState, args.worktreeId)
|
||||
})
|
||||
) {
|
||||
return null
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
antigravitySessionOrigin,
|
||||
isAntigravityReferenceSession
|
||||
} from '../../../../shared/antigravity-session-origin'
|
||||
import type React from 'react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import RepoBadgeLabel from '@/components/repo/RepoBadgeLabel'
|
||||
@@ -41,6 +45,7 @@ export function SessionMetadata({
|
||||
vaultScope: AiVaultScope
|
||||
}) {
|
||||
const modelLabel = sessionModelLabel(session)
|
||||
const origin = antigravitySessionOrigin(session.filePath)
|
||||
return (
|
||||
<div
|
||||
data-testid="ai-vault-session-metadata"
|
||||
@@ -54,6 +59,19 @@ export function SessionMetadata({
|
||||
would mark most rows; only live attention states earn a dot. */}
|
||||
{liveState && liveState !== 'done' ? <AgentStateDot state={liveState} /> : null}
|
||||
<span className="min-w-0 shrink-[2] truncate">{agentLabel(session.agent)}</span>
|
||||
{isAntigravityReferenceSession(session) ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
title={translate(
|
||||
'aiVault.antigravityReferenceDescription',
|
||||
'Continues in a new Antigravity CLI conversation using this transcript as a reference.'
|
||||
)}
|
||||
>
|
||||
{origin === 'antigravity-ide'
|
||||
? translate('aiVault.antigravityIdeToCli', 'IDE → CLI')
|
||||
: translate('aiVault.antigravity2ToCli', '2.0 → CLI')}
|
||||
</Badge>
|
||||
) : null}
|
||||
<span className="shrink-0 tabular-nums">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.AiVaultSessionRow.messageCount',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getAiVaultResumeWorkspaceWslDistro } from '@/lib/ai-vault-resume-shell'
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import {
|
||||
@@ -200,7 +201,8 @@ export default function AiVaultSessionDropLayer({
|
||||
sessionFilePath: payload.sessionFilePath ?? null,
|
||||
sessionExecutionHostId: payload.sessionExecutionHostId ?? null,
|
||||
targetStatus,
|
||||
targetExecutionHostId
|
||||
targetExecutionHostId,
|
||||
targetWslDistro: getAiVaultResumeWorkspaceWslDistro(state, worktreeId)
|
||||
})
|
||||
) {
|
||||
toast.error(
|
||||
|
||||
@@ -18698,6 +18698,11 @@
|
||||
}
|
||||
},
|
||||
"aiVault": {
|
||||
"antigravityReferenceDescription": "Continues in a new Antigravity CLI conversation using this transcript as a reference.",
|
||||
"antigravityIdeToCli": "IDE → CLI",
|
||||
"antigravity2ToCli": "2.0 → CLI",
|
||||
"continueInCli": "Continue in CLI",
|
||||
"continueInCliNewTab": "Continue in CLI in New Tab",
|
||||
"subagents": {
|
||||
"loading": "Loading subagents…",
|
||||
"loadError": "Could not load all subagents.",
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { makeFolderWorkspace, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
import { resolveAiVaultSessionLaunchTarget } from '@/components/right-sidebar/ai-vault-session-launch-target'
|
||||
import {
|
||||
buildAiVaultResumeCopyCommandForWorktree,
|
||||
buildAiVaultResumeStartupForWorktree
|
||||
} from './ai-vault-resume-command'
|
||||
import { canResumeAiVaultSessionOnTarget } from './ai-vault-resume-target'
|
||||
import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
|
||||
import { buildAgentLaunchRouteInput } from './agent-launch-route-input'
|
||||
import { getLocalProjectExecutionRuntimeContext } from './local-preflight-context'
|
||||
import type { ExecutionHostId } from '../../../shared/execution-host'
|
||||
import { createGlobalSettingsFixture } from '../../../shared/global-settings-test-fixture'
|
||||
|
||||
vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'win32' }))
|
||||
vi.mock('@/lib/renderer-app-platform', () => ({ getRendererAppPlatform: () => 'win32' }))
|
||||
|
||||
type State = Parameters<typeof buildAiVaultResumeStartupForWorktree>[0]['state']
|
||||
const windowsFile = 'C:/Users/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
|
||||
const linuxFile = '/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
|
||||
const debianFile =
|
||||
'//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl'
|
||||
|
||||
function stateFor(host: ExecutionHostId, distro?: string): State {
|
||||
const worktree = makeWorktree({
|
||||
id: 'repo::workspace',
|
||||
repoId: 'repo',
|
||||
hostId: host,
|
||||
path: distro ? `//wsl.localhost/${distro}/home/example/project` : 'C:/project'
|
||||
})
|
||||
return {
|
||||
activeRepoId: 'repo',
|
||||
activeWorktreeId: worktree.id,
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
settings: null,
|
||||
repos: [
|
||||
{
|
||||
id: 'repo',
|
||||
path: worktree.path,
|
||||
displayName: 'project',
|
||||
badgeColor: '',
|
||||
addedAt: 0,
|
||||
projectGroupId: 'group-1',
|
||||
executionHostId: host
|
||||
}
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: 'repo',
|
||||
displayName: 'project',
|
||||
badgeColor: '',
|
||||
sourceRepoIds: ['repo'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
localWindowsRuntimePreference: distro ? { kind: 'wsl', distro } : { kind: 'windows-host' }
|
||||
}
|
||||
],
|
||||
worktreesByRepo: { repo: [worktree] }
|
||||
}
|
||||
}
|
||||
|
||||
const cases = [
|
||||
{ source: 'local', target: 'local', file: debianFile, distro: 'Debian', allowed: true },
|
||||
{ source: 'local', target: 'local', file: debianFile, distro: 'Ubuntu', allowed: false },
|
||||
{ source: 'local', target: 'local', file: debianFile, allowed: false },
|
||||
{ source: 'local', target: 'local', file: windowsFile, distro: 'Debian', allowed: false },
|
||||
{ source: 'local', target: 'local', file: windowsFile, allowed: true },
|
||||
{ source: 'local', target: 'ssh:other', file: debianFile, allowed: false },
|
||||
{ source: 'ssh:owner', target: 'ssh:other', file: linuxFile, allowed: false },
|
||||
{ source: 'ssh:owner', target: 'local', file: linuxFile, allowed: false },
|
||||
{ source: 'ssh:owner', target: 'ssh:owner', file: linuxFile, allowed: true },
|
||||
{ source: 'runtime:owner', target: 'runtime:other', file: linuxFile, allowed: false },
|
||||
{ source: 'runtime:owner', target: 'runtime:owner', file: linuxFile, allowed: true }
|
||||
] as const
|
||||
|
||||
describe('Antigravity transcript reference ownership', () => {
|
||||
it.each(cases)(
|
||||
'confines $file from $source to $target ($distro)',
|
||||
({ source, target, file, allowed, ...options }) => {
|
||||
const distro = 'distro' in options ? options.distro : undefined
|
||||
const state = stateFor(target, distro)
|
||||
const session = {
|
||||
agent: 'antigravity' as const,
|
||||
sessionId: 'copied-id',
|
||||
cwd: null,
|
||||
codexHome: null,
|
||||
executionHostId: source,
|
||||
executionHostPlatform: target === 'local' ? ('win32' as const) : ('linux' as const),
|
||||
filePath: file
|
||||
}
|
||||
const launch = resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: file,
|
||||
sessionExecutionHostId: source,
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
})
|
||||
expect(launch.status).toBe(allowed ? 'ready' : 'unsupported')
|
||||
for (const build of [
|
||||
buildAiVaultResumeStartupForWorktree,
|
||||
buildAiVaultResumeCopyCommandForWorktree
|
||||
]) {
|
||||
if (!allowed) {
|
||||
expect(() => build({ state, session })).toThrow('execution host or WSL distro')
|
||||
} else {
|
||||
const result = build({ state, session })
|
||||
const command = typeof result === 'string' ? result : result.command
|
||||
expect(command).toContain('--prompt-interactive')
|
||||
expect(command).not.toContain('--conversation')
|
||||
expect(command).not.toContain('wsl.localhost')
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('preserves legacy WSL ID resume eligibility on SSH', () => {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: debianFile.replace('antigravity-ide', 'antigravity-cli'),
|
||||
sessionExecutionHostId: 'local',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: 'ssh:other'
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('uses a project runtime override before the workspace UNC distro', () => {
|
||||
const state = stateFor('local', 'Ubuntu')
|
||||
const workspace = state.worktreesByRepo.repo?.[0]
|
||||
if (!workspace) {
|
||||
throw new Error('Missing fixture workspace')
|
||||
}
|
||||
workspace.path = '//wsl.localhost/Debian/home/example/project'
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, workspace.id)).toBe('Ubuntu')
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, `worktree:${workspace.id}`)).toBe('Ubuntu')
|
||||
})
|
||||
|
||||
it('applies the same confinement to folder workspaces', () => {
|
||||
const state = stateFor('local')
|
||||
state.activeRepoId = null
|
||||
state.repos = []
|
||||
state.projects = []
|
||||
state.activeWorktreeId = 'folder:folder-1'
|
||||
state.folderWorkspaces = [
|
||||
makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' })
|
||||
]
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: debianFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: 'folder:folder-1',
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('ready')
|
||||
state.folderWorkspaces[0].folderPath = '//wsl.localhost/Ubuntu/home/example/project'
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: debianFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: 'folder:folder-1',
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('uses the folder launch project runtime for a native folder path', () => {
|
||||
const state = stateFor('local', 'Debian')
|
||||
state.activeWorktreeId = 'folder:folder-1'
|
||||
state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
|
||||
expect(
|
||||
buildAgentLaunchRouteInput(state, {
|
||||
agent: 'antigravity',
|
||||
workspace: { kind: 'folder', repoId: 'repo' }
|
||||
}).projectRuntime
|
||||
).toMatchObject({ status: 'resolved', runtime: { kind: 'wsl', distro: 'Debian' } })
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Debian')
|
||||
for (const [file, status] of [
|
||||
[windowsFile, 'unsupported'],
|
||||
[debianFile, 'ready']
|
||||
] as const) {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: file,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe(status)
|
||||
}
|
||||
})
|
||||
|
||||
it('lets an explicit folder project runtime override the UNC path distro', () => {
|
||||
const state = stateFor('local', 'Ubuntu')
|
||||
state.activeWorktreeId = 'folder:folder-1'
|
||||
state.folderWorkspaces = [
|
||||
makeFolderWorkspace({
|
||||
folderPath: '//wsl.localhost/Debian/home/example/project'
|
||||
})
|
||||
]
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBe('Ubuntu')
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: debianFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('does not substitute a local project runtime into an SSH folder launch', () => {
|
||||
const state = stateFor('ssh:owner', 'Debian')
|
||||
state.activeWorktreeId = 'folder:folder-1'
|
||||
state.folderWorkspaces = [
|
||||
makeFolderWorkspace({
|
||||
folderPath: '/remote/non-git-folder',
|
||||
executionHostId: 'ssh:owner'
|
||||
})
|
||||
]
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeNull()
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: linuxFile,
|
||||
sessionExecutionHostId: 'ssh:owner',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('ready')
|
||||
})
|
||||
|
||||
it('keeps references refused for SSH aliases claiming local WSL ownership', () => {
|
||||
for (const target of [
|
||||
'ssh:localhost',
|
||||
'ssh:127.0.0.1',
|
||||
'ssh:Debian',
|
||||
'ssh:local-wsl'
|
||||
] as const) {
|
||||
expect(
|
||||
canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: debianFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
targetStatus: 'ssh',
|
||||
targetExecutionHostId: target,
|
||||
targetWslDistro: 'Debian'
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the target folder owner for both the pane and reference gate with another repo active', () => {
|
||||
const state = stateFor('local', 'Ubuntu')
|
||||
state.repos[0].path = 'C:/project'
|
||||
state.repos[0].kind = 'folder'
|
||||
state.activeRepoId = 'unrelated'
|
||||
state.activeWorktreeId = 'unrelated::workspace'
|
||||
state.repos = [
|
||||
...state.repos,
|
||||
{
|
||||
id: 'unrelated',
|
||||
path: 'C:/elsewhere',
|
||||
displayName: 'unrelated',
|
||||
badgeColor: '',
|
||||
addedAt: 0
|
||||
}
|
||||
]
|
||||
state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
|
||||
const target = 'folder:folder-1'
|
||||
expect(getLocalProjectExecutionRuntimeContext(state, target, 'win32')).toMatchObject({
|
||||
status: 'resolved',
|
||||
runtime: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, target)).toBe('Ubuntu')
|
||||
for (const [file, expected] of [
|
||||
[windowsFile, 'unsupported'],
|
||||
[debianFile.replace('Debian', 'Ubuntu'), 'ready']
|
||||
] as const) {
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: file,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetWorktreeId: target,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe(expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not borrow an unrelated active project override for an orphan native folder', () => {
|
||||
const state = stateFor('local', 'Debian')
|
||||
state.folderWorkspaces = [
|
||||
makeFolderWorkspace({ folderPath: 'C:/orphan', projectGroupId: 'orphan-group' })
|
||||
]
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps explicit SSH folder authority ahead of a matching local project path', () => {
|
||||
const state = stateFor('local', 'Debian')
|
||||
state.repos[0].path = 'C:/project'
|
||||
state.folderWorkspaces = [
|
||||
makeFolderWorkspace({ folderPath: 'C:/project/folder', executionHostId: 'ssh:owner' })
|
||||
]
|
||||
expect(
|
||||
getLocalProjectExecutionRuntimeContext(state, 'folder:folder-1', 'win32')
|
||||
).toBeUndefined()
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, 'folder:folder-1')).toBeNull()
|
||||
})
|
||||
|
||||
it('refuses local references while the project runtime requires repair', () => {
|
||||
const state = stateFor('local')
|
||||
state.projects[0].localWindowsRuntimePreference = { kind: 'inherit-global' }
|
||||
state.settings = createGlobalSettingsFixture({
|
||||
localWindowsRuntimeDefault: { kind: 'wsl', distro: null }
|
||||
})
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: windowsFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('unsupported')
|
||||
})
|
||||
|
||||
it('refuses references when a folder has multiple possible owning projects', () => {
|
||||
const state = stateFor('local', 'Ubuntu')
|
||||
state.activeWorktreeId = 'folder:folder-1'
|
||||
state.folderWorkspaces = [makeFolderWorkspace({ folderPath: 'C:/project/folder' })]
|
||||
state.repos = [
|
||||
...state.repos,
|
||||
{
|
||||
id: 'second',
|
||||
path: 'C:/second',
|
||||
projectGroupId: 'group-1',
|
||||
displayName: 'second',
|
||||
badgeColor: '',
|
||||
addedAt: 0
|
||||
}
|
||||
]
|
||||
expect(getAiVaultResumeWorkspaceWslDistro(state, state.activeWorktreeId)).toBeUndefined()
|
||||
expect(
|
||||
resolveAiVaultSessionLaunchTarget({
|
||||
sessionFilePath: windowsFile,
|
||||
sessionExecutionHostId: 'local',
|
||||
activeWorktreeId: state.activeWorktreeId,
|
||||
targetState: state
|
||||
}).status
|
||||
).toBe('unsupported')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
|
||||
import {
|
||||
canResumeAiVaultSessionOnTarget,
|
||||
getAiVaultResumeWorkspaceExecutionHostId,
|
||||
getAiVaultResumeWorkspaceTargetStatus
|
||||
} from './ai-vault-resume-target'
|
||||
import { getAiVaultResumeWorkspaceWslDistro } from './ai-vault-resume-shell'
|
||||
import { buildAiVaultResumeCommand } from '../../../shared/ai-vault-resume-command'
|
||||
import { buildAgentStartupPlan } from '../../../shared/tui-agent-startup'
|
||||
import {
|
||||
resolveTuiAgentLaunchArgs,
|
||||
resolveTuiAgentLaunchEnv
|
||||
} from '../../../shared/tui-agent-launch-defaults'
|
||||
import type { AgentStartupShell } from '../../../shared/tui-agent-startup-shell'
|
||||
import type { GlobalSettings } from '../../../shared/global-settings-types'
|
||||
import type {
|
||||
AiVaultResumeStartup,
|
||||
buildAiVaultResumeStartupForWorktree
|
||||
} from './ai-vault-resume-command'
|
||||
|
||||
/** References are fresh launches, so preserve normal model arguments and environment. */
|
||||
export function buildAntigravityReferenceStartup(args: {
|
||||
session: { sessionId: string; filePath?: string }
|
||||
cwd: string | null
|
||||
platform: NodeJS.Platform
|
||||
shell?: AgentStartupShell
|
||||
commandOverride?: string | null
|
||||
settings?: Partial<
|
||||
Pick<GlobalSettings, 'agentCmdOverrides' | 'agentDefaultArgs' | 'agentDefaultEnv'>
|
||||
> | null
|
||||
}): AiVaultResumeStartup | null {
|
||||
const plan = buildAgentStartupPlan({
|
||||
agent: 'antigravity',
|
||||
prompt: '',
|
||||
allowEmptyPromptLaunch: true,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
cmdOverrides: {
|
||||
...args.settings?.agentCmdOverrides,
|
||||
...(args.commandOverride?.trim() ? { antigravity: args.commandOverride } : {})
|
||||
},
|
||||
agentArgs: resolveTuiAgentLaunchArgs('antigravity', args.settings?.agentDefaultArgs),
|
||||
agentEnv: resolveTuiAgentLaunchEnv('antigravity', args.settings?.agentDefaultEnv)
|
||||
})
|
||||
if (!plan) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
command: buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: args.session.sessionId,
|
||||
resumeFilePath: args.session.filePath,
|
||||
cwd: args.cwd,
|
||||
platform: args.platform,
|
||||
shell: args.shell,
|
||||
commandOverride: plan.launchCommand
|
||||
}),
|
||||
...(plan.env ? { env: plan.env } : {}),
|
||||
launchConfig: plan.launchConfig
|
||||
}
|
||||
}
|
||||
|
||||
export function assertAntigravityReferenceTarget(
|
||||
args: Parameters<typeof buildAiVaultResumeStartupForWorktree>[0]
|
||||
): void {
|
||||
const workspaceId = args.worktreeId ?? args.state.activeWorktreeId
|
||||
if (
|
||||
isAntigravityReferenceSession(args.session) &&
|
||||
!canResumeAiVaultSessionOnTarget({
|
||||
sessionFilePath: args.session.filePath,
|
||||
sessionExecutionHostId: args.session.executionHostId,
|
||||
targetStatus: getAiVaultResumeWorkspaceTargetStatus(args.state, workspaceId),
|
||||
targetExecutionHostId: getAiVaultResumeWorkspaceExecutionHostId(args.state, workspaceId),
|
||||
targetWslDistro: getAiVaultResumeWorkspaceWslDistro(args.state, workspaceId)
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
'The Antigravity transcript belongs to a different execution host or WSL distro.'
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AppState } from '@/store/types'
|
||||
import {
|
||||
getAiVaultAgentProviderSession,
|
||||
buildAiVaultResumeCopyCommandForWorktree,
|
||||
buildAiVaultResumeStartupForWorktree
|
||||
} from './ai-vault-resume-command'
|
||||
@@ -68,6 +69,47 @@ function buildQueuedAiVaultResumeCommand(
|
||||
}
|
||||
|
||||
describe('ai vault resume command runtime', () => {
|
||||
it.each(['local', 'ssh:reference-host'] as const)(
|
||||
'starts a fresh IDE reference on %s with model and environment preserved',
|
||||
(executionHostId) => {
|
||||
const state = makeState({
|
||||
worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\example\\project',
|
||||
localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }
|
||||
})
|
||||
if (executionHostId !== 'local') {
|
||||
state.repos = state.repos.map((repo) => ({ ...repo, executionHostId }))
|
||||
}
|
||||
const session = {
|
||||
agent: 'antigravity' as const,
|
||||
sessionId: 'ide-id',
|
||||
cwd: '/home/example/project',
|
||||
codexHome: null,
|
||||
executionHostId,
|
||||
executionHostPlatform: 'linux' as const,
|
||||
resumeCommand: 'agy --conversation ide-id',
|
||||
filePath:
|
||||
executionHostId === 'local'
|
||||
? '\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\ide-id\\.system_generated\\logs\\transcript_full.jsonl'
|
||||
: '/home/example/.gemini/antigravity-ide/brain/ide-id/.system_generated/logs/transcript_full.jsonl'
|
||||
}
|
||||
if (!state.settings) {
|
||||
throw new Error('Missing fixture settings')
|
||||
}
|
||||
state.settings.agentDefaultArgs = { antigravity: '--model claude-sonnet-4-6' }
|
||||
state.settings.agentDefaultEnv = { antigravity: { AGY_CLI_HIDE_ACCOUNT_INFO: '1' } }
|
||||
const startup = buildAiVaultResumeStartupForWorktree({ state, session })
|
||||
expect(startup.command).toContain('--prompt-interactive')
|
||||
expect(startup.command).not.toContain('--conversation')
|
||||
expect(startup.command).not.toContain('wsl.localhost')
|
||||
expect(startup.command).toContain('--model')
|
||||
expect(startup.command).toContain('claude-sonnet-4-6')
|
||||
expect(startup.env).toMatchObject({ AGY_CLI_HIDE_ACCOUNT_INFO: '1' })
|
||||
expect(startup.providerSession).toBeUndefined()
|
||||
expect(getAiVaultAgentProviderSession(session)).toBeNull()
|
||||
expect(startup.cwd).toBe('/home/example/project')
|
||||
}
|
||||
)
|
||||
|
||||
it('repro: queues a host-runtime resume without configured-WSL shell syntax', () => {
|
||||
const state = makeState({
|
||||
worktreePath: 'C:\\Users\\alice\\repo',
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import {
|
||||
assertAntigravityReferenceTarget,
|
||||
buildAntigravityReferenceStartup
|
||||
} from './ai-vault-antigravity-reference-startup'
|
||||
import { isAntigravityReferenceSession } from '../../../shared/antigravity-session-origin'
|
||||
import type { AiVaultSession } from '../../../shared/ai-vault-types'
|
||||
import {
|
||||
buildAiVaultResumeCommand,
|
||||
@@ -125,11 +130,13 @@ function buildAiVaultResumeForWorktree(
|
||||
* Spawned startups drop them through `envToDelete` instead. */
|
||||
clearEnvNames?: readonly string[]
|
||||
): AiVaultResumeStartup {
|
||||
assertAntigravityReferenceTarget(args)
|
||||
const providerSession = getAiVaultAgentProviderSession(args.session)
|
||||
if (
|
||||
args.session.executionHostId &&
|
||||
args.session.executionHostId !== LOCAL_EXECUTION_HOST_ID &&
|
||||
args.session.resumeCommand &&
|
||||
!isAntigravityReferenceSession(args.session) &&
|
||||
args.session.agent !== 'omp' &&
|
||||
!(args.session.agent === 'codex' && args.session.codexHome === null) &&
|
||||
!args.commandOverride?.trim()
|
||||
@@ -160,6 +167,19 @@ function buildAiVaultResumeForWorktree(
|
||||
: undefined
|
||||
const cwd = embedCwd ? args.session.cwd : null
|
||||
const startupCwd = !embedCwd && args.session.cwd ? { cwd: args.session.cwd } : {}
|
||||
if (isAntigravityReferenceSession(args.session)) {
|
||||
const reference = buildAntigravityReferenceStartup({
|
||||
session: { ...args.session, filePath: resumeFilePath },
|
||||
cwd,
|
||||
platform,
|
||||
shell: liveShell,
|
||||
commandOverride: args.commandOverride,
|
||||
settings: args.state.settings
|
||||
})
|
||||
if (reference) {
|
||||
return { ...reference, ...startupCwd }
|
||||
}
|
||||
}
|
||||
if (providerSession && isResumableTuiAgent(args.session.agent)) {
|
||||
const startupPlan = buildAgentResumeStartupPlan({
|
||||
agent: args.session.agent,
|
||||
@@ -256,6 +276,9 @@ export function getAiVaultAgentProviderSession(
|
||||
if (!isResumableTuiAgent(session.agent)) {
|
||||
return null
|
||||
}
|
||||
if (isAntigravityReferenceSession(session)) {
|
||||
return null
|
||||
}
|
||||
if (session.agent === 'antigravity' || session.agent === 'cursor') {
|
||||
return { key: 'conversation_id', id: session.sessionId }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context'
|
||||
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
|
||||
import { getAiVaultResumeWorkspaceExecutionHostId } from './ai-vault-resume-target'
|
||||
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import { CLIENT_PLATFORM } from '@/lib/new-workspace'
|
||||
import { resolveLocalWindowsTerminalShellOverrideForTab } from '../../../shared/local-windows-terminal-runtime'
|
||||
import { resolveWindowsShellStartupFamily } from '../../../shared/windows-terminal-shell'
|
||||
@@ -74,3 +77,50 @@ export function getAiVaultResumeWorkspacePath(
|
||||
.find((candidate) => candidate.id === targetWorktreeId)?.path ?? null
|
||||
)
|
||||
}
|
||||
|
||||
export function getAiVaultResumeWorkspaceWslDistro(
|
||||
state: Pick<AppState, 'folderWorkspaces' | 'repos' | 'worktreesByRepo'> &
|
||||
Partial<
|
||||
Pick<
|
||||
AppState,
|
||||
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups'
|
||||
>
|
||||
>,
|
||||
workspaceId: string | null | undefined
|
||||
): string | null | undefined {
|
||||
const workspacePath = getAiVaultResumeWorkspacePath(state, workspaceId)
|
||||
const workspaceKey = workspaceId ? parseWorkspaceKey(workspaceId) : null
|
||||
const runtimeState = {
|
||||
activeRepoId: state.activeRepoId ?? null,
|
||||
activeWorktreeId: state.activeWorktreeId ?? null,
|
||||
projects: state.projects ?? [],
|
||||
settings: state.settings ?? null,
|
||||
folderWorkspaces: state.folderWorkspaces,
|
||||
projectGroups: state.projectGroups ?? [],
|
||||
repos: state.repos,
|
||||
worktreesByRepo: state.worktreesByRepo
|
||||
}
|
||||
if (workspaceKey?.type === 'folder') {
|
||||
if (
|
||||
getAiVaultResumeWorkspaceExecutionHostId(runtimeState, workspaceId ?? null) !==
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
) {
|
||||
return null
|
||||
}
|
||||
if (getFolderWorkspaceCandidateRepos(runtimeState, workspaceKey.folderWorkspaceId).length > 1) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const runtime = getLocalProjectExecutionRuntimeContext(
|
||||
runtimeState,
|
||||
workspaceKey?.type === 'worktree' ? workspaceKey.worktreeId : workspaceId,
|
||||
CLIENT_PLATFORM
|
||||
)
|
||||
if (runtime?.status === 'repair-required') {
|
||||
return undefined
|
||||
}
|
||||
if (runtime?.status === 'resolved') {
|
||||
return runtime.runtime.kind === 'wsl' ? runtime.runtime.distro : null
|
||||
}
|
||||
return workspacePath ? (parseWslUncPath(workspacePath)?.distro ?? null) : null
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { isWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { isWslUncPath, parseWslUncPath } from '../../../shared/wsl-paths'
|
||||
import { antigravitySessionOrigin } from '../../../shared/antigravity-session-origin'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getIndexedWorktreeMap } from '@/store/worktree-repo-index'
|
||||
import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection'
|
||||
@@ -42,9 +43,36 @@ export function canResumeAiVaultSessionOnTarget(args: {
|
||||
sessionExecutionHostId?: ExecutionHostId | null
|
||||
targetStatus: AiVaultResumeTargetStatus
|
||||
targetExecutionHostId?: ExecutionHostId | null
|
||||
targetWslDistro?: string | null
|
||||
}): boolean {
|
||||
const sessionExecutionHostId = normalizeExecutionHostId(args.sessionExecutionHostId)
|
||||
const targetExecutionHostId = normalizeExecutionHostId(args.targetExecutionHostId)
|
||||
const origin = args.sessionFilePath ? antigravitySessionOrigin(args.sessionFilePath) : null
|
||||
if (origin && origin !== 'antigravity-cli') {
|
||||
if (!isSupportedAiVaultResumeTargetStatus(args.targetStatus)) {
|
||||
return false
|
||||
}
|
||||
const sourceHost = sessionExecutionHostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const targetHost =
|
||||
targetExecutionHostId ?? (args.targetStatus === 'local' ? LOCAL_EXECUTION_HOST_ID : null)
|
||||
if (sourceHost !== targetHost) {
|
||||
// #6270's SSH/UNC labels do not prove this host owns the referenced file.
|
||||
return false
|
||||
}
|
||||
if (args.targetStatus === 'local' && args.targetWslDistro === undefined) {
|
||||
return false
|
||||
}
|
||||
const sourceWsl = args.sessionFilePath ? parseWslUncPath(args.sessionFilePath) : null
|
||||
if (sourceWsl) {
|
||||
return (
|
||||
args.targetStatus === 'local' &&
|
||||
Boolean(args.targetWslDistro) &&
|
||||
sourceWsl.distro.toLowerCase() === args.targetWslDistro?.toLowerCase()
|
||||
)
|
||||
}
|
||||
// File references require the original filesystem, unlike legacy ID resumes.
|
||||
return args.targetStatus !== 'local' || !args.targetWslDistro
|
||||
}
|
||||
if (args.targetStatus === 'runtime') {
|
||||
// Runtime session stores live on one paired server; only queue resumes back
|
||||
// onto that exact server host.
|
||||
|
||||
@@ -9,7 +9,9 @@ import {
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import type { Repo } from '../../../shared/repo-types'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import { getIndexedRepoMap } from '@/store/worktree-repo-index'
|
||||
import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace'
|
||||
import { getProviderRuntimeContextKey } from './provider-runtime-context'
|
||||
import { getRendererAppPlatform } from './renderer-app-platform'
|
||||
import {
|
||||
@@ -35,11 +37,11 @@ export {
|
||||
type LocalProjectRuntimeState = Pick<
|
||||
AppState,
|
||||
'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo'
|
||||
>
|
||||
> &
|
||||
Partial<Pick<AppState, 'folderWorkspaces' | 'projectGroups'>>
|
||||
|
||||
// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}`
|
||||
// or `[]` fallback would miss the cache on every read.
|
||||
const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
|
||||
const EMPTY_REPOS: AppState['repos'] = []
|
||||
|
||||
type LocalProjectRuntimeWslContext = {
|
||||
@@ -66,7 +68,13 @@ export function getLocalProjectExecutionRuntimeContext(
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return undefined
|
||||
}
|
||||
const worktree = getLocalWorktree(state, worktreeId)
|
||||
const worktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
|
||||
if (
|
||||
!worktree &&
|
||||
parseWorkspaceKey(worktreeId ?? state.activeWorktreeId ?? '')?.type === 'folder'
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
const repo = getLocalRuntimeRepoForWorktree(state, worktree)
|
||||
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(worktree)) {
|
||||
return undefined
|
||||
@@ -262,7 +270,7 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext
|
||||
}
|
||||
|
||||
function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null {
|
||||
const activeWorktree = getLocalWorktree(state, worktreeId)
|
||||
const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
|
||||
const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree)
|
||||
if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) {
|
||||
return null
|
||||
@@ -275,6 +283,9 @@ function getLocalRuntimeRepoForWorktree(
|
||||
state: LocalProjectRuntimeState,
|
||||
worktree?: Pick<Worktree, 'repoId'> | null
|
||||
): Pick<Repo, 'id' | 'path' | 'connectionId' | 'executionHostId'> | undefined {
|
||||
if (!worktree && parseWorkspaceKey(state.activeWorktreeId ?? '')?.type === 'folder') {
|
||||
return undefined
|
||||
}
|
||||
const repoId = worktree?.repoId ?? state.activeRepoId
|
||||
return repoId ? getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) : undefined
|
||||
}
|
||||
@@ -303,25 +314,11 @@ function getLocalRuntimeProject(
|
||||
)
|
||||
}
|
||||
|
||||
function getLocalWorktree(
|
||||
state: LocalProjectRuntimeState,
|
||||
worktreeId?: string | null
|
||||
): Pick<Worktree, 'id' | 'repoId' | 'projectId' | 'path' | 'hostId'> | null {
|
||||
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
|
||||
if (!targetWorktreeId) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function getLocalPreflightProjectId(
|
||||
state: LocalProjectRuntimeState,
|
||||
worktreeId?: string | null
|
||||
): string {
|
||||
const activeWorktree = getLocalWorktree(state, worktreeId)
|
||||
const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId)
|
||||
return (
|
||||
activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project'
|
||||
)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { AppState } from '@/store/types'
|
||||
import { getIndexedWorktreeById } from '@/store/worktree-repo-index'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
|
||||
import {
|
||||
findFolderWorkspaceCandidateRepos,
|
||||
resolveFolderWorkspaceHost
|
||||
} from '../../../shared/folder-workspace-execution-host'
|
||||
import type { Worktree } from '../../../shared/worktree/types'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
|
||||
type RuntimeWorkspaceState = Pick<AppState, 'activeWorktreeId' | 'repos' | 'worktreesByRepo'> &
|
||||
Partial<Pick<AppState, 'folderWorkspaces' | 'projectGroups'>>
|
||||
|
||||
const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {}
|
||||
|
||||
export function getLocalProjectRuntimeWorkspace(
|
||||
state: RuntimeWorkspaceState,
|
||||
worktreeId?: string | null
|
||||
): Pick<Worktree, 'id' | 'repoId' | 'projectId' | 'path' | 'hostId'> | null {
|
||||
const targetWorktreeId = worktreeId ?? state.activeWorktreeId
|
||||
if (!targetWorktreeId) {
|
||||
return null
|
||||
}
|
||||
const scope = parseWorkspaceKey(targetWorktreeId)
|
||||
if (scope?.type !== 'folder') {
|
||||
return (
|
||||
getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
const folderState = {
|
||||
folderWorkspaces: state.folderWorkspaces ?? [],
|
||||
projectGroups: state.projectGroups ?? [],
|
||||
repos: state.repos
|
||||
}
|
||||
const folder = folderState.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||
if (!folder || resolveFolderWorkspaceHost(folderState, folder.id).kind !== 'local') {
|
||||
return null
|
||||
}
|
||||
const group = folderState.projectGroups.find((entry) => entry.id === folder.projectGroupId)
|
||||
if (
|
||||
(folder.executionHostId ?? group?.executionHostId ?? LOCAL_EXECUTION_HOST_ID) !==
|
||||
LOCAL_EXECUTION_HOST_ID
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const candidates = findFolderWorkspaceCandidateRepos(folderState, folder.id)
|
||||
const repo = candidates.length === 1 ? candidates[0] : undefined
|
||||
return repo && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID
|
||||
? {
|
||||
id: targetWorktreeId,
|
||||
repoId: repo.id,
|
||||
path: folder.folderPath,
|
||||
hostId: LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
: null
|
||||
}
|
||||
@@ -55,6 +55,7 @@ export function createWebAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault'
|
||||
}
|
||||
// Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host.
|
||||
return callRuntimeResult<AiVaultListResult>('aiVault.listSessions', {
|
||||
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions,
|
||||
limit: args?.limit,
|
||||
force: args?.force,
|
||||
scopePaths: args?.scopePaths,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// Resume-command construction for Agent Session History rows: turns a scanned
|
||||
// session into the shell line that re-enters it, quoted for the target platform
|
||||
// and (when known) the live tab's shell.
|
||||
import {
|
||||
isAntigravityReferenceSession,
|
||||
antigravityTranscriptReferencePrompt
|
||||
} from './antigravity-session-origin'
|
||||
import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
|
||||
import { TUI_AGENT_CONFIG } from './tui-agent-config'
|
||||
import {
|
||||
clearEnvCommand,
|
||||
@@ -41,7 +46,11 @@ export function buildAiVaultResumeCommand(args: {
|
||||
: shell
|
||||
? quoteStartupArg(resumeTarget, shell)
|
||||
: quoteShellArg(resumeTarget, platform)
|
||||
const resumeCommand = buildAgentResumeInvocation(agent, baseCommand, sessionArg)
|
||||
const referencePath = normalizeAiVaultResumeFilePath(resumeFilePath ?? undefined, platform)
|
||||
const resumeCommand =
|
||||
isAntigravityReferenceSession({ agent, filePath: referencePath }) && referencePath
|
||||
? `${baseCommand} --prompt-interactive ${quoteResumeArg(antigravityTranscriptReferencePrompt(referencePath), platform, shell)}`
|
||||
: buildAgentResumeInvocation(agent, baseCommand, sessionArg)
|
||||
|
||||
return buildAiVaultResumeShellCommand({
|
||||
resumeCommand,
|
||||
|
||||
@@ -116,6 +116,8 @@ export type AiVaultSession = {
|
||||
/** Older messages fell out of the newest-N window: the earliest preview turn
|
||||
* is NOT the opening ask, so first-prompt consumers must not scan it. */
|
||||
previewMessagesTruncated?: boolean
|
||||
/** Bounded opening-prompt identity for Antigravity history joins; never a rolling preview. */
|
||||
antigravityOpeningPrompt?: { hash: string; timestamp: string | null }
|
||||
/**
|
||||
* Full first non-injected user prompt. List scans omit this (payload/perf);
|
||||
* populated only by on-demand `aiVault.getFirstUserPrompt` re-parses for copy.
|
||||
@@ -212,6 +214,8 @@ export type AiVaultScanIssue = {
|
||||
}
|
||||
|
||||
export type AiVaultListArgs = {
|
||||
/** Opt-in promises this client starts IDE history in a new CLI conversation. */
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
force?: boolean
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
export const ANTIGRAVITY_HISTORY_ROOTS = [
|
||||
'antigravity-cli',
|
||||
'antigravity-ide',
|
||||
'antigravity'
|
||||
] as const
|
||||
export type AntigravitySessionOrigin = (typeof ANTIGRAVITY_HISTORY_ROOTS)[number]
|
||||
|
||||
export function antigravitySessionOrigin(filePath: string): AntigravitySessionOrigin | null {
|
||||
const segments = filePath.split(/[\\/]+/).filter(Boolean)
|
||||
const brainIndex = segments.lastIndexOf('brain')
|
||||
if (brainIndex < 2 || segments[brainIndex - 2] !== '.gemini') {
|
||||
return null
|
||||
}
|
||||
return ANTIGRAVITY_HISTORY_ROOTS.find((origin) => origin === segments[brainIndex - 1]) ?? null
|
||||
}
|
||||
|
||||
export function isAntigravityReferenceSession(session: {
|
||||
agent: string
|
||||
filePath?: string
|
||||
}): boolean {
|
||||
const origin = session.filePath ? antigravitySessionOrigin(session.filePath) : null
|
||||
return session.agent === 'antigravity' && origin !== null && origin !== 'antigravity-cli'
|
||||
}
|
||||
|
||||
export function antigravityTranscriptReferencePrompt(filePath: string): string {
|
||||
return `Review the chat transcript at ${JSON.stringify(filePath)}. Confirm your understanding of the previous conversation and continue from where it left off. This starts a new CLI conversation using the original transcript as a reference.`
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildAiVaultResumeCommand } from './ai-vault-resume-command'
|
||||
import { tokenizeCommandLine } from './agent-command-line-entrypoint'
|
||||
import { antigravityTranscriptReferencePrompt } from './antigravity-session-origin'
|
||||
import { normalizeAiVaultResumeFilePath } from './ai-vault-resume-path'
|
||||
|
||||
describe('Antigravity transcript reference launch', () => {
|
||||
it.each(['antigravity-ide', 'antigravity'])(
|
||||
'starts a new conversation for %s and preserves the explicit transcript argument',
|
||||
(origin) => {
|
||||
const path = `/home/example/.gemini/${origin}/brain/same-id/.system_generated/logs/transcript_full.jsonl`
|
||||
const command = buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'same-id',
|
||||
cwd: null,
|
||||
platform: 'linux',
|
||||
resumeFilePath: path
|
||||
})
|
||||
expect(tokenizeCommandLine(command)).toEqual([
|
||||
'agy',
|
||||
'--prompt-interactive',
|
||||
antigravityTranscriptReferencePrompt(path)
|
||||
])
|
||||
expect(command).not.toContain('--conversation')
|
||||
}
|
||||
)
|
||||
it('quotes hostile path text as one prompt argument', () => {
|
||||
const path = `/tmp/a'$(touch forbidden)/.gemini/antigravity-ide/brain/id/.system_generated/logs/transcript.jsonl`
|
||||
const command = buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'id',
|
||||
cwd: null,
|
||||
platform: 'linux',
|
||||
resumeFilePath: path,
|
||||
shell: 'posix'
|
||||
})
|
||||
expect(tokenizeCommandLine(command)).toEqual([
|
||||
'agy',
|
||||
'--prompt-interactive',
|
||||
antigravityTranscriptReferencePrompt(path)
|
||||
])
|
||||
})
|
||||
it('converts the selected WSL transcript into its owning Linux path', () => {
|
||||
const path =
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\example\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
|
||||
const normalized = normalizeAiVaultResumeFilePath(path, 'linux')
|
||||
const command = buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'id',
|
||||
cwd: null,
|
||||
platform: 'linux',
|
||||
resumeFilePath: path
|
||||
})
|
||||
expect(tokenizeCommandLine(command)).toEqual([
|
||||
'agy',
|
||||
'--prompt-interactive',
|
||||
antigravityTranscriptReferencePrompt(normalized!)
|
||||
])
|
||||
expect(command).not.toContain('wsl.localhost')
|
||||
})
|
||||
it.each(['cmd', 'powershell', 'posix'] as const)(
|
||||
'uses the actual Windows %s shell and preserves CLI resume behavior',
|
||||
(shell) => {
|
||||
const path =
|
||||
'C:\\User data\\.gemini\\antigravity-ide\\brain\\id\\.system_generated\\logs\\transcript_full.jsonl'
|
||||
const command = buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'id',
|
||||
cwd: null,
|
||||
platform: 'win32',
|
||||
resumeFilePath: path,
|
||||
shell
|
||||
})
|
||||
expect(command).toContain('--prompt-interactive')
|
||||
expect(command).not.toContain('--conversation')
|
||||
const cli = buildAiVaultResumeCommand({
|
||||
agent: 'antigravity',
|
||||
sessionId: 'id',
|
||||
cwd: null,
|
||||
platform: 'win32',
|
||||
resumeFilePath: path.replace('antigravity-ide', 'antigravity-cli'),
|
||||
shell
|
||||
})
|
||||
expect(cli).toContain('--conversation')
|
||||
expect(cli).not.toContain('--prompt-interactive')
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { closeSync, fstatSync, openSync, readSync, type Stats } from 'node:fs'
|
||||
import { open, type FileHandle } from 'node:fs/promises'
|
||||
import { closeSync, constants, fstatSync, openSync, readSync, type Stats } from 'node:fs'
|
||||
import { open, stat, type FileHandle } from 'node:fs/promises'
|
||||
|
||||
const MIN_GROWTH_BYTES = 64 * 1024
|
||||
|
||||
@@ -20,6 +20,8 @@ export type BoundedNodeFileRead = {
|
||||
stats: Stats
|
||||
}
|
||||
|
||||
type NodeFileReadOptions = { regularFileOnly?: boolean; signal?: AbortSignal }
|
||||
|
||||
function validateSize(size: number, maxBytes: number): void {
|
||||
if (!Number.isSafeInteger(size) || size < 0) {
|
||||
throw new Error('File has an invalid byte size')
|
||||
@@ -31,11 +33,21 @@ function validateSize(size: number, maxBytes: number): void {
|
||||
|
||||
export async function readNodeFileWithinLimit(
|
||||
filePath: string,
|
||||
maxBytes: number
|
||||
maxBytes: number,
|
||||
options: NodeFileReadOptions = {}
|
||||
): Promise<BoundedNodeFileRead> {
|
||||
const handle = await open(filePath, 'r')
|
||||
options.signal?.throwIfAborted()
|
||||
if (options.regularFileOnly && !(await stat(filePath)).isFile()) {
|
||||
throw new Error('Expected a regular file')
|
||||
}
|
||||
options.signal?.throwIfAborted()
|
||||
// Nonblocking open fences replacement with a FIFO after the path check.
|
||||
const flags = options.regularFileOnly
|
||||
? constants.O_RDONLY | (process.platform === 'win32' ? 0 : constants.O_NONBLOCK)
|
||||
: 'r'
|
||||
const handle = await open(filePath, flags)
|
||||
try {
|
||||
return await readNodeFileHandleWithinLimit(handle, maxBytes)
|
||||
return await readNodeFileHandleWithinLimit(handle, maxBytes, options)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
@@ -43,20 +55,28 @@ export async function readNodeFileWithinLimit(
|
||||
|
||||
export async function readNodeFileHandleWithinLimit(
|
||||
handle: FileHandle,
|
||||
maxBytes: number
|
||||
maxBytes: number,
|
||||
options: NodeFileReadOptions = {}
|
||||
): Promise<BoundedNodeFileRead> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
|
||||
throw new RangeError('File read limit must be a non-negative safe integer')
|
||||
}
|
||||
|
||||
options.signal?.throwIfAborted()
|
||||
const stats = await handle.stat()
|
||||
options.signal?.throwIfAborted()
|
||||
if (options.regularFileOnly && !stats.isFile()) {
|
||||
throw new Error('Expected a regular file')
|
||||
}
|
||||
validateSize(stats.size, maxBytes)
|
||||
|
||||
let buffer = Buffer.allocUnsafe(stats.size)
|
||||
let offset = 0
|
||||
while (true) {
|
||||
options.signal?.throwIfAborted()
|
||||
while (offset < buffer.length) {
|
||||
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
|
||||
options.signal?.throwIfAborted()
|
||||
if (bytesRead === 0) {
|
||||
return { buffer: buffer.subarray(0, offset), stats }
|
||||
}
|
||||
@@ -65,6 +85,7 @@ export async function readNodeFileHandleWithinLimit(
|
||||
|
||||
const probe = Buffer.allocUnsafe(1)
|
||||
const { bytesRead } = await handle.read(probe, 0, 1, offset)
|
||||
options.signal?.throwIfAborted()
|
||||
if (bytesRead === 0) {
|
||||
return { buffer: buffer.subarray(0, offset), stats }
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export const AiVaultListSessionsParams = z
|
||||
)
|
||||
.pipe(z.union([z.number().int(), z.undefined()]))
|
||||
.optional(),
|
||||
includeAntigravityIdeSessions: OptionalBoolean,
|
||||
unlimited: OptionalBoolean,
|
||||
force: OptionalBoolean,
|
||||
scopePaths: z
|
||||
|
||||
@@ -6,6 +6,7 @@ export const SSH_AI_VAULT_LIST_LIMIT_MAX = 1000
|
||||
export const SSH_AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
|
||||
|
||||
export type SshAiVaultRelayListParams = {
|
||||
includeAntigravityIdeSessions?: boolean
|
||||
limit?: number
|
||||
unlimited?: boolean
|
||||
force?: boolean
|
||||
|
||||
Reference in New Issue
Block a user