feat: show Antigravity terminal sessions in Chat UI (#21660)

* feat: render Antigravity terminal sessions in native chat

Co-authored-by: haoliangli <haoliangli@users.noreply.github.com>

* fix: keep direct SSH Antigravity chat on its execution host

---------

Co-authored-by: haoliangli <haoliangli@users.noreply.github.com>
This commit is contained in:
Neil
2026-09-20 18:04:39 -07:00
committed by GitHub
co-authored by haoliangli
parent 32a37f02b4
commit aa00abfbbc
24 changed files with 623 additions and 56 deletions
+53
View File
@@ -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/<conversation-id>/.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.
@@ -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 = '<USER_REQUEST>'
const startIndex = content.indexOf(opener)
if (startIndex === -1) {
@@ -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`.
@@ -0,0 +1,5 @@
{"step_index":0,"source":"USER_EXPLICIT","type":"USER_INPUT","status":"DONE","created_at":"2026-09-19T10:00:00Z","content":"<USER_REQUEST>Inspect the sample file.</USER_REQUEST>"}
{"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."}
@@ -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
@@ -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)
}
@@ -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
}
@@ -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()
})
})
@@ -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'
@@ -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()
})
})
@@ -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) }
}
@@ -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
}
@@ -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<boolean>) {
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()
})
})
@@ -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<NativeChatApi, 'readSession' | 'subscribe'>
/** An older host cannot decode agy; distinguish that from a transcript awaiting its first write. */
export function guardAntigravityChatTransport(
transport: Transport,
supports: () => Promise<boolean>
): Transport {
const errorForHost = async (): Promise<string | null> => {
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?.()
}
}
}
}
@@ -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(
@@ -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(
@@ -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<NativeChatApi, 'readSession' | 'subscribe'>
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)
})
}
@@ -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'
@@ -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(
+13
View File
@@ -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)
})
+1 -1
View File
@@ -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': {
+2 -1
View File
@@ -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)
+9 -8
View File
@@ -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<string> = 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
+3
View File
@@ -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',