mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user