From 2f3f65533b1c4e82dd2f6f9d3980877c5b2cecd8 Mon Sep 17 00:00:00 2001 From: iamtoruk Date: Mon, 27 Jul 2026 01:04:25 -0700 Subject: [PATCH] refactor(core): antigravity decode into core, stitching and caches host-side (phase 8, stateful tier) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves every record-parsing arm into @codeburn/core/providers/antigravity: the protobuf wire reader, the gen_metadata row decode, the RPC generatorMetadata decode, the statusline JSONL run-collapse/delta decode, and model canonicalization. All five emit arms (statusline, cache-hit, sqlite, RPC, RPC-failure fallback) plus the snapshot write-path keep their host-side control flow byte-for-byte. Cache integrity: CACHE_VERSION stays 5 and antigravity-results.json keeps its shape. The cache is not re-derivable — the RPC-failure fallback arm replays cached.calls for cascades whose language server is gone — so no bump, and the cache-write-before-seenKeys-filter ordering is preserved in both the sqlite and RPC arms. Pricing stays host-side: normalizePricingModel / PRICING_ALIASES remain in the CLI, and every arm now emits through one shared toProviderCall carrying costBasis 'estimated' plus pricingModel, and no costUSD. parseStatusLinePayload's wall clock becomes an injected `at` parameter; the host passes new Date().toISOString() at each call site, once per payload. Validator fixes on top of the migration: - content-smuggling: the hostile statusline record used the hook-payload shape, so the decoder dropped it and the cwd/session_id assertions proved nothing; corrected to the recorded-event shape, with a call-count guard. - goldens: G14 maps through the exported host toProviderCall instead of a private copy, and a new G15 pins the emit loop's turnIndex-before-seenKeys and previousSnapshotUsage-before-skip ordering, which no golden could previously detect. - core: removed an unused type import and a dead CANONICAL_TOOL_NAME const; restored the statusline reasoningTokens rationale comment. --- packages/cli/src/providers/antigravity.ts | 694 +-------- .../providers/antigravity-golden.test.ts | 1317 +++++++++++++++++ packages/core/package.json | 4 + .../core/src/providers/antigravity/decode.ts | 583 ++++++++ .../core/src/providers/antigravity/index.ts | 44 + .../src/providers/antigravity/observations.ts | 85 ++ .../core/src/providers/antigravity/types.ts | 113 ++ packages/core/tests/content-smuggling.test.ts | 175 +++ .../providers/antigravity-decode.test.ts | 400 +++++ packages/core/tsup.config.ts | 1 + 10 files changed, 2792 insertions(+), 624 deletions(-) create mode 100644 packages/cli/tests/providers/antigravity-golden.test.ts create mode 100644 packages/core/src/providers/antigravity/decode.ts create mode 100644 packages/core/src/providers/antigravity/index.ts create mode 100644 packages/core/src/providers/antigravity/observations.ts create mode 100644 packages/core/src/providers/antigravity/types.ts create mode 100644 packages/core/tests/providers/antigravity-decode.test.ts diff --git a/packages/cli/src/providers/antigravity.ts b/packages/cli/src/providers/antigravity.ts index f1339802..e95d3b6e 100644 --- a/packages/cli/src/providers/antigravity.ts +++ b/packages/cli/src/providers/antigravity.ts @@ -8,6 +8,24 @@ import https from 'https' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { DecodeContext } from '@codeburn/core' +import { + decodeAntigravityGenMetadata, + decodeAntigravityGeneratorMetadata, + decodeAntigravityStatusLine, + parseAntigravityStatusLinePayload, + extractAntigravityModelMap, + extractAntigravityGeneratorMetadata, +} from '@codeburn/core/providers/antigravity' +import type { + AntigravityDecodedCall, + AntigravityGeneratorMetadata, + AntigravityModelMap, +} from '@codeburn/core/providers/antigravity' + +// Moved into @codeburn/core (the decoders use them). Re-exported so existing +// importers keep resolving them from this module. +export { extractAntigravityModelMap, extractAntigravityGeneratorMetadata } type AntigravityConversationRoot = { dir: string @@ -61,74 +79,7 @@ type ServerCandidate = ServerInfo & { appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide' } -type ModelMap = Record - -type UsageEntry = { - model: string - inputTokens: string - outputTokens: string - thinkingOutputTokens?: string - responseOutputTokens?: string - apiProvider: string - responseId?: string -} - -export type GeneratorMetadata = { - stepIndices?: number[] - chatModel?: { - model: string - usage: UsageEntry - chatStartMetadata?: { - createdAt?: string - } - } -} - -type ModelMapResponse = { - models?: Record - response?: { - models?: Record - } -} - -type GeneratorMetadataResponse = { - generatorMetadata?: GeneratorMetadata[] - response?: { - generatorMetadata?: GeneratorMetadata[] - } -} - -type StatusLineCurrentUsage = { - input_tokens?: number - output_tokens?: number - cache_creation_input_tokens?: number - cache_read_input_tokens?: number -} - -type StatusLinePayload = { - conversation_id?: string - session_id?: string - model?: string | { - id?: string - display_name?: string - } - context_window?: { - current_usage?: StatusLineCurrentUsage | null - } -} - -type StatusLineEvent = { - at: string - conversationId: string - sessionId?: string - model: string - usage: { - inputTokens: number - outputTokens: number - cacheCreationInputTokens: number - cacheReadInputTokens: number - } -} +type ModelMap = AntigravityModelMap type CachedCascade = { mtimeMs: number @@ -141,29 +92,11 @@ type AntigravityCache = { cascades: Record } -type ProtoField = { - number: number - wireType: number - value?: bigint - bytes?: Uint8Array -} - -type ProtoVarint = { - value: bigint - offset: number -} - -type AntigravityGenMetadataRow = { - idx: number - data: Uint8Array | string -} - const cachedServers = new Map() const cachedModelMaps = new Map() let memCache: AntigravityCache | null = null let cacheDirty = false let httpsAgent: https.Agent | undefined -const protoTextDecoder = new TextDecoder('utf-8', { fatal: false }) const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port', 'https-server-port', 'extension-server-port'] const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token', 'csrf-token', 'extension-server-csrf-token'] @@ -259,69 +192,6 @@ function parseAntigravityServerCandidates(lines: string[]): ServerCandidate[] { .filter((server): server is ServerCandidate => server !== null) } -// Antigravity's own model-map config sometimes hasn't caught up with a new -// model yet, so both the config key and displayName can still be the raw -// placeholder id (e.g. "MODEL_PLACEHOLDER_M26"). Falling through to that -// value as the "canonical" model would leak an internal placeholder as a -// model name; 'unknown' is what this file already uses when no model can be -// resolved at all (see antigravitySqliteModel). -const MODEL_PLACEHOLDER_PATTERN = /^MODEL_PLACEHOLDER_/ - -function dropPlaceholderModelId(model: string): string { - return MODEL_PLACEHOLDER_PATTERN.test(model) ? 'unknown' : model -} - -function getCanonicalModelId(key: string, displayName?: string): string { - if (displayName) { - const lower = displayName.toLowerCase() - if (lower.includes('3.5 flash')) { - if (lower.includes('high')) return 'gemini-3.5-flash-high' - if (lower.includes('medium')) return 'gemini-3.5-flash-medium' - if (lower.includes('low')) return 'gemini-3.5-flash-low' - return 'gemini-3.5-flash' - } - if (lower.includes('3.1 pro')) { - if (lower.includes('high')) return 'gemini-3.1-pro-high' - if (lower.includes('low')) return 'gemini-3.1-pro-low' - return 'gemini-3.1-pro' - } - if (lower.includes('3.1 flash')) { - if (lower.includes('image')) return 'gemini-3.1-flash-image' - if (lower.includes('lite')) return 'gemini-3.1-flash-lite' - return 'gemini-3.1-flash' - } - if (lower.includes('3 flash')) { - return 'gemini-3-flash' - } - if (lower.includes('3 pro')) { - return 'gemini-3-pro' - } - } - return dropPlaceholderModelId(key) -} - -export function extractAntigravityModelMap(resp: unknown): ModelMap { - if (!resp || typeof resp !== 'object') return {} - const data = resp as ModelMapResponse - const models = data.response?.models ?? data.models - const map = new Map() - if (!models) return {} - for (const [key, info] of Object.entries(models)) { - if (info && typeof info === 'object' && typeof info.model === 'string') { - const canonicalKey = getCanonicalModelId(key, info.displayName) - map.set(info.model, canonicalKey) - } - } - return Object.fromEntries(map) -} - -export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] { - if (!resp || typeof resp !== 'object') return [] - const data = resp as GeneratorMetadataResponse - const metadata = data.response?.generatorMetadata ?? data.generatorMetadata - return Array.isArray(metadata) ? metadata : [] -} - async function loadCache(): Promise { if (memCache) return memCache try { @@ -578,232 +448,38 @@ function normalizePricingModel(model: string): string { return PRICING_ALIASES[stripped] ?? stripped } -function readProtoVarint(data: Uint8Array, startOffset: number): ProtoVarint | null { - let value = 0n - let shift = 0n - let offset = startOffset - - while (offset < data.length) { - const byte = BigInt(data[offset]!) - offset += 1 - value |= (byte & 0x7fn) << shift - if ((byte & 0x80n) === 0n) return { value, offset } - shift += 7n - if (shift > 70n) return null - } - - return null -} - -function parseProtoFields(data: Uint8Array): ProtoField[] { - const fields: ProtoField[] = [] - let offset = 0 - - while (offset < data.length) { - const key = readProtoVarint(data, offset) - if (!key) break - offset = key.offset - - const fieldNumber = Number(key.value >> 3n) - const wireType = Number(key.value & 0x7n) - if (!Number.isSafeInteger(fieldNumber) || fieldNumber <= 0) break - - if (wireType === 0) { - const value = readProtoVarint(data, offset) - if (!value) break - fields.push({ number: fieldNumber, wireType, value: value.value }) - offset = value.offset - continue - } - - if (wireType === 1) { - if (offset + 8 > data.length) break - fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 8) }) - offset += 8 - continue - } - - if (wireType === 2) { - const length = readProtoVarint(data, offset) - if (!length) break - offset = length.offset - const byteLength = Number(length.value) - if (!Number.isSafeInteger(byteLength) || byteLength < 0 || offset + byteLength > data.length) break - fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + byteLength) }) - offset += byteLength - continue - } - - if (wireType === 5) { - if (offset + 4 > data.length) break - fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 4) }) - offset += 4 - continue - } - - break - } - - return fields -} - -function firstProtoField(fields: readonly ProtoField[], fieldNumber: number): ProtoField | undefined { - return fields.find(field => field.number === fieldNumber) -} - -function protoFieldText(field: ProtoField | undefined): string | undefined { - if (!field?.bytes || field.bytes.length === 0) return undefined - const text = protoTextDecoder.decode(field.bytes) - if (!text || /[\u0000-\u0008\u000E-\u001F\u007F\uFFFD]/.test(text)) return undefined - return text -} - -function protoFieldPositiveInteger(field: ProtoField | undefined): number { - if (field?.value === undefined) return 0 - const value = Number(field.value) - return Number.isSafeInteger(value) && value > 0 ? value : 0 -} - -function protoFieldBytes(field: ProtoField | undefined): Uint8Array | undefined { - return field?.bytes -} - -function isAntigravityResponseId(value: string): boolean { - return /^[^\s]+$/.test(value) -} - -function antigravitySqliteResponseId(usageFields: readonly ProtoField[], fallback: string): string { - const responseId = protoFieldText(firstProtoField(usageFields, 11)) - return responseId && isAntigravityResponseId(responseId) ? responseId : fallback -} - -function genMetadataDataBytes(value: Uint8Array | string): Uint8Array { - return typeof value === 'string' - ? new TextEncoder().encode(value) - : value +function decodeContext(sourceRef: string): DecodeContext { + return { privacyKey: '', providerId: 'antigravity', sourceRef } } -function antigravitySqliteMetadataAttributes(chatFields: readonly ProtoField[]): Map { - const attributes = new Map() - for (const field of chatFields) { - if (field.number !== 20) continue - const pairFields = parseProtoFields(protoFieldBytes(field) ?? new Uint8Array()) - const key = protoFieldText(firstProtoField(pairFields, 1)) - const value = protoFieldText(firstProtoField(pairFields, 2)) - if (key && value) attributes.set(key, value) - } - return attributes -} - -function antigravitySqliteModel(chatFields: readonly ProtoField[]): string { - const attributes = antigravitySqliteMetadataAttributes(chatFields) - const displayName = protoFieldText(firstProtoField(chatFields, 21)) - const rawModel = protoFieldText(firstProtoField(chatFields, 19)) - ?? attributes.get('model_enum') - ?? displayName - ?? 'unknown' - - return getCanonicalModelId(rawModel, displayName) -} - -// Decode a proto field that carries a time into an ISO-8601 string. Antigravity -// may encode ChatStartMetadata.created_at as an ISO string, a Timestamp -// submessage (seconds in field 1), or a bare unix varint. Returns '' when the -// field is absent or unparseable so the caller can fall back. -function protoTimestampToIso(field: ProtoField | undefined): string { - if (!field) return '' - const text = protoFieldText(field) - if (text && !Number.isNaN(Date.parse(text))) return new Date(text).toISOString() - if (field.bytes) { - // google.protobuf.Timestamp submessage: seconds (#1), nanos (#2). - const tsFields = parseProtoFields(field.bytes) - const seconds = firstProtoField(tsFields, 1)?.value - if (seconds !== undefined) { - const nanos = firstProtoField(tsFields, 2)?.value ?? 0n - const ms = Number(seconds) * 1000 + Math.floor(Number(nanos) / 1e6) - if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() - } - } - if (field.value !== undefined) { - const raw = Number(field.value) - const ms = raw < 1e12 ? raw * 1000 : raw - if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() - } - return '' -} - -// ChatStartMetadata lives at chatModel(#1).#9; its created_at is #4. Not every -// gen_metadata row carries it, so this returns '' when missing. -function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string { - const metadataBytes = protoFieldBytes(firstProtoField(chatFields, 9)) - if (!metadataBytes) return '' - return protoTimestampToIso(firstProtoField(parseProtoFields(metadataBytes), 4)) -} - -function buildCallFromSqliteGenMetadataRow(cascadeId: string, row: AntigravityGenMetadataRow): ParsedProviderCall | null { - const rootFields = parseProtoFields(genMetadataDataBytes(row.data)) - const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array()) - const usageFields = parseProtoFields(protoFieldBytes(firstProtoField(chatFields, 4)) ?? new Uint8Array()) - if (usageFields.length === 0) return null - - const inputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 2)) - || protoFieldPositiveInteger(firstProtoField(usageFields, 1)) - const totalOutputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 3)) - let responseTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 9)) - let thinkingTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 10)) - - if (responseTokens === 0 && thinkingTokens === 0) { - responseTokens = totalOutputTokens - } else if (totalOutputTokens > 0 && responseTokens + thinkingTokens !== totalOutputTokens) { - const adjustedResponseTokens = totalOutputTokens - thinkingTokens - if (adjustedResponseTokens >= 0) responseTokens = adjustedResponseTokens - } - - if (inputTokens === 0 && totalOutputTokens === 0) return null - - const responseId = antigravitySqliteResponseId(usageFields, String(row.idx)) - const model = antigravitySqliteModel(chatFields) - const pricingModel = normalizePricingModel(model) - +// Exported so the RPC-arm golden can assert the exact emitted shape without a +// live language server; every emit arm goes through this one mapper. +export function toProviderCall(rich: AntigravityDecodedCall, project?: string): ParsedProviderCall { return { provider: 'antigravity', - model, - inputTokens, - outputTokens: responseTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: thinkingTokens, - webSearchRequests: 0, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, // Whitelisted for cost persistence: the pass prices `pricingModel` (which // strips suffixes / applies aliases) and the result is stored verbatim, so // the display `model` never reaches the price table. reasoning is billed at // the output rate (outputTokens + reasoningTokens), matching the lift. costBasis: 'estimated', - pricingModel, + pricingModel: normalizePricingModel(rich.model), tools: [], bashCommands: [], - timestamp: antigravitySqliteCreatedAt(chatFields), - speed: 'standard', - deduplicationKey: `antigravity:${cascadeId}:${responseId}`, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, userMessage: '', - sessionId: cascadeId, - } -} - -function buildCallsFromSqliteGenMetadata(cascadeId: string, rows: AntigravityGenMetadataRow[]): ParsedProviderCall[] { - const calls: ParsedProviderCall[] = [] - const seenResponseIds = new Set() - - for (const row of rows) { - const call = buildCallFromSqliteGenMetadataRow(cascadeId, row) - if (!call) continue - if (seenResponseIds.has(call.deduplicationKey)) continue - seenResponseIds.add(call.deduplicationKey) - calls.push(call) + sessionId: rich.sessionId, + ...(project !== undefined ? { project } : {}), } - - return calls } async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string): Promise { @@ -813,8 +489,9 @@ async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string): let db: ReturnType | null = null try { db = openDatabase(filePath) - const rows = db.query('SELECT idx, data FROM gen_metadata ORDER BY idx') - return buildCallsFromSqliteGenMetadata(cascadeId, rows) + const rows = db.query<{ idx: number; data: Uint8Array | string }>('SELECT idx, data FROM gen_metadata ORDER BY idx') + const { calls } = decodeAntigravityGenMetadata({ records: rows, context: decodeContext(filePath), cascadeId }) + return calls.map(c => toProviderCall(c)) } catch (err) { // Let a transient lock propagate so the run retries this file on the next // refresh instead of treating it as empty (see parser.ts busy handling). @@ -825,107 +502,10 @@ async function parseSqliteGenMetadataCalls(filePath: string, cascadeId: string): } } -function parseFiniteToken(value: unknown): number { - return typeof value === 'number' && Number.isFinite(value) && value > 0 - ? Math.floor(value) - : 0 -} - -function usageSignature(event: StatusLineEvent): string { - const u = event.usage - return [ - event.model, - u.inputTokens, - u.outputTokens, - u.cacheCreationInputTokens, - u.cacheReadInputTokens, - ].join(':') -} - -function usageHasTokens(usage: StatusLineEvent['usage']): boolean { - return ( - usage.inputTokens > 0 || - usage.outputTokens > 0 || - usage.cacheCreationInputTokens > 0 || - usage.cacheReadInputTokens > 0 - ) -} - -function usageIsMonotonic(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): boolean { - return ( - current.inputTokens >= previous.inputTokens && - current.outputTokens >= previous.outputTokens && - current.cacheCreationInputTokens >= previous.cacheCreationInputTokens && - current.cacheReadInputTokens >= previous.cacheReadInputTokens - ) -} - -function usageDelta(current: StatusLineEvent['usage'], previous: StatusLineEvent['usage']): StatusLineEvent['usage'] { - return { - inputTokens: current.inputTokens - previous.inputTokens, - outputTokens: current.outputTokens - previous.outputTokens, - cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens, - cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens, - } -} - export function antigravityCascadeIdFromPath(path: string): string { return basename(path).replace(/\.(pb|db)$/i, '') } -function buildCallsFromGeneratorMetadata( - cascadeId: string, - metadata: GeneratorMetadata[], - modelMap: ModelMap, -): ParsedProviderCall[] { - const results: ParsedProviderCall[] = [] - - for (let i = 0; i < metadata.length; i++) { - const entry = metadata[i]! - const usage = entry.chatModel?.usage - if (!usage) continue - - const inputTokens = parseInt(usage.inputTokens ?? '0', 10) - const outputTokens = parseInt(usage.outputTokens ?? '0', 10) - const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10) - const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10) - - if (inputTokens === 0 && outputTokens === 0) continue - - const responseId = usage.responseId || String(i) - const dedupKey = `antigravity:${cascadeId}:${responseId}` - - const model = dropPlaceholderModelId(modelMap[usage.model] ?? usage.model) - const pricingModel = normalizePricingModel(model) - const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? '' - - results.push({ - provider: 'antigravity', - model, - inputTokens, - outputTokens: responseTokens, - cacheCreationInputTokens: 0, - cacheReadInputTokens: 0, - cachedInputTokens: 0, - reasoningTokens: thinkingTokens, - webSearchRequests: 0, - // See buildCallFromSqliteGenMetadataRow: pricing pass prices `pricingModel` - // and the whitelist persists the result verbatim. - costBasis: 'estimated', - pricingModel, - tools: [], - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: '', - sessionId: cascadeId, - }) - } - - return results -} - function isConversationFile(file: string, extensions: readonly string[]): boolean { const lowerFile = file.toLowerCase() return extensions.some(ext => lowerFile.endsWith(ext)) @@ -979,38 +559,8 @@ export async function discoverAntigravitySessionSources( return sources } -function parseStatusLinePayload(input: unknown): StatusLineEvent | null { - if (!input || typeof input !== 'object') return null - const payload = input as StatusLinePayload - if (typeof payload.conversation_id !== 'string' || payload.conversation_id.length === 0) return null - const usage = payload.context_window?.current_usage - if (!usage) return null - - const event: StatusLineEvent = { - at: new Date().toISOString(), - conversationId: payload.conversation_id, - sessionId: typeof payload.session_id === 'string' ? payload.session_id : undefined, - model: typeof payload.model === 'string' - ? payload.model - : payload.model?.id ?? payload.model?.display_name ?? 'unknown', - usage: { - inputTokens: parseFiniteToken(usage.input_tokens), - outputTokens: parseFiniteToken(usage.output_tokens), - cacheCreationInputTokens: parseFiniteToken(usage.cache_creation_input_tokens), - cacheReadInputTokens: parseFiniteToken(usage.cache_read_input_tokens), - }, - } - - const u = event.usage - if (u.inputTokens === 0 && u.outputTokens === 0 && u.cacheCreationInputTokens === 0 && u.cacheReadInputTokens === 0) { - return null - } - if (event.model === 'unknown') return null - return event -} - export async function recordAntigravityStatusLinePayload(input: unknown): Promise { - const event = parseStatusLinePayload(input) + const event = parseAntigravityStatusLinePayload(input, new Date().toISOString()) if (!event) return false const path = getAntigravityStatusLineEventsPath() @@ -1024,128 +574,14 @@ export async function recordAntigravityStatusLinePayload(input: unknown): Promis return true } -function parseStatusLineEvent(input: unknown): StatusLineEvent | null { - if (!input || typeof input !== 'object') return null - const event = input as StatusLineEvent - if (typeof event.at !== 'string' || Number.isNaN(new Date(event.at).getTime())) return null - if (typeof event.conversationId !== 'string' || event.conversationId.length === 0) return null - if (typeof event.model !== 'string' || event.model.length === 0) return null - if (!event.usage || typeof event.usage !== 'object') return null - - const usage = { - inputTokens: parseFiniteToken(event.usage.inputTokens), - outputTokens: parseFiniteToken(event.usage.outputTokens), - cacheCreationInputTokens: parseFiniteToken(event.usage.cacheCreationInputTokens), - cacheReadInputTokens: parseFiniteToken(event.usage.cacheReadInputTokens), - } - - if ( - usage.inputTokens === 0 && - usage.outputTokens === 0 && - usage.cacheCreationInputTokens === 0 && - usage.cacheReadInputTokens === 0 - ) return null - - return { - at: event.at, - conversationId: event.conversationId, - sessionId: typeof event.sessionId === 'string' ? event.sessionId : undefined, - model: event.model, - usage, - } -} - -function hasRpcCacheForConversation(seenKeys: Set, conversationId: string): boolean { - const prefix = `antigravity:${conversationId}:` - for (const key of seenKeys) { - if (key.startsWith(prefix)) return true - } - return false -} - async function parseStatusLineCalls(source: SessionSource, seenKeys: Set): Promise { const raw = await readFile(source.path, 'utf-8').catch(() => '') - const runsByConversation = new Map>() - - for (const line of raw.split(/\r?\n/)) { - if (!line.trim()) continue - let parsed: unknown - try { - parsed = JSON.parse(line) - } catch { - continue - } - - const event = parseStatusLineEvent(parsed) - if (!event) continue - if (hasRpcCacheForConversation(seenKeys, event.conversationId)) continue - - const signature = usageSignature(event) - const runs = runsByConversation.get(event.conversationId) ?? [] - const lastRun = runs.at(-1) - if (lastRun?.signature === signature) { - lastRun.count += 1 - lastRun.event = event - } else { - runs.push({ event, signature, count: 1 }) - runsByConversation.set(event.conversationId, runs) - } - } - - const results: ParsedProviderCall[] = [] - - for (const runs of runsByConversation.values()) { - let turnIndex = 0 - let previousSnapshotUsage: StatusLineEvent['usage'] | null = null - for (let i = 0; i < runs.length; i++) { - const run = runs[i]! - const isLastRun = i === runs.length - 1 - if (run.count === 1 && !isLastRun) continue - - const event = run.event - const signature = run.signature - const billableUsage = previousSnapshotUsage && usageIsMonotonic(event.usage, previousSnapshotUsage) - ? usageDelta(event.usage, previousSnapshotUsage) - : event.usage - previousSnapshotUsage = event.usage - if (!usageHasTokens(billableUsage)) continue - - const dedupKey = `antigravity-statusline:${event.conversationId}:${turnIndex}:${signature}` - turnIndex += 1 - if (seenKeys.has(dedupKey)) continue - - const u = billableUsage - - results.push({ - provider: 'antigravity', - model: event.model, - inputTokens: u.inputTokens, - outputTokens: u.outputTokens, - cacheCreationInputTokens: u.cacheCreationInputTokens, - cacheReadInputTokens: u.cacheReadInputTokens, - cachedInputTokens: 0, - // StatusLine current_usage exposes aggregate output tokens, not a - // separate thinking/response split. Preserve the exact total instead - // of inventing a breakdown. - reasoningTokens: 0, - webSearchRequests: 0, - // Pass prices `pricingModel` (reasoningTokens is 0 here, so the output - // basis is exactly u.outputTokens); whitelist persists the result. - costBasis: 'estimated', - pricingModel: normalizePricingModel(event.model), - tools: [], - bashCommands: [], - timestamp: event.at, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: '', - sessionId: event.conversationId, - project: source.project, - }) - } - } - - return results + const { calls } = decodeAntigravityStatusLine({ + records: raw.split(/\r?\n/), + context: decodeContext(source.path), + seenKeys, + }) + return calls.map(c => toProviderCall(c, source.project)) } export function shouldReparseAntigravitySource(path: string, cachedTurnCount: number): boolean { @@ -1163,7 +599,7 @@ async function findCascadeSource(cascadeId: string): Promise { - const event = parseStatusLinePayload(input) + const event = parseAntigravityStatusLinePayload(input, new Date().toISOString()) if (!event) return false const cascadeId = event.conversationId @@ -1182,13 +618,18 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom const server = await detectServer(antigravityAppDataDirFromSourcePath(source.path)) if (!server) return false - let metadata: GeneratorMetadata[] + let metadata: AntigravityGeneratorMetadata[] try { const modelMap = await getModelMap(server) metadata = extractAntigravityGeneratorMetadata( await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), ) - const snapshotCalls = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap) + const snapshotCalls = decodeAntigravityGeneratorMetadata({ + records: metadata, + context: decodeContext(source.path), + cascadeId, + modelMap, + }).calls.map(c => toProviderCall(c)) assignStableTimestamps(snapshotCalls, cached?.calls, new Date(s.mtimeMs).toISOString()) cache.cascades[cascadeId] = { mtimeMs: s.mtimeMs, @@ -1253,13 +694,13 @@ function applyAntigravityProject(call: ParsedProviderCall, source: SessionSource call.project = source.project } -// gen_metadata rows and RPC entries without a real ChatStartMetadata.created_at -// carry no per-call timestamp. Left empty, those calls are dropped by the -// date-range filters in parser.ts (`if (!callTs) continue`), so each needs a -// fallback. The fallback must be *stable* across file rewrites: the generic -// session-cache persists whatever timestamp is emitted, and a non-durable -// source is cleared and reparsed whenever its mtime changes, so stamping the -// current mtime on every reparse would retro-date the whole session forward. +// Sqlite rows and RPC entries without a real created_at timestamp carry no +// per-call timestamp. Left empty, those calls are dropped by the date-range +// filters in parser.ts (`if (!callTs) continue`), so each needs a fallback. +// The fallback must be *stable* across file rewrites: the generic session-cache +// persists whatever timestamp is emitted, and a non-durable source is cleared +// and reparsed whenever its mtime changes, so stamping the current mtime on +// every reparse would retro-date the whole session forward. // // assignStableTimestamps carries forward the timestamp already recorded for a // dedup key (its first-seen time, held in the durable Antigravity cache) and @@ -1356,7 +797,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const modelMap = await getModelMap(server) - let metadata: GeneratorMetadata[] + let metadata: AntigravityGeneratorMetadata[] try { metadata = extractAntigravityGeneratorMetadata( await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }), @@ -1373,7 +814,12 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars return } - const results = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap) + const results = decodeAntigravityGeneratorMetadata({ + records: metadata, + context: decodeContext(source.path), + cascadeId, + modelMap, + }).calls.map(c => toProviderCall(c)) assignStableTimestamps(results, cached?.calls, fallbackTimestamp) for (const call of results) { applyAntigravityProject(call, source, projectPath) diff --git a/packages/cli/tests/providers/antigravity-golden.test.ts b/packages/cli/tests/providers/antigravity-golden.test.ts new file mode 100644 index 00000000..3d9ee2f1 --- /dev/null +++ b/packages/cli/tests/providers/antigravity-golden.test.ts @@ -0,0 +1,1317 @@ +import { mkdtemp, mkdir, rm, stat, utimes, writeFile, readFile } from 'fs/promises' +import { tmpdir } from 'os' +import { join } from 'path' +import { createRequire } from 'node:module' +import { describe, expect, it } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { + createAntigravityProvider, + getAntigravityStatusLineEventsPath, + recordAntigravityStatusLinePayload, + toProviderCall, +} from '../../src/providers/antigravity.js' +import { decodeAntigravityGeneratorMetadata } from '@codeburn/core/providers/antigravity' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +const requireForTest = createRequire(import.meta.url) + +type CurrentCliFixture = { + conversationId: string + rows: Array<{ idx: number; hex: string }> +} + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +function createCurrentAntigravityCliDb(dbPath: string, fixture: CurrentCliFixture): void { + const { DatabaseSync: Database } = requireForTest('node:sqlite') + const db = new Database(dbPath) as TestDb + try { + db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))') + db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))') + db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run( + 'main', + Buffer.from('file:///Users/example/private-project'), + ) + for (const row of fixture.rows) { + const data = Buffer.from(row.hex, 'hex') + db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length) + } + } finally { + db.close() + } +} + +function varint(n: number): number[] { + const out: number[] = [] + let v = n + while (v > 0x7f) { + out.push((v & 0x7f) | 0x80) + v = Math.floor(v / 128) + } + out.push(v) + return out +} + +function tag(field: number, wire: number): number[] { + return varint(field * 8 + wire) +} + +function varintField(field: number, n: number): number[] { + return [...tag(field, 0), ...varint(n)] +} + +function lenField(field: number, bytes: number[]): number[] { + return [...tag(field, 2), ...varint(bytes.length), ...bytes] +} + +function textField(field: number, text: string): number[] { + const bytes = [...new TextEncoder().encode(text)] + return lenField(field, bytes) +} + +function attrField(key: string, value: string): number[] { + return lenField(20, [...textField(1, key), ...textField(2, value)]) +} + +async function withTempAntigravityHome(prefix: string, fn: (tempHome: string) => Promise): Promise { + const tempHome = await mkdtemp(join(tmpdir(), prefix)) + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + process.env['CODEBURN_CACHE_DIR'] = join(tempHome, 'cache') + try { + await fn(tempHome) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(tempHome, { recursive: true, force: true }) + } +} + +async function parseRawCallsFromDb(dbPath: string, project = 'antigravity-cli'): Promise { + const parser = createAntigravityProvider().createSessionParser( + { path: dbPath, project, provider: 'antigravity' }, + new Set(), + ) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + return calls +} + +function sortedKeys(call: ParsedProviderCall): string[] { + return Object.keys(call).sort() +} + +const sqliteCallKeys = [ + 'bashCommands', + 'cacheCreationInputTokens', + 'cacheReadInputTokens', + 'cachedInputTokens', + 'costBasis', + 'deduplicationKey', + 'inputTokens', + 'model', + 'outputTokens', + 'pricingModel', + 'project', + 'provider', + 'reasoningTokens', + 'sessionId', + 'speed', + 'timestamp', + 'tools', + 'userMessage', + 'webSearchRequests', +] + +const statusLineCallKeys = sqliteCallKeys + +const rpcCallKeys = [ + 'bashCommands', + 'cacheCreationInputTokens', + 'cacheReadInputTokens', + 'cachedInputTokens', + 'costBasis', + 'deduplicationKey', + 'inputTokens', + 'model', + 'outputTokens', + 'pricingModel', + 'provider', + 'reasoningTokens', + 'sessionId', + 'speed', + 'timestamp', + 'tools', + 'userMessage', + 'webSearchRequests', +] + +describe('antigravity golden tests', () => { + it('G1: shipped 1-row fixture produces the exact baseline call', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g1-', async (tempHome) => { + const fixture = JSON.parse( + await readFile(new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url), 'utf-8'), + ) as CurrentCliFixture + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${fixture.conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, fixture) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 30265, + outputTokens: 659, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 71, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:fixture-current-cli:fixture-response-1', + userMessage: '', + sessionId: 'fixture-current-cli', + project: 'antigravity-cli', + }) + }) + }) + + it('G2: per-cascade seenResponseIds deduplicates identical response ids', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g2-', async (tempHome) => { + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 10), ...textField(11, 'dup-response')]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g2-dedup' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { + conversationId, + rows: [ + { idx: 0, hex }, + { idx: 1, hex }, + ], + }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 100, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g2-dedup:dup-response', + userMessage: '', + sessionId: 'g2-dedup', + project: 'antigravity-cli', + }) + }) + }) + + it('G3: responseTokens falls back to totalOutputTokens when split fields are absent', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g3-', async (tempHome) => { + const usage = lenField(4, [...varintField(2, 50), ...varintField(3, 500)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g3-total-output' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 0, hex }] }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 50, + outputTokens: 500, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g3-total-output:0', + userMessage: '', + sessionId: 'g3-total-output', + project: 'antigravity-cli', + }) + }) + }) + + it('G4: output split adjust arm reconciles response + thinking to total', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g4-', async (tempHome) => { + const usage = lenField(4, [ + ...varintField(2, 80), + ...varintField(3, 100), + ...varintField(9, 10), + ...varintField(10, 30), + ]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g4-adjust' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 0, hex }] }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 80, + outputTokens: 70, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 30, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g4-adjust:0', + userMessage: '', + sessionId: 'g4-adjust', + project: 'antigravity-cli', + }) + }) + }) + + it('G5: inputTokens falls back to field #1 when field #2 is absent', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g5-', async (tempHome) => { + const usage = lenField(4, [...varintField(1, 777), ...varintField(3, 5)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g5-input-fallback' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 0, hex }] }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 777, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g5-input-fallback:0', + userMessage: '', + sessionId: 'g5-input-fallback', + project: 'antigravity-cli', + }) + }) + }) + + it('G6: responseId falls back to row idx when field #11 is absent', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g6-', async (tempHome) => { + const usage = lenField(4, [...varintField(2, 33), ...varintField(3, 3)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g6-response-id-fallback' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 3, hex }] }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 33, + outputTokens: 3, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g6-response-id-fallback:3', + userMessage: '', + sessionId: 'g6-response-id-fallback', + project: 'antigravity-cli', + }) + }) + }) + + it('G7: model precedence and placeholder guard produce distinct canonical models', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g7-', async (tempHome) => { + const conversationId = 'g7-model-precedence' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + + const baseUsage = lenField(4, [...varintField(2, 10), ...varintField(3, 1)]) + + // (a) #19 only + const aChat = [ + ...baseUsage, + ...textField(19, 'gemini-3.1-pro-high'), + ] + const aHex = Buffer.from(lenField(1, aChat)).toString('hex') + + // (b) no #19, model_enum attr only + const bChat = [ + ...baseUsage, + ...attrField('model_enum', 'gemini-3.5-flash-agent'), + ] + const bHex = Buffer.from(lenField(1, bChat)).toString('hex') + + // (c) no #19/no attr, #21 only + const cChat = [...baseUsage, ...textField(21, 'Gemini 3 Flash')] + const cHex = Buffer.from(lenField(1, cChat)).toString('hex') + + // (d) none + const dChat = [...baseUsage] + const dHex = Buffer.from(lenField(1, dChat)).toString('hex') + + // (e) #19 placeholder with no #21 + const eChat = [...baseUsage, ...textField(19, 'MODEL_PLACEHOLDER_M99')] + const eHex = Buffer.from(lenField(1, eChat)).toString('hex') + + createCurrentAntigravityCliDb(dbPath, { + conversationId, + rows: [ + { idx: 0, hex: aHex }, + { idx: 1, hex: bHex }, + { idx: 2, hex: cHex }, + { idx: 3, hex: dHex }, + { idx: 4, hex: eHex }, + ], + }) + + const mtime = new Date('2026-01-15T12:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(5) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(sqliteCallKeys) + } + + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 10, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.1-pro', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g7-model-precedence:0', + userMessage: '', + sessionId: 'g7-model-precedence', + project: 'antigravity-cli', + }) + + expect(calls[1]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.5-flash-agent', + inputTokens: 10, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3.5-flash', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g7-model-precedence:1', + userMessage: '', + sessionId: 'g7-model-precedence', + project: 'antigravity-cli', + }) + + expect(calls[2]).toEqual({ + provider: 'antigravity', + model: 'gemini-3-flash', + inputTokens: 10, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3-flash', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g7-model-precedence:2', + userMessage: '', + sessionId: 'g7-model-precedence', + project: 'antigravity-cli', + }) + + expect(calls[3]).toEqual({ + provider: 'antigravity', + model: 'unknown', + inputTokens: 10, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unknown', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g7-model-precedence:3', + userMessage: '', + sessionId: 'g7-model-precedence', + project: 'antigravity-cli', + }) + + expect(calls[4]).toEqual({ + provider: 'antigravity', + model: 'unknown', + inputTokens: 10, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unknown', + tools: [], + bashCommands: [], + timestamp: '2026-01-15T12:00:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g7-model-precedence:4', + userMessage: '', + sessionId: 'g7-model-precedence', + project: 'antigravity-cli', + }) + }) + }) + + it('G8: chatStartMetadata.created_at beats file mtime', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g8-', async (tempHome) => { + const seconds = 1783326234 + const nanos = 724675400 + const timestamp = [...varintField(1, seconds), ...varintField(2, nanos)] + const chatStartMetadata = lenField(4, timestamp) + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 50)]) + const chatModel = [...usage, ...lenField(9, chatStartMetadata)] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g8-created-at' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 0, hex }] }) + + const mtime = new Date('2026-01-01T00:00:00.000Z') + await utimes(dbPath, mtime, mtime) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(sqliteCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'unknown', + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unknown', + tools: [], + bashCommands: [], + timestamp: '2026-07-06T08:23:54.724Z', + speed: 'standard', + deduplicationKey: 'antigravity:g8-created-at:0', + userMessage: '', + sessionId: 'g8-created-at', + project: 'antigravity-cli', + }) + }) + }) + + it('G9: zero input and output tokens skip the row', async () => { + if (!isSqliteAvailable()) return + + await withTempAntigravityHome('codeburn-antigravity-g9-', async (tempHome) => { + const usage = lenField(4, [...varintField(2, 0), ...varintField(3, 0)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const conversationId = 'g9-zero' + const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations') + await mkdir(conversationsDir, { recursive: true }) + const dbPath = join(conversationsDir, `${conversationId}.db`) + createCurrentAntigravityCliDb(dbPath, { conversationId, rows: [{ idx: 0, hex }] }) + + const calls = await parseRawCallsFromDb(dbPath) + + expect(calls).toEqual([]) + }) + }) + + it('G10: statusline singleton skip, monotonic delta, and turnIndex/dedupKey', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-g10-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const basePayload = { + conversation_id: 'statusline-g10', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + } + + const withUsage = ( + input_tokens: number, + output_tokens: number, + cache_read_input_tokens = 0, + ) => ({ + ...basePayload, + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30, 50))).toBe(true) + + const recorded = (await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')) + .split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line) as { at: string }) + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity' as const, + } + const parser = createAntigravityProvider().createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(statusLineCallKeys) + expect(call.project).toBe('antigravity-cli') + expect(call.projectPath).toBeUndefined() + } + + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 200, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[2]!.at, + speed: 'standard', + deduplicationKey: + 'antigravity-statusline:statusline-g10:0:Gemini 3.5 Flash (High):200:20:0:0', + userMessage: '', + sessionId: 'statusline-g10', + project: 'antigravity-cli', + }) + + expect(calls[1]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 100, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 50, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[3]!.at, + speed: 'standard', + deduplicationKey: + 'antigravity-statusline:statusline-g10:1:Gemini 3.5 Flash (High):300:30:0:50', + userMessage: '', + sessionId: 'statusline-g10', + project: 'antigravity-cli', + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('G11: non-monotonic usage resets to the full snapshot', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-g11-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const basePayload = { + conversation_id: 'statusline-g11', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + } + + const withUsage = ( + input_tokens: number, + output_tokens: number, + cache_read_input_tokens = 0, + ) => ({ + ...basePayload, + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(withUsage(1000, 100))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(1000, 100))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 30, 500))).toBe(true) + + const recorded = (await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')) + .split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line) as { at: string }) + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity' as const, + } + const parser = createAntigravityProvider().createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(statusLineCallKeys) + } + + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 1000, + outputTokens: 100, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[1]!.at, + speed: 'standard', + deduplicationKey: + 'antigravity-statusline:statusline-g11:0:Gemini 3.5 Flash (High):1000:100:0:0', + userMessage: '', + sessionId: 'statusline-g11', + project: 'antigravity-cli', + }) + + expect(calls[1]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 200, + outputTokens: 30, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 500, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[2]!.at, + speed: 'standard', + deduplicationKey: + 'antigravity-statusline:statusline-g11:1:Gemini 3.5 Flash (High):200:30:0:500', + userMessage: '', + sessionId: 'statusline-g11', + project: 'antigravity-cli', + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('G12: antigravity: prefix in seenKeys skips the whole conversation', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-g12-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + try { + expect(await recordAntigravityStatusLinePayload({ + conversation_id: 'rpc-covered-g12', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + context_window: { + current_usage: { + input_tokens: 1000, + output_tokens: 100, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + })).toBe(true) + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity' as const, + } + const parser = createAntigravityProvider().createSessionParser( + source, + new Set(['antigravity:rpc-covered-g12:0']), + ) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toEqual([]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('G13: only the last repeated run survives singleton skip', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-g13-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const basePayload = { + conversation_id: 'statusline-g13', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + } + + const withUsage = ( + input_tokens: number, + output_tokens: number, + ) => ({ + ...basePayload, + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30))).toBe(true) + + const recorded = (await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')) + .split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line) as { at: string }) + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity' as const, + } + const parser = createAntigravityProvider().createSessionParser(source, new Set()) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(statusLineCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + inputTokens: 300, + outputTokens: 30, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[3]!.at, + speed: 'standard', + deduplicationKey: + 'antigravity-statusline:statusline-g13:0:Gemini 3.5 Flash (High):300:30:0:0', + userMessage: '', + sessionId: 'statusline-g13', + project: 'antigravity-cli', + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + // The emit loop increments turnIndex AFTER building the dedup key but BEFORE + // the seenKeys check, and updates previousSnapshotUsage before either. A run + // that is skipped because its key is already seen therefore still consumes a + // turn index and still advances the delta baseline. Both orderings are + // invisible unless a statusline key is pre-seeded, which is what this does. + it('G15: a seenKeys-skipped run still consumes its turn index and advances the delta baseline', async () => { + const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-g15-')) + process.env['CODEBURN_CACHE_DIR'] = dir + + const basePayload = { + conversation_id: 'statusline-g15', + session_id: 'session-1', + model: 'Gemini 3.5 Flash (High)', + } + + const withUsage = (input_tokens: number, output_tokens: number) => ({ + ...basePayload, + context_window: { + current_usage: { + input_tokens, + output_tokens, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + }) + + try { + expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30))).toBe(true) + expect(await recordAntigravityStatusLinePayload(withUsage(300, 30))).toBe(true) + + const recorded = (await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')) + .split(/\r?\n/) + .filter(Boolean) + .map(line => JSON.parse(line) as { at: string }) + + const source = { + path: getAntigravityStatusLineEventsPath(), + project: 'antigravity-cli', + provider: 'antigravity' as const, + } + const parser = createAntigravityProvider().createSessionParser( + source, + new Set(['antigravity-statusline:statusline-g15:0:Gemini 3.5 Flash (High):100:10:0:0']), + ) + const calls: ParsedProviderCall[] = [] + for await (const call of parser.parse()) calls.push(call) + + expect(calls).toHaveLength(1) + expect(sortedKeys(calls[0]!)).toEqual(statusLineCallKeys) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'Gemini 3.5 Flash (High)', + // 300-100 / 30-10: the skipped run still moved the delta baseline. + inputTokens: 200, + outputTokens: 20, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'Gemini 3.5 Flash (High)', + tools: [], + bashCommands: [], + timestamp: recorded[3]!.at, + speed: 'standard', + // turnIndex 1, not 0: the skipped run consumed index 0. + deduplicationKey: + 'antigravity-statusline:statusline-g15:1:Gemini 3.5 Flash (High):300:30:0:0', + userMessage: '', + sessionId: 'statusline-g15', + project: 'antigravity-cli', + }) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('G14: RPC generator metadata decode handles skips, fallbacks, model map, and timestamps', () => { + const modelMap = { + 'raw-model-a': 'gemini-3-pro', + 'placeholder-key': 'MODEL_PLACEHOLDER_7', + } + + const metadata = [ + // lacks chatModel.usage -> skipped + { chatModel: { model: 'no-usage' } }, + // zero tokens -> skipped + { + chatModel: { + model: 'zero', + usage: { + model: 'zero', + inputTokens: '0', + outputTokens: '0', + apiProvider: 'google', + }, + }, + }, + // responseId absent -> index fallback + { + chatModel: { + model: 'unmapped-model-a', + usage: { + model: 'unmapped-model-a', + inputTokens: '100', + outputTokens: '20', + responseOutputTokens: '15', + thinkingOutputTokens: '5', + apiProvider: 'google', + }, + }, + }, + // usage.model IS in modelMap + { + chatModel: { + model: 'raw-model-a', + usage: { + model: 'raw-model-a', + inputTokens: '50', + outputTokens: '10', + responseOutputTokens: '8', + thinkingOutputTokens: '2', + apiProvider: 'google', + responseId: 'mapped-call', + }, + }, + }, + // usage.model is NOT in modelMap -> raw passthrough + { + chatModel: { + model: 'unmapped-model-b', + usage: { + model: 'unmapped-model-b', + inputTokens: '77', + outputTokens: '7', + responseOutputTokens: '6', + thinkingOutputTokens: '1', + apiProvider: 'google', + responseId: 'raw-passthrough-call', + }, + }, + }, + // mapped value is MODEL_PLACEHOLDER_* -> unknown + { + chatModel: { + model: 'placeholder-key', + usage: { + model: 'placeholder-key', + inputTokens: '25', + outputTokens: '5', + responseOutputTokens: '4', + thinkingOutputTokens: '1', + apiProvider: 'google', + responseId: 'placeholder-call', + }, + }, + }, + // createdAt set + { + chatModel: { + model: 'with-timestamp', + usage: { + model: 'with-timestamp', + inputTokens: '99', + outputTokens: '9', + responseOutputTokens: '9', + apiProvider: 'google', + responseId: 'timestamp-call', + }, + chatStartMetadata: { createdAt: '2026-03-15T09:30:00.000Z' }, + }, + }, + // createdAt not set + { + chatModel: { + model: 'no-timestamp', + usage: { + model: 'no-timestamp', + inputTokens: '11', + outputTokens: '2', + responseOutputTokens: '2', + apiProvider: 'google', + responseId: 'no-timestamp-call', + }, + }, + }, + ] + + const { calls: richCalls } = decodeAntigravityGeneratorMetadata({ + records: metadata as never, + context: { privacyKey: '', providerId: 'antigravity', sourceRef: 'g14-rpc' }, + cascadeId: 'g14-rpc', + modelMap, + }) + const calls = richCalls.map(c => toProviderCall(c)) + + expect(calls).toHaveLength(6) + for (const call of calls) { + expect(sortedKeys(call)).toEqual(rpcCallKeys) + } + + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'unmapped-model-a', + inputTokens: 100, + outputTokens: 15, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 5, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unmapped-model-a', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:2', + userMessage: '', + sessionId: 'g14-rpc', + }) + + expect(calls[1]).toEqual({ + provider: 'antigravity', + model: 'gemini-3-pro', + inputTokens: 50, + outputTokens: 8, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 2, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'gemini-3-pro', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:mapped-call', + userMessage: '', + sessionId: 'g14-rpc', + }) + + expect(calls[2]).toEqual({ + provider: 'antigravity', + model: 'unmapped-model-b', + inputTokens: 77, + outputTokens: 6, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 1, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unmapped-model-b', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:raw-passthrough-call', + userMessage: '', + sessionId: 'g14-rpc', + }) + + expect(calls[3]).toEqual({ + provider: 'antigravity', + model: 'unknown', + inputTokens: 25, + outputTokens: 4, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 1, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'unknown', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:placeholder-call', + userMessage: '', + sessionId: 'g14-rpc', + }) + + expect(calls[4]).toEqual({ + provider: 'antigravity', + model: 'with-timestamp', + inputTokens: 99, + outputTokens: 9, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'with-timestamp', + tools: [], + bashCommands: [], + timestamp: '2026-03-15T09:30:00.000Z', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:timestamp-call', + userMessage: '', + sessionId: 'g14-rpc', + }) + + expect(calls[5]).toEqual({ + provider: 'antigravity', + model: 'no-timestamp', + inputTokens: 11, + outputTokens: 2, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + pricingModel: 'no-timestamp', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g14-rpc:no-timestamp-call', + userMessage: '', + sessionId: 'g14-rpc', + }) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index f7f73808..09ab6bea 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -154,6 +154,10 @@ "./providers/opencode-session": { "types": "./dist/providers/opencode-session/index.d.ts", "import": "./dist/providers/opencode-session/index.js" + }, + "./providers/antigravity": { + "types": "./dist/providers/antigravity/index.d.ts", + "import": "./dist/providers/antigravity/index.js" } }, "files": [ diff --git a/packages/core/src/providers/antigravity/decode.ts b/packages/core/src/providers/antigravity/decode.ts new file mode 100644 index 00000000..3ad3e25d --- /dev/null +++ b/packages/core/src/providers/antigravity/decode.ts @@ -0,0 +1,583 @@ +// @codeburn/core Antigravity decoder: pure decode over three record shapes the +// host hands it: sqlite gen_metadata rows, RPC generatorMetadata entries, and +// statusline JSONL events. No fs / env / clock — the host owns discovery, +// durable caching, project attribution, and pricing. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + AntigravityDecodedCall, + AntigravityGeneratorMetadata, + AntigravityGeneratorMetadataResponse, + AntigravityGenMetadataRow, + AntigravityModelMap, + AntigravityModelMapResponse, + AntigravityStatusLineEvent, + AntigravityStatusLinePayload, + ProtoField, + ProtoVarint, +} from './types.js' + +export type AntigravityGenMetadataDecodeInput = { + records: unknown[] + context: DecodeContext + cascadeId: string +} + +export type AntigravityDecodeResult = { + calls: AntigravityDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +export type AntigravityGeneratorMetadataDecodeInput = { + records: unknown[] + context: DecodeContext + cascadeId: string + modelMap: AntigravityModelMap +} + +export type AntigravityStatusLineDecodeInput = { + records: unknown[] + context: DecodeContext + seenKeys: ReadonlySet +} + +const protoTextDecoder = new TextDecoder('utf-8', { fatal: false }) + +function readProtoVarint(data: Uint8Array, startOffset: number): ProtoVarint | null { + let value = 0n + let shift = 0n + let offset = startOffset + + while (offset < data.length) { + const byte = BigInt(data[offset]!) + offset += 1 + value |= (byte & 0x7fn) << shift + if ((byte & 0x80n) === 0n) return { value, offset } + shift += 7n + if (shift > 70n) return null + } + + return null +} + +function parseProtoFields(data: Uint8Array): ProtoField[] { + const fields: ProtoField[] = [] + let offset = 0 + + while (offset < data.length) { + const key = readProtoVarint(data, offset) + if (!key) break + offset = key.offset + + const fieldNumber = Number(key.value >> 3n) + const wireType = Number(key.value & 0x7n) + if (!Number.isSafeInteger(fieldNumber) || fieldNumber <= 0) break + + if (wireType === 0) { + const value = readProtoVarint(data, offset) + if (!value) break + fields.push({ number: fieldNumber, wireType, value: value.value }) + offset = value.offset + continue + } + + if (wireType === 1) { + if (offset + 8 > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 8) }) + offset += 8 + continue + } + + if (wireType === 2) { + const length = readProtoVarint(data, offset) + if (!length) break + offset = length.offset + const byteLength = Number(length.value) + if (!Number.isSafeInteger(byteLength) || byteLength < 0 || offset + byteLength > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + byteLength) }) + offset += byteLength + continue + } + + if (wireType === 5) { + if (offset + 4 > data.length) break + fields.push({ number: fieldNumber, wireType, bytes: data.subarray(offset, offset + 4) }) + offset += 4 + continue + } + + break + } + + return fields +} + +function firstProtoField(fields: readonly ProtoField[], fieldNumber: number): ProtoField | undefined { + return fields.find(field => field.number === fieldNumber) +} + +function protoFieldText(field: ProtoField | undefined): string | undefined { + if (!field?.bytes || field.bytes.length === 0) return undefined + const text = protoTextDecoder.decode(field.bytes) + if (!text || /[\u0000-\u0008\u000E-\u001F\u007F\uFFFD]/.test(text)) return undefined + return text +} + +function protoFieldPositiveInteger(field: ProtoField | undefined): number { + if (field?.value === undefined) return 0 + const value = Number(field.value) + return Number.isSafeInteger(value) && value > 0 ? value : 0 +} + +function protoFieldBytes(field: ProtoField | undefined): Uint8Array | undefined { + return field?.bytes +} + +// Antigravity's own model-map config sometimes hasn't caught up with a new +// model yet, so both the config key and displayName can still be the raw +// placeholder id (e.g. "MODEL_PLACEHOLDER_M26"). Falling through to that +// value as the "canonical" model would leak an internal placeholder as a +// model name; 'unknown' is what the CLI already uses when no model can be +// resolved at all. +const MODEL_PLACEHOLDER_PATTERN = /^MODEL_PLACEHOLDER_/ + +function dropPlaceholderModelId(model: string): string { + return MODEL_PLACEHOLDER_PATTERN.test(model) ? 'unknown' : model +} + +function getCanonicalModelId(key: string, displayName?: string): string { + if (displayName) { + const lower = displayName.toLowerCase() + if (lower.includes('3.5 flash')) { + if (lower.includes('high')) return 'gemini-3.5-flash-high' + if (lower.includes('medium')) return 'gemini-3.5-flash-medium' + if (lower.includes('low')) return 'gemini-3.5-flash-low' + return 'gemini-3.5-flash' + } + if (lower.includes('3.1 pro')) { + if (lower.includes('high')) return 'gemini-3.1-pro-high' + if (lower.includes('low')) return 'gemini-3.1-pro-low' + return 'gemini-3.1-pro' + } + if (lower.includes('3.1 flash')) { + if (lower.includes('image')) return 'gemini-3.1-flash-image' + if (lower.includes('lite')) return 'gemini-3.1-flash-lite' + return 'gemini-3.1-flash' + } + if (lower.includes('3 flash')) { + return 'gemini-3-flash' + } + if (lower.includes('3 pro')) { + return 'gemini-3-pro' + } + } + return dropPlaceholderModelId(key) +} + +function isAntigravityResponseId(value: string): boolean { + return /^[^\s]+$/.test(value) +} + +function antigravitySqliteResponseId(usageFields: readonly ProtoField[], fallback: string): string { + const responseId = protoFieldText(firstProtoField(usageFields, 11)) + return responseId && isAntigravityResponseId(responseId) ? responseId : fallback +} + +function genMetadataDataBytes(value: Uint8Array | string): Uint8Array { + return typeof value === 'string' + ? new TextEncoder().encode(value) + : value +} + +function antigravitySqliteMetadataAttributes(chatFields: readonly ProtoField[]): Map { + const attributes = new Map() + for (const field of chatFields) { + if (field.number !== 20) continue + const pairFields = parseProtoFields(protoFieldBytes(field) ?? new Uint8Array()) + const key = protoFieldText(firstProtoField(pairFields, 1)) + const value = protoFieldText(firstProtoField(pairFields, 2)) + if (key && value) attributes.set(key, value) + } + return attributes +} + +function antigravitySqliteModel(chatFields: readonly ProtoField[]): string { + const attributes = antigravitySqliteMetadataAttributes(chatFields) + const displayName = protoFieldText(firstProtoField(chatFields, 21)) + const rawModel = protoFieldText(firstProtoField(chatFields, 19)) + ?? attributes.get('model_enum') + ?? displayName + ?? 'unknown' + + return getCanonicalModelId(rawModel, displayName) +} + +// Decode a proto field that carries a time into an ISO-8601 string. Antigravity +// may encode ChatStartMetadata.created_at as an ISO string, a Timestamp +// submessage (seconds in field 1), or a bare unix varint. Returns '' when the +// field is absent or unparseable so the caller can fall back. +function protoTimestampToIso(field: ProtoField | undefined): string { + if (!field) return '' + const text = protoFieldText(field) + if (text && !Number.isNaN(Date.parse(text))) return new Date(text).toISOString() + if (field.bytes) { + // google.protobuf.Timestamp submessage: seconds (#1), nanos (#2). + const tsFields = parseProtoFields(field.bytes) + const seconds = firstProtoField(tsFields, 1)?.value + if (seconds !== undefined) { + const nanos = firstProtoField(tsFields, 2)?.value ?? 0n + const ms = Number(seconds) * 1000 + Math.floor(Number(nanos) / 1e6) + if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() + } + } + if (field.value !== undefined) { + const raw = Number(field.value) + const ms = raw < 1e12 ? raw * 1000 : raw + if (Number.isSafeInteger(ms) && ms > 0) return new Date(ms).toISOString() + } + return '' +} + +// ChatStartMetadata lives at chatModel(#1).#9; its created_at is #4. Not every +// gen_metadata row carries it, so this returns '' when missing. +function antigravitySqliteCreatedAt(chatFields: readonly ProtoField[]): string { + const metadataBytes = protoFieldBytes(firstProtoField(chatFields, 9)) + if (!metadataBytes) return '' + return protoTimestampToIso(firstProtoField(parseProtoFields(metadataBytes), 4)) +} + +function decodeAntigravityGenMetadataRow( + cascadeId: string, + row: AntigravityGenMetadataRow, +): AntigravityDecodedCall | null { + const rootFields = parseProtoFields(genMetadataDataBytes(row.data)) + const chatFields = parseProtoFields(protoFieldBytes(firstProtoField(rootFields, 1)) ?? new Uint8Array()) + const usageFields = parseProtoFields(protoFieldBytes(firstProtoField(chatFields, 4)) ?? new Uint8Array()) + if (usageFields.length === 0) return null + + const inputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 2)) + || protoFieldPositiveInteger(firstProtoField(usageFields, 1)) + const totalOutputTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 3)) + let responseTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 9)) + let thinkingTokens = protoFieldPositiveInteger(firstProtoField(usageFields, 10)) + + if (responseTokens === 0 && thinkingTokens === 0) { + responseTokens = totalOutputTokens + } else if (totalOutputTokens > 0 && responseTokens + thinkingTokens !== totalOutputTokens) { + const adjustedResponseTokens = totalOutputTokens - thinkingTokens + if (adjustedResponseTokens >= 0) responseTokens = adjustedResponseTokens + } + + if (inputTokens === 0 && totalOutputTokens === 0) return null + + const responseId = antigravitySqliteResponseId(usageFields, String(row.idx)) + const model = antigravitySqliteModel(chatFields) + + return { + provider: 'antigravity', + model, + inputTokens, + outputTokens: responseTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + timestamp: antigravitySqliteCreatedAt(chatFields), + speed: 'standard', + deduplicationKey: `antigravity:${cascadeId}:${responseId}`, + sessionId: cascadeId, + } +} + +/** gen_metadata rows -> calls. Per-cascade dedup on deduplicationKey (host-side + * cross-file dedup is applied later by the caller, exactly as before). */ +export function decodeAntigravityGenMetadata( + input: AntigravityGenMetadataDecodeInput, +): AntigravityDecodeResult { + const { records, cascadeId } = input + const calls: AntigravityDecodedCall[] = [] + const seenResponseIds = new Set() + + for (const row of records as AntigravityGenMetadataRow[]) { + const call = decodeAntigravityGenMetadataRow(cascadeId, row) + if (!call) continue + if (seenResponseIds.has(call.deduplicationKey)) continue + seenResponseIds.add(call.deduplicationKey) + calls.push(call) + } + + return { calls, diagnostics: [] } +} + +export function extractAntigravityModelMap(resp: unknown): AntigravityModelMap { + if (!resp || typeof resp !== 'object') return {} + const data = resp as AntigravityModelMapResponse + const models = data.response?.models ?? data.models + const map = new Map() + if (!models) return {} + for (const [key, info] of Object.entries(models)) { + if (info && typeof info === 'object' && typeof info.model === 'string') { + const canonicalKey = getCanonicalModelId(key, info.displayName) + map.set(info.model, canonicalKey) + } + } + return Object.fromEntries(map) +} + +export function extractAntigravityGeneratorMetadata(resp: unknown): AntigravityGeneratorMetadata[] { + if (!resp || typeof resp !== 'object') return [] + const data = resp as AntigravityGeneratorMetadataResponse + const metadata = data.response?.generatorMetadata ?? data.generatorMetadata + return Array.isArray(metadata) ? metadata : [] +} + +function parseFiniteToken(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 + ? Math.floor(value) + : 0 +} + +function usageSignature(event: AntigravityStatusLineEvent): string { + const u = event.usage + return [ + event.model, + u.inputTokens, + u.outputTokens, + u.cacheCreationInputTokens, + u.cacheReadInputTokens, + ].join(':') +} + +function usageHasTokens(usage: AntigravityStatusLineEvent['usage']): boolean { + return ( + usage.inputTokens > 0 || + usage.outputTokens > 0 || + usage.cacheCreationInputTokens > 0 || + usage.cacheReadInputTokens > 0 + ) +} + +function usageIsMonotonic( + current: AntigravityStatusLineEvent['usage'], + previous: AntigravityStatusLineEvent['usage'], +): boolean { + return ( + current.inputTokens >= previous.inputTokens && + current.outputTokens >= previous.outputTokens && + current.cacheCreationInputTokens >= previous.cacheCreationInputTokens && + current.cacheReadInputTokens >= previous.cacheReadInputTokens + ) +} + +function usageDelta( + current: AntigravityStatusLineEvent['usage'], + previous: AntigravityStatusLineEvent['usage'], +): AntigravityStatusLineEvent['usage'] { + return { + inputTokens: current.inputTokens - previous.inputTokens, + outputTokens: current.outputTokens - previous.outputTokens, + cacheCreationInputTokens: current.cacheCreationInputTokens - previous.cacheCreationInputTokens, + cacheReadInputTokens: current.cacheReadInputTokens - previous.cacheReadInputTokens, + } +} + +/** Hook-capture record parse. The wall clock is INJECTED (`at`) so the decoder + * stays pure; the host passes a freshly-generated ISO-8601 timestamp. */ +export function parseAntigravityStatusLinePayload( + input: unknown, + at: string, +): AntigravityStatusLineEvent | null { + if (!input || typeof input !== 'object') return null + const payload = input as AntigravityStatusLinePayload + if (typeof payload.conversation_id !== 'string' || payload.conversation_id.length === 0) return null + const usage = payload.context_window?.current_usage + if (!usage) return null + + const event: AntigravityStatusLineEvent = { + at, + conversationId: payload.conversation_id, + sessionId: typeof payload.session_id === 'string' ? payload.session_id : undefined, + model: typeof payload.model === 'string' + ? payload.model + : payload.model?.id ?? payload.model?.display_name ?? 'unknown', + usage: { + inputTokens: parseFiniteToken(usage.input_tokens), + outputTokens: parseFiniteToken(usage.output_tokens), + cacheCreationInputTokens: parseFiniteToken(usage.cache_creation_input_tokens), + cacheReadInputTokens: parseFiniteToken(usage.cache_read_input_tokens), + }, + } + + const u = event.usage + if (u.inputTokens === 0 && u.outputTokens === 0 && u.cacheCreationInputTokens === 0 && u.cacheReadInputTokens === 0) { + return null + } + if (event.model === 'unknown') return null + return event +} + +function parseStatusLineEvent(input: unknown): AntigravityStatusLineEvent | null { + if (!input || typeof input !== 'object') return null + const event = input as AntigravityStatusLineEvent + if (typeof event.at !== 'string' || Number.isNaN(new Date(event.at).getTime())) return null + if (typeof event.conversationId !== 'string' || event.conversationId.length === 0) return null + if (typeof event.model !== 'string' || event.model.length === 0) return null + if (!event.usage || typeof event.usage !== 'object') return null + + const usage = { + inputTokens: parseFiniteToken(event.usage.inputTokens), + outputTokens: parseFiniteToken(event.usage.outputTokens), + cacheCreationInputTokens: parseFiniteToken(event.usage.cacheCreationInputTokens), + cacheReadInputTokens: parseFiniteToken(event.usage.cacheReadInputTokens), + } + + if ( + usage.inputTokens === 0 && + usage.outputTokens === 0 && + usage.cacheCreationInputTokens === 0 && + usage.cacheReadInputTokens === 0 + ) return null + + return { + at: event.at, + conversationId: event.conversationId, + sessionId: typeof event.sessionId === 'string' ? event.sessionId : undefined, + model: event.model, + usage, + } +} + +function hasRpcCacheForConversation(seenKeys: ReadonlySet, conversationId: string): boolean { + const prefix = `antigravity:${conversationId}:` + for (const key of seenKeys) { + if (key.startsWith(prefix)) return true + } + return false +} + +/** statusline jsonl -> calls (run collapse + monotonic deltas). */ +export function decodeAntigravityStatusLine( + input: AntigravityStatusLineDecodeInput, +): AntigravityDecodeResult { + const { records, seenKeys } = input + const runsByConversation = new Map>() + + for (const line of records as string[]) { + if (!line.trim()) continue + let parsed: unknown + try { + parsed = JSON.parse(line) + } catch { + continue + } + + const event = parseStatusLineEvent(parsed) + if (!event) continue + if (hasRpcCacheForConversation(seenKeys, event.conversationId)) continue + + const signature = usageSignature(event) + const runs = runsByConversation.get(event.conversationId) ?? [] + const lastRun = runs.at(-1) + if (lastRun?.signature === signature) { + lastRun.count += 1 + lastRun.event = event + } else { + runs.push({ event, signature, count: 1 }) + runsByConversation.set(event.conversationId, runs) + } + } + + const calls: AntigravityDecodedCall[] = [] + + for (const runs of runsByConversation.values()) { + let turnIndex = 0 + let previousSnapshotUsage: AntigravityStatusLineEvent['usage'] | null = null + for (let i = 0; i < runs.length; i++) { + const run = runs[i]! + const isLastRun = i === runs.length - 1 + if (run.count === 1 && !isLastRun) continue + + const event = run.event + const signature = run.signature + const billableUsage = previousSnapshotUsage && usageIsMonotonic(event.usage, previousSnapshotUsage) + ? usageDelta(event.usage, previousSnapshotUsage) + : event.usage + previousSnapshotUsage = event.usage + if (!usageHasTokens(billableUsage)) continue + + const dedupKey = `antigravity-statusline:${event.conversationId}:${turnIndex}:${signature}` + turnIndex += 1 + if (seenKeys.has(dedupKey)) continue + + const u = billableUsage + + calls.push({ + provider: 'antigravity', + model: event.model, + inputTokens: u.inputTokens, + outputTokens: u.outputTokens, + cacheCreationInputTokens: u.cacheCreationInputTokens, + cacheReadInputTokens: u.cacheReadInputTokens, + cachedInputTokens: 0, + // StatusLine current_usage exposes aggregate output tokens, not a + // separate thinking/response split. Preserve the exact total instead + // of inventing a breakdown. + reasoningTokens: 0, + webSearchRequests: 0, + timestamp: event.at, + speed: 'standard', + deduplicationKey: dedupKey, + sessionId: event.conversationId, + }) + } + } + + return { calls, diagnostics: [] } +} + +/** RPC generatorMetadata entries -> calls. */ +export function decodeAntigravityGeneratorMetadata( + input: AntigravityGeneratorMetadataDecodeInput, +): AntigravityDecodeResult { + const { records, cascadeId, modelMap } = input + const calls: AntigravityDecodedCall[] = [] + + for (let i = 0; i < records.length; i++) { + const entry = records[i] as AntigravityGeneratorMetadata + const usage = entry.chatModel?.usage + if (!usage) continue + + const inputTokens = parseInt(usage.inputTokens ?? '0', 10) + const outputTokens = parseInt(usage.outputTokens ?? '0', 10) + const thinkingTokens = parseInt(usage.thinkingOutputTokens ?? '0', 10) + const responseTokens = parseInt(usage.responseOutputTokens ?? '0', 10) + + if (inputTokens === 0 && outputTokens === 0) continue + + const responseId = usage.responseId || String(i) + const dedupKey = `antigravity:${cascadeId}:${responseId}` + + const model = dropPlaceholderModelId(modelMap[usage.model] ?? usage.model) + const timestamp = entry.chatModel?.chatStartMetadata?.createdAt ?? '' + + calls.push({ + provider: 'antigravity', + model, + inputTokens, + outputTokens: responseTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: thinkingTokens, + webSearchRequests: 0, + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + sessionId: cascadeId, + }) + } + + return { calls, diagnostics: [] } +} diff --git a/packages/core/src/providers/antigravity/index.ts b/packages/core/src/providers/antigravity/index.ts new file mode 100644 index 00000000..c89984d3 --- /dev/null +++ b/packages/core/src/providers/antigravity/index.ts @@ -0,0 +1,44 @@ +// @codeburn/core Antigravity provider. +// +// Two layers: +// - Rich pure decode (`decodeAntigravityGenMetadata`, +// `decodeAntigravityGeneratorMetadata`, `decodeAntigravityStatusLine`, +// `parseAntigravityStatusLinePayload`): host-facing, NOT part of the stable +// minimized surface. Pure over supplied records; carries token buckets but no +// pricing (cost leaves the decoder). +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeAntigravityGenMetadata, + decodeAntigravityGeneratorMetadata, + decodeAntigravityStatusLine, + parseAntigravityStatusLinePayload, + extractAntigravityModelMap, + extractAntigravityGeneratorMetadata, + type AntigravityGenMetadataDecodeInput, + type AntigravityGeneratorMetadataDecodeInput, + type AntigravityStatusLineDecodeInput, + type AntigravityDecodeResult, +} from './decode.js' + +export { + toObservations, + type RichAntigravitySessionDecode, + type AntigravityToObservationsContext, +} from './observations.js' + +export type { + AntigravityDecodedCall, + AntigravityGeneratorMetadata, + AntigravityGeneratorMetadataResponse, + AntigravityGenMetadataRow, + AntigravityModelMap, + AntigravityModelMapResponse, + AntigravityStatusLineCurrentUsage, + AntigravityStatusLineEvent, + AntigravityStatusLinePayload, + AntigravityUsageEntry, + ProtoField, + ProtoVarint, +} from './types.js' diff --git a/packages/core/src/providers/antigravity/observations.ts b/packages/core/src/providers/antigravity/observations.ts new file mode 100644 index 00000000..91d9fcb2 --- /dev/null +++ b/packages/core/src/providers/antigravity/observations.ts @@ -0,0 +1,85 @@ +// Minimizing transform: rich Antigravity decode -> the strict observation +// envelope. Only opaque ids, fingerprints, enums, numbers, timestamps, and +// canonical tool names cross into the output. Antigravity never records file +// paths and has no tool calls, so projectPath is fingerprinted and toolNames is +// always empty. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { AntigravityDecodedCall } from './types.js' + +/** One Antigravity cascade's rich decode, as the host holds it before minimization. */ +export interface RichAntigravitySessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + calls: AntigravityDecodedCall[] +} + +export interface AntigravityToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +function toCallObservation(call: AntigravityDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + // Antigravity records carry no tool calls at all, so there is nothing to + // filter through a canonical-name gate here. + toolNames: [], + turnIndex, + } +} + +function toSessionObservation( + decode: RichAntigravitySessionDecode, + ctx: AntigravityToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'antigravity' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + const session: SessionObservation = { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } + return session +} + +/** + * Map a rich Antigravity decode (one or many cascades) into the minimized + * observation layer. Returns the `sessions` array plus any per-record + * `diagnostics`. + */ +export function toObservations( + decode: RichAntigravitySessionDecode | RichAntigravitySessionDecode[], + ctx: AntigravityToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/antigravity/types.ts b/packages/core/src/providers/antigravity/types.ts new file mode 100644 index 00000000..f17f673b --- /dev/null +++ b/packages/core/src/providers/antigravity/types.ts @@ -0,0 +1,113 @@ +// Raw record + rich-decode types for the Antigravity provider. +// +// Antigravity is a Category D stateful multi-store provider. The host keeps all +// I/O, discovery, durable caching, and project attribution; core owns only the +// pure decode of the three record shapes the host hands it: sqlite gen_metadata +// rows, RPC generatorMetadata entries, and statusline JSONL events. + +export type AntigravityUsageEntry = { + model: string + inputTokens: string + outputTokens: string + thinkingOutputTokens?: string + responseOutputTokens?: string + apiProvider: string + responseId?: string +} + +export type AntigravityGeneratorMetadata = { + stepIndices?: number[] + chatModel?: { + model: string + usage: AntigravityUsageEntry + chatStartMetadata?: { + createdAt?: string + } + } +} + +export type AntigravityModelMapResponse = { + models?: Record + response?: { + models?: Record + } +} + +export type AntigravityGeneratorMetadataResponse = { + generatorMetadata?: AntigravityGeneratorMetadata[] + response?: { + generatorMetadata?: AntigravityGeneratorMetadata[] + } +} + +export type AntigravityModelMap = Record + +export type AntigravityStatusLineCurrentUsage = { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number +} + +export type AntigravityStatusLinePayload = { + conversation_id?: string + session_id?: string + model?: string | { + id?: string + display_name?: string + } + context_window?: { + current_usage?: AntigravityStatusLineCurrentUsage | null + } +} + +export type AntigravityStatusLineEvent = { + at: string + conversationId: string + sessionId?: string + model: string + usage: { + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + } +} + +export type AntigravityGenMetadataRow = { + idx: number + data: Uint8Array | string +} + +export type ProtoField = { + number: number + wireType: number + value?: bigint + bytes?: Uint8Array +} + +export type ProtoVarint = { + value: bigint + offset: number +} + +// The rich decode of one Antigravity call, pre-pricing. Mirrors the host's +// ParsedProviderCall minus everything the host owns: cost (costUSD/costBasis/ +// pricingModel), project attribution, and the always-empty tools/bashCommands +// plus the host-supplied empty text field. Antigravity records carry no tool +// calls and no user text at all. +export type AntigravityDecodedCall = { + provider: 'antigravity' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + timestamp: string + speed: 'standard' + deduplicationKey: string + sessionId: string +} diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index 05e15835..3cf934dc 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -38,6 +38,12 @@ import { decodeCopilot, toObservations as toCopilotObservations } from '../src/p import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js' import { decodeOpenCodeSession, toObservations as toOpenCodeSessionObservations } from '../src/providers/opencode-session/index.js' import { decodeMistralVibe, toObservations as toMistralVibeObservations } from '../src/providers/mistral-vibe/index.js' +import { + decodeAntigravityGenMetadata, + decodeAntigravityGeneratorMetadata, + decodeAntigravityStatusLine, + toObservations as toAntigravityObservations, +} from '../src/providers/antigravity/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' @@ -1571,3 +1577,172 @@ describe('content-smuggling guardrail: real mistral-vibe decode -> toObservation expect(allToolNames).not.toContain(SECRETS.commandLine) }) }) + +describe('content-smuggling guardrail: real antigravity decode -> toObservations is secret-free', () => { + // A hostile Antigravity cascade planting secrets in every free-text field the + // decode reads but never emits: proto attribute values other than model_enum, + // ignored proto fields, the statusline envelope's cwd/session_id, and the RPC + // usage.apiProvider. Only the canonicalized model and the responseId (inside + // the dedupKey) are emitted by design — they are the provider's own machine + // identifiers, exactly like every other provider's model and dedupKey. + const antigravityContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'antigravity', + sourceRef: 'ref', + } + + function varint(n: number): number[] { + const out: number[] = [] + let v = n + while (v > 0x7f) { + out.push((v & 0x7f) | 0x80) + v = Math.floor(v / 128) + } + out.push(v) + return out + } + + function tag(field: number, wire: number): number[] { + return varint(field * 8 + wire) + } + + function varintField(field: number, n: number): number[] { + return [...tag(field, 0), ...varint(n)] + } + + function lenField(field: number, bytes: number[]): number[] { + return [...tag(field, 2), ...varint(bytes.length), ...bytes] + } + + function textField(field: number, text: string): number[] { + return lenField(field, [...new TextEncoder().encode(text)]) + } + + function attrField(key: string, value: string): number[] { + return lenField(20, [...textField(1, key), ...textField(2, value)]) + } + + function buildHostileGenMetadataRow(): { idx: number; data: Uint8Array } { + const chatStartMetadata = textField(4, '2026-07-17T10:00:00.000Z') + const usage = lenField(4, [ + ...varintField(2, 100), + ...varintField(3, 50), + ...varintField(6, 999), + ]) + const chatModel = [ + ...usage, + ...lenField(9, chatStartMetadata), + ...textField(19, 'gemini-3-pro'), + ...attrField('trajectory_id', SECRETS.commandLine), + ...attrField('used_claude', SECRETS.apiKey), + ...attrField('last_step_index', SECRETS.prompt), + ] + const root = [ + ...lenField(2, [...new TextEncoder().encode(SECRETS.fileContent)]), + ...lenField(4, [...new TextEncoder().encode(SECRETS.absPath)]), + ...lenField(1, chatModel), + ] + return { idx: 0, data: Buffer.from(root) } + } + + function decodeAndMinimize() { + const genMetadataCalls = decodeAntigravityGenMetadata({ + records: [buildHostileGenMetadataRow()], + context: antigravityContext, + cascadeId: 'sess-hostile', + }).calls + + // The statusline decoder consumes RECORDED events (camelCase, already + // normalized by parseAntigravityStatusLinePayload), not the raw hook + // payload. `sessionId` and any stray envelope field such as `cwd` are read + // past but never emitted — the emitted call's sessionId is conversationId. + const statusLineCalls = decodeAntigravityStatusLine({ + records: [ + JSON.stringify({ + at: '2026-07-17T10:00:00.000Z', + conversationId: 'sess-hostile-statusline', + sessionId: SECRETS.apiKey, + cwd: SECRETS.absPath, + model: 'gemini-3-pro', + usage: { + inputTokens: 100, + outputTokens: 50, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + }, + }), + ], + context: antigravityContext, + seenKeys: new Set(), + }).calls + + const rpcCalls = decodeAntigravityGeneratorMetadata({ + records: [ + { + chatModel: { + model: 'gemini-3-pro', + usage: { + model: 'gemini-3-pro', + inputTokens: '100', + outputTokens: '50', + responseOutputTokens: '50', + apiProvider: SECRETS.commandLine, + responseId: 'rpc-secret', + }, + chatStartMetadata: { createdAt: '2026-07-17T10:00:00.000Z' }, + }, + }, + ], + context: antigravityContext, + cascadeId: 'sess-hostile', + modelMap: {}, + }).calls + + const calls = [...genMetadataCalls, ...statusLineCalls, ...rpcCalls] + + const { sessions } = toAntigravityObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'antigravity' }, + ) + + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile cascade', () => { + const envelope = decodeAndMinimize() + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + // Guards against a vacuous fixture: a malformed record would be dropped by + // its decoder and the secret assertions below would then prove nothing. + expect(envelope.sessions[0]?.calls).toHaveLength(3) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('emits no tool names (antigravity has no tool map)', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toEqual([]) + }) + + it('a MODEL_PLACEHOLDER id with no displayName surfaces as unknown, never the raw placeholder', () => { + const usage = lenField(4, [...varintField(2, 10), ...varintField(3, 1)]) + const chatModel = [...usage, ...textField(19, 'MODEL_PLACEHOLDER_HOSTILE')] + const row = { idx: 0, data: Buffer.from(lenField(1, chatModel)) } + const { calls } = decodeAntigravityGenMetadata({ + records: [row], + context: antigravityContext, + cascadeId: 'placeholder-test', + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('unknown') + }) +}) diff --git a/packages/core/tests/providers/antigravity-decode.test.ts b/packages/core/tests/providers/antigravity-decode.test.ts new file mode 100644 index 00000000..ca261c7d --- /dev/null +++ b/packages/core/tests/providers/antigravity-decode.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from 'vitest' + +import { + decodeAntigravityGenMetadata, + decodeAntigravityGeneratorMetadata, + decodeAntigravityStatusLine, + parseAntigravityStatusLinePayload, + toObservations, +} from '../../src/providers/antigravity/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'antigravity', sourceRef: 'ref' } + +function varint(n: number): number[] { + const out: number[] = [] + let v = n + while (v > 0x7f) { + out.push((v & 0x7f) | 0x80) + v = Math.floor(v / 128) + } + out.push(v) + return out +} + +function tag(field: number, wire: number): number[] { + return varint(field * 8 + wire) +} + +function varintField(field: number, n: number): number[] { + return [...tag(field, 0), ...varint(n)] +} + +function lenField(field: number, bytes: number[]): number[] { + return [...tag(field, 2), ...varint(bytes.length), ...bytes] +} + +function textField(field: number, text: string): number[] { + const bytes = [...new TextEncoder().encode(text)] + return lenField(field, bytes) +} + +function attrField(key: string, value: string): number[] { + return lenField(20, [...textField(1, key), ...textField(2, value)]) +} + +function sqliteRow(idx: number, hex: string): { idx: number; data: Uint8Array } { + return { idx, data: Buffer.from(hex, 'hex') } +} + +describe('antigravity rich decode (moved to @codeburn/core)', () => { + it('G2: per-cascade seenResponseIds deduplicates identical response ids', () => { + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 10), ...textField(11, 'dup-response')]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex), sqliteRow(1, hex)], + context, + cascadeId: 'g2-dedup', + }) + + expect(calls).toHaveLength(1) + expect(calls[0]).toEqual({ + provider: 'antigravity', + model: 'gemini-3.1-pro-high', + inputTokens: 100, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + timestamp: '', + speed: 'standard', + deduplicationKey: 'antigravity:g2-dedup:dup-response', + sessionId: 'g2-dedup', + }) + }) + + it('G3: responseTokens falls back to totalOutputTokens when split fields are absent', () => { + const usage = lenField(4, [...varintField(2, 50), ...varintField(3, 500)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'g3-total-output', + }) + + expect(calls[0]).toMatchObject({ outputTokens: 500, reasoningTokens: 0 }) + }) + + it('G4: output split adjust arm reconciles response + thinking to total', () => { + const usage = lenField(4, [ + ...varintField(2, 80), + ...varintField(3, 100), + ...varintField(9, 10), + ...varintField(10, 30), + ]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'g4-adjust', + }) + + expect(calls[0]).toMatchObject({ outputTokens: 70, reasoningTokens: 30 }) + }) + + it('G5: inputTokens falls back to field #1 when field #2 is absent', () => { + const usage = lenField(4, [...varintField(1, 777), ...varintField(3, 5)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'g5-input-fallback', + }) + + expect(calls[0]).toMatchObject({ inputTokens: 777 }) + }) + + it('G6: responseId falls back to row idx when field #11 is absent', () => { + const usage = lenField(4, [...varintField(2, 33), ...varintField(3, 3)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(3, hex)], + context, + cascadeId: 'g6-response-id-fallback', + }) + + expect(calls[0]).toMatchObject({ deduplicationKey: 'antigravity:g6-response-id-fallback:3' }) + }) + + it('G7: model precedence and placeholder guard produce distinct canonical models', () => { + const baseUsage = lenField(4, [...varintField(2, 10), ...varintField(3, 1)]) + + const aChat = [...baseUsage, ...textField(19, 'gemini-3.1-pro-high')] + const bChat = [...baseUsage, ...attrField('model_enum', 'gemini-3.5-flash-agent')] + const cChat = [...baseUsage, ...textField(21, 'Gemini 3 Flash')] + const dChat = [...baseUsage] + const eChat = [...baseUsage, ...textField(19, 'MODEL_PLACEHOLDER_M99')] + + const { calls } = decodeAntigravityGenMetadata({ + records: [ + sqliteRow(0, Buffer.from(lenField(1, aChat)).toString('hex')), + sqliteRow(1, Buffer.from(lenField(1, bChat)).toString('hex')), + sqliteRow(2, Buffer.from(lenField(1, cChat)).toString('hex')), + sqliteRow(3, Buffer.from(lenField(1, dChat)).toString('hex')), + sqliteRow(4, Buffer.from(lenField(1, eChat)).toString('hex')), + ], + context, + cascadeId: 'g7-model-precedence', + }) + + expect(calls.map(c => c.model)).toEqual([ + 'gemini-3.1-pro-high', + 'gemini-3.5-flash-agent', + 'gemini-3-flash', + 'unknown', + 'unknown', + ]) + }) + + it('G8: chatStartMetadata.created_at beats any host mtime', () => { + const seconds = 1783326234 + const nanos = 724675400 + const timestamp = [...varintField(1, seconds), ...varintField(2, nanos)] + const chatStartMetadata = lenField(4, timestamp) + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 50)]) + const chatModel = [...usage, ...lenField(9, chatStartMetadata)] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'g8-created-at', + }) + + expect(calls[0]).toMatchObject({ timestamp: '2026-07-06T08:23:54.724Z' }) + }) + + it('G9: zero input and output tokens skip the row', () => { + const usage = lenField(4, [...varintField(2, 0), ...varintField(3, 0)]) + const chatModel = [...usage, ...textField(19, 'gemini-pro-default'), ...textField(21, 'Gemini 3.1 Pro (High)')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'g9-zero', + }) + + expect(calls).toEqual([]) + }) + + it('G14: RPC generator metadata decode handles skips, fallbacks, model map, and timestamps', () => { + const modelMap = { + 'raw-model-a': 'gemini-3-pro', + 'placeholder-key': 'MODEL_PLACEHOLDER_7', + } + + const metadata = [ + { chatModel: { model: 'no-usage' } }, + { + chatModel: { + model: 'zero', + usage: { + model: 'zero', + inputTokens: '0', + outputTokens: '0', + apiProvider: 'google', + }, + }, + }, + { + chatModel: { + model: 'unmapped-model-a', + usage: { + model: 'unmapped-model-a', + inputTokens: '100', + outputTokens: '20', + responseOutputTokens: '15', + thinkingOutputTokens: '5', + apiProvider: 'google', + }, + }, + }, + { + chatModel: { + model: 'raw-model-a', + usage: { + model: 'raw-model-a', + inputTokens: '50', + outputTokens: '10', + responseOutputTokens: '8', + thinkingOutputTokens: '2', + apiProvider: 'google', + responseId: 'mapped-call', + }, + }, + }, + { + chatModel: { + model: 'unmapped-model-b', + usage: { + model: 'unmapped-model-b', + inputTokens: '77', + outputTokens: '7', + responseOutputTokens: '6', + thinkingOutputTokens: '1', + apiProvider: 'google', + responseId: 'raw-passthrough-call', + }, + }, + }, + { + chatModel: { + model: 'placeholder-key', + usage: { + model: 'placeholder-key', + inputTokens: '25', + outputTokens: '5', + responseOutputTokens: '4', + thinkingOutputTokens: '1', + apiProvider: 'google', + responseId: 'placeholder-call', + }, + }, + }, + { + chatModel: { + model: 'with-timestamp', + usage: { + model: 'with-timestamp', + inputTokens: '99', + outputTokens: '9', + responseOutputTokens: '9', + apiProvider: 'google', + responseId: 'timestamp-call', + }, + chatStartMetadata: { createdAt: '2026-03-15T09:30:00.000Z' }, + }, + }, + { + chatModel: { + model: 'no-timestamp', + usage: { + model: 'no-timestamp', + inputTokens: '11', + outputTokens: '2', + responseOutputTokens: '2', + apiProvider: 'google', + responseId: 'no-timestamp-call', + }, + }, + }, + ] + + const { calls } = decodeAntigravityGeneratorMetadata({ + records: metadata as unknown[], + context, + cascadeId: 'g14-rpc', + modelMap, + }) + + expect(calls).toHaveLength(6) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'antigravity:g14-rpc:2', + 'antigravity:g14-rpc:mapped-call', + 'antigravity:g14-rpc:raw-passthrough-call', + 'antigravity:g14-rpc:placeholder-call', + 'antigravity:g14-rpc:timestamp-call', + 'antigravity:g14-rpc:no-timestamp-call', + ]) + expect(calls.map(c => ({ model: c.model, inputTokens: c.inputTokens, outputTokens: c.outputTokens, reasoningTokens: c.reasoningTokens, timestamp: c.timestamp }))).toEqual([ + { model: 'unmapped-model-a', inputTokens: 100, outputTokens: 15, reasoningTokens: 5, timestamp: '' }, + { model: 'gemini-3-pro', inputTokens: 50, outputTokens: 8, reasoningTokens: 2, timestamp: '' }, + { model: 'unmapped-model-b', inputTokens: 77, outputTokens: 6, reasoningTokens: 1, timestamp: '' }, + { model: 'unknown', inputTokens: 25, outputTokens: 4, reasoningTokens: 1, timestamp: '' }, + { model: 'with-timestamp', inputTokens: 99, outputTokens: 9, reasoningTokens: 0, timestamp: '2026-03-15T09:30:00.000Z' }, + { model: 'no-timestamp', inputTokens: 11, outputTokens: 2, reasoningTokens: 0, timestamp: '' }, + ]) + }) + + it('statusline: run collapse, monotonic delta, and seenKeys respect', () => { + const lines = [ + JSON.stringify({ at: '2026-01-01T00:00:01.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 100, outputTokens: 10, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }), + JSON.stringify({ at: '2026-01-01T00:00:02.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 200, outputTokens: 20, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }), + JSON.stringify({ at: '2026-01-01T00:00:03.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 200, outputTokens: 20, cacheCreationInputTokens: 0, cacheReadInputTokens: 0 } }), + JSON.stringify({ at: '2026-01-01T00:00:04.000Z', conversationId: 's1', model: 'm', usage: { inputTokens: 300, outputTokens: 30, cacheCreationInputTokens: 0, cacheReadInputTokens: 50 } }), + ] + + const { calls } = decodeAntigravityStatusLine({ records: lines, context, seenKeys: new Set() }) + + expect(calls).toHaveLength(2) + expect(calls[0]).toMatchObject({ inputTokens: 200, outputTokens: 20, cacheReadInputTokens: 0 }) + expect(calls[1]).toMatchObject({ inputTokens: 100, outputTokens: 10, cacheReadInputTokens: 50 }) + }) + + it('parseAntigravityStatusLinePayload uses the injected at value and never captures cwd', () => { + const fixedAt = '2026-05-05T05:05:05.005Z' + const payload = { + conversation_id: 'parse-test', + session_id: 'session-secret', + cwd: '/Users/victim/secret-project', + model: { id: 'gemini-3-pro', display_name: 'Gemini 3 Pro' }, + context_window: { + current_usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } + + const event = parseAntigravityStatusLinePayload(payload, fixedAt) + expect(event).not.toBeNull() + expect(event!.at).toBe(fixedAt) + expect(event).not.toHaveProperty('cwd') + expect(JSON.stringify(event)).not.toContain('/Users/victim/secret-project') + }) + + it('toObservations produces a schema-valid, secret-free envelope', () => { + const chatStartMetadata = textField(4, '2026-07-17T10:00:00.000Z') + const usage = lenField(4, [...varintField(2, 100), ...varintField(3, 10)]) + const chatModel = [...usage, ...lenField(9, chatStartMetadata), ...textField(19, 'gemini-3.1-pro-high')] + const hex = Buffer.from(lenField(1, chatModel)).toString('hex') + + const { calls } = decodeAntigravityGenMetadata({ + records: [sqliteRow(0, hex)], + context, + cascadeId: 'obs-test', + }) + + const { sessions } = toObservations( + { sessionId: 'obs-test', projectPath: '/Users/t/secret-project', calls }, + { privacyKey: 'test-privacy-key', provider: 'antigravity' }, + ) + + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]?.calls[0]?.toolNames).toEqual([]) + expect(JSON.stringify(envelope)).not.toContain('/Users/t/secret-project') + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 68775e22..8e99de43 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -41,6 +41,7 @@ export default defineConfig({ 'src/providers/copilot/index.ts', 'src/providers/vscode-cline/index.ts', 'src/providers/opencode-session/index.ts', + 'src/providers/antigravity/index.ts', ], format: ['esm'], target: 'node20',