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:
Neil
2026-10-02 23:01:12 -07:00
committed by GitHub
parent 8f26bfad22
commit 055871ade6
67 changed files with 2257 additions and 147 deletions
@@ -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()] }
}
+5 -1
View File
@@ -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.
+22 -16
View File
@@ -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,
+18
View File
@@ -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).
+6 -3
View File
@@ -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
}
+7
View File
@@ -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,
+3
View File
@@ -80,6 +80,9 @@ function runtimeScanArgs(
if (listArgs?.scopePaths !== undefined) {
scanArgs.scopePaths = listArgs.scopePaths
}
if (listArgs?.includeAntigravityIdeSessions === true) {
scanArgs.includeAntigravityIdeSessions = true
}
return scanArgs
}
+6 -1
View File
@@ -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)
+5 -15
View File
@@ -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 }))
+2 -1
View File
@@ -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) {
+4
View File
@@ -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 } : {}),
+1
View File
@@ -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
})
+15 -1
View File
@@ -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(
+5
View File
@@ -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
}
+29 -1
View File
@@ -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.
+17 -20
View File
@@ -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,
+10 -1
View File
@@ -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,
+4
View File
@@ -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
+27
View File
@@ -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')
}
)
})
+27 -6
View File
@@ -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
+1
View File
@@ -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