diff --git a/docs/reference/antigravity-native-chat.md b/docs/reference/antigravity-native-chat.md new file mode 100644 index 00000000000..c08881143c1 --- /dev/null +++ b/docs/reference/antigravity-native-chat.md @@ -0,0 +1,53 @@ +# Antigravity terminal-backed Chat UI + +Antigravity uses the existing experimental Chat UI over its terminal and saved +transcript. It is not a structured-session provider. The opt-in default-chat +setting applies, and users can return to the terminal. + +## Transcript contract + +The execution host reads +`.gemini/antigravity-cli/brain//.system_generated/logs/transcript.jsonl`. +A hook-reported transcript path takes precedence. Existing WSL exact-path and +host-isolation rules apply; a missing guest transcript must not fall back to a +native host's same-named conversation. Direct SSH panes stay in the terminal: +that connection has no native-chat transcript transport, and its absolute file +path must never be opened on the client. Paired runtimes remain eligible because +their own host reads the transcript. Direct SSH chat support remains unfinished. + +The sanitized fixture in `src/main/native-chat/__fixtures__/antigravity/` records +these observed shapes: + +- User input is `USER_EXPLICIT/USER_INPUT` with a `USER_REQUEST` wrapper. +- Planner responses carry text, thinking, or tool calls. Tool arguments are in `args`. +- Tool output is a `MODEL` record with a tool-specific type such as `RUN_COMMAND` + or `VIEW_FILE`. It is not a `TOOL_RESULT` record. +- A planner step marked `DONE` can precede more tool work in the same user turn. + The decoder therefore emits no turn-completion verdict. The existing host-owned + hook/status system remains authoritative. + +Full reads, incremental tails, and legacy journal imports reuse the same decoder. +Unknown record types are skipped. Tool failures retain a nonzero exit code or +`ERROR` status as an error result. + +## Mixed versions + +New hosts advertise `native-chat.antigravity.v1`. A new client's chat transport +checks the owning host before reading or subscribing to Antigravity. An older +host gets the existing update-runtime error; a failed capability probe gets the +existing read error. Neither becomes a perpetual first-transcript wait. +No stream opcode or message-block type was added. + +## Verification limits + +On macOS with installed agy 1.2.7, a resumed test conversation rendered from its +real saved transcript, and a message submitted in the chat composer appeared in +that transcript. Generation returned `401 ACCESS_TOKEN_TYPE_UNSUPPORTED`. +Hooks were disabled in the isolated app profile, so pane/session metadata was +supplied as a fixture; this did not validate actual hook delivery or live status +transitions. Tool rendering was checked separately with the sanitized fixture. + +Five bounded real transcript samples supplied 309 tool/activity records; the +corrected decoder preserved all 309. This is offline format evidence, not proof +of a successful new model turn. Windows, Linux, WSL, SSH, paired-runtime execution, +permission questions, and successful tool-generating turns still need live QA. diff --git a/src/main/ai-vault/session-scanner-antigravity-parser.ts b/src/main/ai-vault/session-scanner-antigravity-parser.ts index 775cc228370..ba4b7f67151 100644 --- a/src/main/ai-vault/session-scanner-antigravity-parser.ts +++ b/src/main/ai-vault/session-scanner-antigravity-parser.ts @@ -120,7 +120,7 @@ function consumeAntigravityRecordLine(accumulator: SessionAccumulator, line: str } } -function extractAntigravityUserRequest(content: string): string | null { +export function extractAntigravityUserRequest(content: string): string | null { const opener = '' const startIndex = content.indexOf(opener) if (startIndex === -1) { diff --git a/src/main/native-chat/__fixtures__/antigravity/README.md b/src/main/native-chat/__fixtures__/antigravity/README.md new file mode 100644 index 00000000000..babaccff9da --- /dev/null +++ b/src/main/native-chat/__fixtures__/antigravity/README.md @@ -0,0 +1,8 @@ +This fixture preserves the record and nested tool-call shapes observed in local +Antigravity CLI transcripts on 2026-09-19. Conversation text, tool arguments, +timestamps, and step numbers are replaced with harmless synthetic values. + +A planner step with `status: DONE` can precede a tool step and another planner +response without a new user prompt. It does not prove completion of the user turn. +Tool results use `source: MODEL` and their tool-specific step type; planner tool +calls carry arguments in `args`. diff --git a/src/main/native-chat/__fixtures__/antigravity/tool-turn.jsonl b/src/main/native-chat/__fixtures__/antigravity/tool-turn.jsonl new file mode 100644 index 00000000000..255ef51ccd5 --- /dev/null +++ b/src/main/native-chat/__fixtures__/antigravity/tool-turn.jsonl @@ -0,0 +1,5 @@ +{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-09-19T10:00:00Z","content":"Inspect the sample file."} +{"step_index":1,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-09-19T10:00:01Z","thinking":"I will inspect the sample.","tool_calls":[{"name":"run_command","args":{"CommandLine":"printf sample","Cwd":"/workspace","WaitMsBeforeAsync":1000,"toolAction":"Inspect","toolSummary":"Read sample"}}]} +{"step_index":2,"source":"MODEL","type":"RUN_COMMAND","status":"DONE","exit_code":0,"created_at":"2026-09-19T10:00:02Z","content":"sample"} +{"step_index":3,"source":"SYSTEM","type":"CHECKPOINT","status":"DONE","created_at":"2026-09-19T10:00:03Z"} +{"step_index":4,"source":"MODEL","type":"PLANNER_RESPONSE","status":"DONE","created_at":"2026-09-19T10:00:04Z","content":"The sample file contains sample."} diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index 801c91fb534..a0b417a1660 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -22,6 +22,7 @@ import type { NativeChatBlock, NativeChatMessage } from '../../../shared/native- import { resolveNativeChatTranscriptAgent } from '../../../shared/native-chat-agent-support' import { resolveSessionFilePath, type ResolveSessionFileOptions } from '../session-file-resolver' import { + decodeAntigravityTranscriptLine, decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, @@ -172,6 +173,7 @@ export async function prepareLegacyTranscriptImport(input: { const TRANSCRIPT_DECODERS = { claude: decodeClaudeTranscriptLine, codex: decodeCodexTranscriptLine, + antigravity: decodeAntigravityTranscriptLine, grok: decodeGrokTranscriptLine, omp: decodeOmpTranscriptLine } as const diff --git a/src/main/native-chat/session-file-resolver.ts b/src/main/native-chat/session-file-resolver.ts index 55746b12b2a..2e0b807d839 100644 --- a/src/main/native-chat/session-file-resolver.ts +++ b/src/main/native-chat/session-file-resolver.ts @@ -84,6 +84,8 @@ export type ResolveSessionFileOptions = { grokSessionsDir?: string /** Override the omp sessions root (`~/.omp/agent/sessions`). */ ompSessionsDir?: string + /** Antigravity CLI brain root on the execution host. */ + antigravityBrainDir?: string /** Authoritative transcript path reported by the agent hook * (`providerSession.transcriptPath`). When set and the file exists, it is used * directly — recent Claude Code names the transcript with a UUID that differs @@ -210,6 +212,22 @@ async function resolveSessionFileById( signal ) } + if (transcriptAgent === 'antigravity') { + // A conversation id is one directory segment, never a caller-supplied path. + if (!/^[a-zA-Z0-9_-]+$/.test(trimmedId)) { + return null + } + return toHostReadableTranscriptPath( + join( + options.antigravityBrainDir ?? join(homedir(), '.gemini', 'antigravity-cli', 'brain'), + trimmedId, + '.system_generated', + 'logs', + 'transcript.jsonl' + ), + { signal } + ) + } if (transcriptAgent === 'grok') { return resolveGrokSessionFile(trimmedId, options.grokSessionsDir ?? grokSessionsDir(), signal) } diff --git a/src/main/native-chat/transcript-line-decoders-antigravity.ts b/src/main/native-chat/transcript-line-decoders-antigravity.ts new file mode 100644 index 00000000000..80369d61347 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders-antigravity.ts @@ -0,0 +1,123 @@ +// Antigravity (AGY) JSONL line → NativeChatMessage decoder. + +import type { NativeChatBlock, NativeChatMessage } from '../../shared/native-chat-types' +import { + asRecord, + extractString, + parseJsonObject, + timestampMs +} from '../ai-vault/session-scanner-values' +import { extractAntigravityUserRequest } from '../ai-vault/session-scanner-antigravity-parser' + +const TOOL_STEP_TYPES = new Set([ + 'RUN_COMMAND', + 'LIST_DIRECTORY', + 'GENERIC', + 'INVOKE_SUBAGENT', + 'VIEW_FILE', + 'CODE_ACTION', + 'GREP_SEARCH', + 'SEARCH_WEB' +]) + +export function decodeAntigravityTranscriptLine( + line: string, + fallbackId: string +): NativeChatMessage | null { + const record = parseJsonObject(line) + if (!record) { + return null + } + + const timestamp = parseTimestamp(record.created_at ?? record.timestamp) + const stepIndex = + typeof record.step_index === 'number' + ? String(record.step_index) + : (extractString(record.step_index) ?? extractString(record.id)) + const id = stepIndex ?? fallbackId + const source = extractString(record.source) + const type = extractString(record.type) + + if ( + (source === 'USER_EXPLICIT' || source === 'USER') && + (type === 'USER_INPUT' || type === 'REQUEST') + ) { + const rawContent = extractString(record.content) ?? '' + const text = extractAntigravityUserRequest(rawContent) || rawContent + if (!text.trim()) { + return null + } + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp, + source: 'transcript' + } + } + + if (source === 'MODEL' && type === 'PLANNER_RESPONSE') { + const blocks: NativeChatBlock[] = [] + + const thinking = extractString(record.thinking) + if (thinking && thinking.trim()) { + blocks.push({ type: 'text', text: `> *Thinking:*\n${thinking}` }) + } + + const content = extractString(record.content) + if (content && content.trim()) { + blocks.push({ type: 'text', text: content }) + } + + if (Array.isArray(record.tool_calls)) { + for (const call of record.tool_calls) { + const tool = asRecord(call) + if (tool) { + const name = extractString(tool.name) ?? 'tool' + const input = tool.args ?? tool.arguments ?? tool.input ?? null + blocks.push({ + type: 'tool-call', + name, + input + }) + } + } + } + + if (blocks.length === 0) { + return null + } + + return { + id, + role: 'assistant', + blocks, + timestamp, + source: 'transcript' + } + } + + // Recorded tool steps are MODEL records, not TOOL_RESULT messages. + if (source === 'MODEL' && type && TOOL_STEP_TYPES.has(type)) { + const output = extractString(record.content) + if (!output) { + return null + } + const isError = + record.status === 'ERROR' || (typeof record.exit_code === 'number' && record.exit_code !== 0) + return { + id, + role: 'tool', + blocks: [{ type: 'tool-result', output, ...(isError ? { isError: true } : {}) }], + timestamp, + source: 'transcript' + } + } + + return null +} + +function parseTimestamp(value: unknown): number | null { + const parsed = timestampMs(value) + return Number.isFinite(parsed) ? parsed : null +} diff --git a/src/main/native-chat/transcript-line-decoders.antigravity.test.ts b/src/main/native-chat/transcript-line-decoders.antigravity.test.ts new file mode 100644 index 00000000000..e2eb33af4b2 --- /dev/null +++ b/src/main/native-chat/transcript-line-decoders.antigravity.test.ts @@ -0,0 +1,76 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { decodeAntigravityTranscriptLine } from './transcript-line-decoders-antigravity' + +const lines = readFileSync( + new URL('./__fixtures__/antigravity/tool-turn.jsonl', import.meta.url), + 'utf8' +) + .trim() + .split('\n') + +function decode(record: unknown) { + return decodeAntigravityTranscriptLine(JSON.stringify(record), 'line-1') +} + +describe('Antigravity transcript records', () => { + it('preserves the recorded prompt, tool arguments, output, and subsequent response', () => { + const messages = lines.map((line, index) => + decodeAntigravityTranscriptLine(line, `line-${index}`) + ) + expect(messages[0]).toMatchObject({ + role: 'user', + blocks: [{ type: 'text', text: 'Inspect the sample file.' }] + }) + expect(messages[1]?.blocks).toContainEqual({ + type: 'tool-call', + name: 'run_command', + input: { + CommandLine: 'printf sample', + Cwd: '/workspace', + WaitMsBeforeAsync: 1000, + toolAction: 'Inspect', + toolSummary: 'Read sample' + } + }) + expect(messages[2]).toMatchObject({ + role: 'tool', + blocks: [{ type: 'tool-result', output: 'sample' }] + }) + expect(messages[3]).toBeNull() + expect(messages[4]).toMatchObject({ + role: 'assistant', + blocks: [{ type: 'text', text: 'The sample file contains sample.' }] + }) + }) + + it.each([ + 'RUN_COMMAND', + 'VIEW_FILE', + 'LIST_DIRECTORY', + 'GREP_SEARCH', + 'CODE_ACTION', + 'GENERIC', + 'INVOKE_SUBAGENT' + ])('keeps recorded %s output without treating step completion as turn completion', (type) => { + expect(decode({ source: 'MODEL', type, status: 'DONE', content: 'output' })).toMatchObject({ + role: 'tool', + blocks: [{ type: 'tool-result', output: 'output' }] + }) + }) + + it.each([{ status: 'ERROR' }, { status: 'DONE', exit_code: 1 }])( + 'marks failed tool results', + (fields) => { + expect( + decode({ source: 'MODEL', type: 'RUN_COMMAND', content: 'failed', ...fields })?.blocks + ).toEqual([{ type: 'tool-result', output: 'failed', isError: true }]) + } + ) + + it('ignores malformed, empty, and system bookkeeping records', () => { + expect(decodeAntigravityTranscriptLine('{', 'line')).toBeNull() + expect(decode({ source: 'MODEL', type: 'PLANNER_RESPONSE' })).toBeNull() + expect(decode({ source: 'SYSTEM', type: 'CHECKPOINT', content: 'private context' })).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-line-decoders.ts b/src/main/native-chat/transcript-line-decoders.ts index 2d84d1a5586..ccf05aaa672 100644 --- a/src/main/native-chat/transcript-line-decoders.ts +++ b/src/main/native-chat/transcript-line-decoders.ts @@ -13,3 +13,5 @@ export { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude' export { decodeCodexTranscriptLine } from './transcript-line-decoders-codex' export { decodeGrokTranscriptLine } from './transcript-line-decoders-grok' export { decodeOmpTranscriptLine } from './transcript-line-decoders-omp' + +export { decodeAntigravityTranscriptLine } from './transcript-line-decoders-antigravity' diff --git a/src/main/native-chat/transcript-reader-antigravity.test.ts b/src/main/native-chat/transcript-reader-antigravity.test.ts new file mode 100644 index 00000000000..e1d3a0d6199 --- /dev/null +++ b/src/main/native-chat/transcript-reader-antigravity.test.ts @@ -0,0 +1,67 @@ +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveSessionFilePath } from './session-file-resolver' +import { readNativeChatTranscript } from './transcript-reader' +import { + nativeChatLineDecoderForAgent, + readNativeChatTranscriptTailFile +} from './transcript-tail-reader' +import { nativeChatTurnLifecycleDecoderForAgent } from './transcript-turn-lifecycle' + +let root: string | undefined + +afterEach(async () => { + if (root) { + await rm(root, { recursive: true, force: true }) + } + root = undefined +}) + +async function fixture() { + root = await mkdtemp(join(tmpdir(), 'orca-agy-chat-')) + const dir = join(root, 'conversation', '.system_generated', 'logs') + await mkdir(dir, { recursive: true }) + const path = join(dir, 'transcript.jsonl') + await copyFile(new URL('./__fixtures__/antigravity/tool-turn.jsonl', import.meta.url), path) + return { path, options: { antigravityBrainDir: root } } +} + +describe('Antigravity native transcript reading', () => { + it('resolves its CLI conversation and preserves full-reader / tail-reader parity', async () => { + const { path, options } = await fixture() + expect(await resolveSessionFilePath('antigravity', 'conversation', options)).toBe(path) + const full = await readNativeChatTranscript('antigravity', 'conversation', options) + const decode = nativeChatLineDecoderForAgent('antigravity') + expect(decode).not.toBeNull() + if (!decode || !('messages' in full)) { + throw new Error('transcript reader unavailable') + } + const tail = await readNativeChatTranscriptTailFile(path, 20, decode) + expect(tail.messages).toEqual(full.messages) + expect(full.messages.map((message) => message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'assistant' + ]) + expect(nativeChatTurnLifecycleDecoderForAgent('antigravity')).toBeNull() + expect(full.lifecycle).toBeUndefined() + expect(tail.lifecycle).toBeUndefined() + }) + + it('rejects path-shaped ids and does not substitute a native file for a WSL session', async () => { + const { options } = await fixture() + for (const id of ['../conversation', '..\\conversation', '/conversation', 'C:conversation']) { + expect(await resolveSessionFilePath('antigravity', id, options)).toBeNull() + } + expect( + await resolveSessionFilePath('antigravity', 'conversation', { + ...options, + wslDistro: 'Ubuntu' + }) + ).toBeNull() + expect(await resolveSessionFilePath('antigravity', 'missing', options)).toBeNull() + }) +}) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index a4d3acb7465..f31deabaa22 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -9,6 +9,7 @@ import { resolveSessionFilePath, type ResolveSessionFileOptions } from './sessio import { openTranscriptReadStream } from './wsl-transcript-fs-access' import { wslTranscriptFsRefusal } from './wsl-transcript-fs-gate' import { + decodeAntigravityTranscriptLine, decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, @@ -55,6 +56,9 @@ export async function readNativeChatTranscript( } try { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'antigravity') { + return { messages: await readTranscript(filePath, decodeAntigravityTranscriptLine) } + } if (transcriptAgent === 'claude') { return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) } } diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts index b6fa9bafdb0..30226136788 100644 --- a/src/main/native-chat/transcript-tail-reader.ts +++ b/src/main/native-chat/transcript-tail-reader.ts @@ -6,6 +6,7 @@ import type { import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support' import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver' import { + decodeAntigravityTranscriptLine, decodeClaudeTranscriptLine, decodeCodexTranscriptLine, decodeGrokTranscriptLine, @@ -35,6 +36,9 @@ export type NativeChatLineDecoder = (line: string, fallbackId: string) => Native export function nativeChatLineDecoderForAgent(agent: AgentType): NativeChatLineDecoder | null { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) + if (transcriptAgent === 'antigravity') { + return decodeAntigravityTranscriptLine + } if (transcriptAgent === 'claude') { return decodeClaudeTranscriptLine } diff --git a/src/renderer/src/components/native-chat/native-chat-antigravity-capability.test.ts b/src/renderer/src/components/native-chat/native-chat-antigravity-capability.test.ts new file mode 100644 index 00000000000..37421ddecc1 --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-antigravity-capability.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from 'vitest' +import { guardAntigravityChatTransport } from './native-chat-antigravity-capability' +import { + RUNTIME_NATIVE_CHAT_READ_ERROR, + RUNTIME_NATIVE_CHAT_TOO_OLD +} from './native-chat-runtime-contract' + +const args = { + agent: 'antigravity' as const, + sessionId: 'conversation', + subscriptionId: 'subscription' +} + +function setup(supports: () => Promise) { + const stop = vi.fn() + const transport = { + readSession: vi.fn(async () => ({ messages: [] })), + subscribe: vi.fn(() => stop) + } + return { transport, stop, guarded: guardAntigravityChatTransport(transport, supports) } +} + +describe('Antigravity chat host capability', () => { + it('rejects old hosts before read or subscribe without claiming the transcript is pending', async () => { + const { guarded, transport } = setup(async () => false) + expect(await guarded.readSession('antigravity', 'conversation')).toEqual({ + error: RUNTIME_NATIVE_CHAT_TOO_OLD + }) + const onFrame = vi.fn() + const close = await guarded.subscribe(args, onFrame) + await vi.waitFor(() => + expect(onFrame).toHaveBeenCalledWith({ + type: 'snapshot', + messages: [], + hasMore: false, + error: RUNTIME_NATIVE_CHAT_TOO_OLD + }) + ) + expect(transport.readSession).not.toHaveBeenCalled() + expect(transport.subscribe).not.toHaveBeenCalled() + close() + }) + + it('keeps host-contact failure separate from unsupported versions', async () => { + const { guarded } = setup(async () => { + throw new Error('offline') + }) + expect(await guarded.readSession('antigravity', 'conversation')).toEqual({ + error: RUNTIME_NATIVE_CHAT_READ_ERROR + }) + }) + + it('does not open a subscription after teardown while the probe was pending', async () => { + let resolve: ((supported: boolean) => void) | undefined + const { guarded, transport } = setup( + () => + new Promise((done) => { + resolve = done + }) + ) + const close = await guarded.subscribe(args, vi.fn()) + close() + resolve?.(true) + await Promise.resolve() + await Promise.resolve() + expect(transport.subscribe).not.toHaveBeenCalled() + }) + + it('uses supported hosts and closes their subscription once', async () => { + const { guarded, transport, stop } = setup(async () => true) + await guarded.readSession('antigravity', 'conversation') + expect(transport.readSession).toHaveBeenCalledWith('antigravity', 'conversation') + const close = await guarded.subscribe(args, vi.fn()) + await vi.waitFor(() => expect(transport.subscribe).toHaveBeenCalledOnce()) + close() + expect(stop).toHaveBeenCalledOnce() + }) + + it('leaves existing agents on their existing transport', async () => { + const supports = vi.fn(async () => false) + const { guarded, transport } = setup(supports) + await guarded.readSession('claude', 'conversation') + await guarded.subscribe({ ...args, agent: 'claude' }, vi.fn()) + expect(transport.readSession).toHaveBeenCalledOnce() + expect(transport.subscribe).toHaveBeenCalledOnce() + expect(supports).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/native-chat/native-chat-antigravity-capability.ts b/src/renderer/src/components/native-chat/native-chat-antigravity-capability.ts new file mode 100644 index 00000000000..21b60da3f5f --- /dev/null +++ b/src/renderer/src/components/native-chat/native-chat-antigravity-capability.ts @@ -0,0 +1,70 @@ +import type { NativeChatApi } from '../../../../preload/api-types' +import { + RUNTIME_NATIVE_CHAT_READ_ERROR, + RUNTIME_NATIVE_CHAT_TOO_OLD +} from './native-chat-runtime-contract' + +type Transport = Pick + +/** An older host cannot decode agy; distinguish that from a transcript awaiting its first write. */ +export function guardAntigravityChatTransport( + transport: Transport, + supports: () => Promise +): Transport { + const errorForHost = async (): Promise => { + try { + return (await supports()) ? null : RUNTIME_NATIVE_CHAT_TOO_OLD + } catch { + return RUNTIME_NATIVE_CHAT_READ_ERROR + } + } + return { + readSession: async (agent, ...args) => { + const error = agent === 'antigravity' ? await errorForHost() : null + return error ? { error } : transport.readSession(agent, ...args) + }, + subscribe: (args, onFrame) => { + if (args.agent !== 'antigravity') { + return transport.subscribe(args, onFrame) + } + let cancelled = false + let unsubscribe: (() => void) | undefined + void errorForHost() + .then(async (error) => { + if (cancelled) { + return + } + if (error) { + onFrame({ type: 'snapshot', messages: [], hasMore: false, error }) + return + } + const stop = await Promise.resolve( + transport.subscribe(args, (frame) => { + if (!cancelled) { + onFrame(frame) + } + }) + ) + if (cancelled) { + stop() + } else { + unsubscribe = stop + } + }) + .catch(() => { + if (!cancelled) { + onFrame({ + type: 'snapshot', + messages: [], + hasMore: false, + error: RUNTIME_NATIVE_CHAT_READ_ERROR + }) + } + }) + return () => { + cancelled = true + unsubscribe?.() + } + } + } +} diff --git a/src/renderer/src/components/native-chat/native-chat-availability.test.ts b/src/renderer/src/components/native-chat/native-chat-availability.test.ts index 409f8c7329f..d5c256f47c7 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.test.ts @@ -90,20 +90,22 @@ describe('canToggleNativeChat', () => { ).toBe(false) }) - // Why: omp discloses no hook transcript path either, so its session file is - // only reachable when this process can read the agent's disk. - it('rejects Model-A SSH omp but accepts it local and runtime-owned', () => { - const forConnection = (connectionId: string | null): boolean => - canToggleNativeChat({ - experimentalNativeChatEnabled: true, - contentType: 'terminal', - launchAgent: 'omp', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) - }) - expect(forConnection('ssh-target-1')).toBe(false) - expect(forConnection(null)).toBe(true) - expect(forConnection('runtime-ssh-env-1')).toBe(true) - }) + // Direct SSH has no transcript transport, even when a hook names the remote file. + it.each(['omp', 'antigravity'] as const)( + 'rejects Model-A SSH %s but accepts it local and runtime-owned', + (agent) => { + const forConnection = (connectionId: string | null): boolean => + canToggleNativeChat({ + experimentalNativeChatEnabled: true, + contentType: 'terminal', + launchAgent: agent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBe(false) + expect(forConnection(null)).toBe(true) + expect(forConnection('runtime-ssh-env-1')).toBe(true) + } + ) it('lets an existing Model-A SSH Grok chat toggle back to terminal', () => { expect( diff --git a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts index 329c93f852f..d67c269f9ea 100644 --- a/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts +++ b/src/renderer/src/components/native-chat/native-chat-runtime-contract.ts @@ -4,6 +4,9 @@ import type { } from '../../../../preload/api-types' import type { NativeChatTurnLifecycle } from '../../../../shared/native-chat-types' +export const RUNTIME_NATIVE_CHAT_TOO_OLD = + 'This remote runtime is too old to show agent chat history. Update the remote runtime to view it.' + export const RUNTIME_NATIVE_CHAT_READ_ERROR = "Couldn't read agent chat from the remote runtime." export function parseRuntimeNativeChatTurnLifecycle( diff --git a/src/renderer/src/components/native-chat/native-chat-session-transport.ts b/src/renderer/src/components/native-chat/native-chat-session-transport.ts index 112e7cdc3e0..222b6dab071 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-transport.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-transport.ts @@ -1,7 +1,11 @@ +import { ANTIGRAVITY_NATIVE_CHAT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { ensureLocalRuntimeCapabilities } from '@/runtime/local-runtime-capabilities' +import { guardAntigravityChatTransport } from './native-chat-antigravity-capability' import type { NativeChatApi, NativeChatAppendedMessages } from '../../../../preload/api-types' import { isWebClientLocation } from '@/lib/web-client-location' import { callRuntimeRpc, + runtimeEnvironmentSupportsCapability, RuntimeRpcCallError, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' @@ -9,7 +13,8 @@ import { isRuntimeCompatBlockError } from '@/runtime/runtime-protocol-compat' import { parseRuntimeNativeChatReadSessionResult, parseRuntimeNativeChatTurnLifecycle, - RUNTIME_NATIVE_CHAT_READ_ERROR + RUNTIME_NATIVE_CHAT_READ_ERROR, + RUNTIME_NATIVE_CHAT_TOO_OLD } from './native-chat-runtime-contract' /** The read/subscribe surface the live-session hook needs, decoupled from where @@ -17,9 +22,6 @@ import { * hook and everything downstream (merge, assembler, pagination) are unchanged. */ export type NativeChatSessionTransport = Pick -const RUNTIME_TOO_OLD = - 'This remote runtime is too old to show agent chat history. Update the remote runtime to view it.' - /** Backoff before re-opening a dropped runtime chat stream. Exported for tests. */ export const RUNTIME_NATIVE_CHAT_RECONNECT_MS = 2_000 @@ -30,10 +32,10 @@ export const RUNTIME_NATIVE_CHAT_RECONNECT_MS = 2_000 * never mislabeled as a version problem (KTD-4, not catch-all). */ export function toRuntimeNativeChatErrorMessage(err: unknown): string { if (err instanceof RuntimeRpcCallError && err.code === 'method_not_found') { - return RUNTIME_TOO_OLD + return RUNTIME_NATIVE_CHAT_TOO_OLD } if (isRuntimeCompatBlockError(err)) { - return RUNTIME_TOO_OLD + return RUNTIME_NATIVE_CHAT_TOO_OLD } return RUNTIME_NATIVE_CHAT_READ_ERROR } @@ -268,8 +270,22 @@ function createRuntimeNativeChatTransport(environmentId: string): NativeChatSess export function getNativeChatSessionTransport( runtimeEnvironmentId: string | null ): NativeChatSessionTransport { - if (runtimeEnvironmentId && !isWebClientLocation()) { - return createRuntimeNativeChatTransport(runtimeEnvironmentId) - } - return localNativeChatTransport + const webClient = isWebClientLocation() + const remote = runtimeEnvironmentId && !webClient ? runtimeEnvironmentId : null + const transport = remote ? createRuntimeNativeChatTransport(remote) : localNativeChatTransport + return guardAntigravityChatTransport(transport, async () => { + if (remote) { + return runtimeEnvironmentSupportsCapability( + remote, + ANTIGRAVITY_NATIVE_CHAT_RUNTIME_CAPABILITY + ) + } + const capabilities = webClient + ? (await window.api.runtime.getStatus()).capabilities + : await ensureLocalRuntimeCapabilities() + if (!capabilities) { + throw new Error('Runtime capabilities unavailable') + } + return capabilities.includes(ANTIGRAVITY_NATIVE_CHAT_RUNTIME_CAPABILITY) + }) } diff --git a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx index e18f9ed2d42..0282270aab5 100644 --- a/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx +++ b/src/renderer/src/components/settings/NativeChatSupportedAgents.test.tsx @@ -17,7 +17,8 @@ const EXPECTED_SUPPORTED_AGENTS = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'antigravity' ] as const satisfies readonly TuiAgent[] const SUPPORTED_AGENTS_LABEL_KEY = 'auto.components.settings.NativeChatSupportedAgents.label' diff --git a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts index 957e9b62ce2..36450f805b2 100644 --- a/src/renderer/src/lib/native-chat-initial-view-mode.test.ts +++ b/src/renderer/src/lib/native-chat-initial-view-mode.test.ts @@ -7,15 +7,19 @@ import { import { isNativeChatTranscriptLocalReadable } from './native-chat-transcript-readability' describe('decideInitialAgentTabViewMode', () => { - it("returns 'chat' when native chat and the opt-in default setting are on", () => { - expect( - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'codex' - }) - ).toBe('chat') - }) + it.each(['codex', 'antigravity'] as const)( + 'opens %s in chat when native chat and the opt-in default setting are on', + (agent) => { + expect( + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent, + nativeChatTranscriptIsLocalReadable: true + }) + ).toBe('chat') + } + ) it('returns undefined when native chat is disabled', () => { expect( @@ -74,17 +78,20 @@ describe('decideInitialAgentTabViewMode', () => { ).toBe('chat') }) - it('keeps Model-A SSH omp in the terminal view but opens it locally', () => { - const forConnection = (connectionId: string | null): Tab['viewMode'] => - decideInitialAgentTabViewMode({ - experimentalNativeChat: true, - openAgentTabsInChatByDefault: true, - agent: 'omp', - nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) - }) - expect(forConnection('ssh-target-1')).toBeUndefined() - expect(forConnection(null)).toBe('chat') - }) + it.each(['omp', 'antigravity'] as const)( + 'keeps Model-A SSH %s in the terminal view but opens it locally', + (agent) => { + const forConnection = (connectionId: string | null): Tab['viewMode'] => + decideInitialAgentTabViewMode({ + experimentalNativeChat: true, + openAgentTabsInChatByDefault: true, + agent, + nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(connectionId) + }) + expect(forConnection('ssh-target-1')).toBeUndefined() + expect(forConnection(null)).toBe('chat') + } + ) it('keeps Model-A SSH Grok in the terminal view', () => { expect( diff --git a/src/shared/agent-session-resume.test.ts b/src/shared/agent-session-resume.test.ts index c6025936431..b36f1148c28 100644 --- a/src/shared/agent-session-resume.test.ts +++ b/src/shared/agent-session-resume.test.ts @@ -8,6 +8,19 @@ import { } from './agent-session-resume' describe('agent session resume metadata', () => { + it('preserves the Antigravity hook transcript path for host-owned chat reads', () => { + expect( + extractAgentProviderSession('antigravity', { + conversationId: 'agy-conversation', + transcriptPath: '/workspace/brain/transcript.jsonl' + }) + ).toEqual({ + key: 'conversation_id', + id: 'agy-conversation', + transcriptPath: '/workspace/brain/transcript.jsonl' + }) + }) + it('treats devin as a resumable TUI agent', () => { expect(isResumableTuiAgent('devin')).toBe(true) }) diff --git a/src/shared/agent-session-resume.ts b/src/shared/agent-session-resume.ts index 9ae49955d84..3d8af62913b 100644 --- a/src/shared/agent-session-resume.ts +++ b/src/shared/agent-session-resume.ts @@ -201,7 +201,7 @@ export function extractAgentProviderSession( } case 'antigravity': { const id = readSessionId(payload, ['conversationId']) - return id ? { key: 'conversation_id', id } : null + return id ? withTranscriptPath({ key: 'conversation_id', id }, payload) : null } case 'opencode': case 'mimo-code': { diff --git a/src/shared/native-chat-agent-support.test.ts b/src/shared/native-chat-agent-support.test.ts index 7608f50c98c..bd0b5efaa86 100644 --- a/src/shared/native-chat-agent-support.test.ts +++ b/src/shared/native-chat-agent-support.test.ts @@ -34,11 +34,12 @@ describe('isNativeChatSupportedAgent', () => { }) describe('nativeChatRequiresLocalTranscript', () => { - it('covers the agents whose hook discloses no transcript path', () => { + it('covers agents requiring a runtime-owned transcript filesystem', () => { // Claude/Codex report `transcript_path`; Grok and omp report only an id, so // native chat has to find their file on a disk this process can read. expect(nativeChatRequiresLocalTranscript('grok')).toBe(true) expect(nativeChatRequiresLocalTranscript('omp')).toBe(true) + expect(nativeChatRequiresLocalTranscript('antigravity')).toBe(true) expect(nativeChatRequiresLocalTranscript('claude')).toBe(false) expect(nativeChatRequiresLocalTranscript('openclaude')).toBe(false) expect(nativeChatRequiresLocalTranscript('codex')).toBe(false) diff --git a/src/shared/native-chat-agent-support.ts b/src/shared/native-chat-agent-support.ts index b5bd27c0016..2ff14024005 100644 --- a/src/shared/native-chat-agent-support.ts +++ b/src/shared/native-chat-agent-support.ts @@ -1,6 +1,6 @@ import type { TuiAgent } from './tui-agent' -export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' +export type NativeChatTranscriptAgent = 'claude' | 'codex' | 'grok' | 'omp' | 'antigravity' /** Agents whose transcripts the native chat view can parse and render, in the * order the settings pane advertises them. */ @@ -9,7 +9,8 @@ export const NATIVE_CHAT_SUPPORTED_AGENT_LIST: readonly TuiAgent[] = [ 'openclaude', 'codex', 'grok', - 'omp' + 'omp', + 'antigravity' ] export const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet = new Set( @@ -20,13 +21,13 @@ export function isNativeChatSupportedAgent(agent: string | null | undefined): bo return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent) } -/** Agents whose hook discloses no transcript path (`extractAgentProviderSession`), - * so native chat can only reach the session file by scanning a sessions root on - * a disk THIS process can read. Under Model-A SSH that disk is the wrong host, - * so the chat view must stay closed instead of loading forever. */ +/** Agents whose chat reads require a filesystem owned by the selected runtime. + * Direct SSH has no transcript transport; a hook path alone is not a local file. */ export function nativeChatRequiresLocalTranscript(agent: string | null | undefined): boolean { const transcriptAgent = resolveNativeChatTranscriptAgent(agent) - return transcriptAgent === 'grok' || transcriptAgent === 'omp' + return ( + transcriptAgent === 'grok' || transcriptAgent === 'omp' || transcriptAgent === 'antigravity' + ) } /** True when the agent renders a digit-commit question selector that ignores @@ -47,7 +48,7 @@ export function resolveNativeChatTranscriptAgent( if (agent === 'claude' || agent === 'openclaude') { return 'claude' } - if (agent === 'codex' || agent === 'grok' || agent === 'omp') { + if (agent === 'codex' || agent === 'grok' || agent === 'omp' || agent === 'antigravity') { return agent } return null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 68918b1f263..bb2d768b901 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -292,10 +292,13 @@ export const ELECTRON_REMOTE_RUNTIME_CLIENT_CAPABILITIES = [ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY = 'git.antigravity-configured-model.v1' as const +export const ANTIGRAVITY_NATIVE_CHAT_RUNTIME_CAPABILITY = 'native-chat.antigravity.v1' as const + export const ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY = 'terminal.antigravity-visible-readiness.v1' as const export const RUNTIME_CAPABILITIES = [ + ANTIGRAVITY_NATIVE_CHAT_RUNTIME_CAPABILITY, ANTIGRAVITY_VISIBLE_READINESS_RUNTIME_CAPABILITY, ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY, 'files.pathsExist',