From fdd9151e17900abb501d84e33580849592af40de Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:14:01 -0400 Subject: [PATCH] fix(rate-limits): show agy's own error when Antigravity usage can't be read (#34106) When agy refuses a usage read with its own error envelope (for example the location eligibility rejection), Orca now shows agy's text, redacted and bounded to one line, instead of the generic "did not report a quota" message. Existing failure precedence and the last good quota bars are unchanged. Fixes #33426 --- .../antigravity-usage-error.test.ts | 13 ++++- .../rate-limits/antigravity-usage-error.ts | 25 ++++++++++ .../antigravity-usage-fetcher.test.ts | 50 +++++++++++++++++++ .../rate-limits/antigravity-usage-fetcher.ts | 14 ++++-- .../rate-limits/antigravity-usage-response.ts | 34 ++++++------- 5 files changed, 113 insertions(+), 23 deletions(-) diff --git a/src/main/rate-limits/antigravity-usage-error.test.ts b/src/main/rate-limits/antigravity-usage-error.test.ts index f4661b196f3..86a50004115 100644 --- a/src/main/rate-limits/antigravity-usage-error.test.ts +++ b/src/main/rate-limits/antigravity-usage-error.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' -import { classifyAntigravityUsageFailure } from './antigravity-usage-error' +import { + classifyAntigravityUsageFailure, + formatAgyErrorForDisplay +} from './antigravity-usage-error' /** Captured verbatim from agy 1.2.11 when the account had exhausted its weekly pool. */ const REAL_RESOURCE_EXHAUSTED = @@ -74,3 +77,11 @@ describe('classifyAntigravityUsageFailure', () => { expect(classifyAntigravityUsageFailure(output)).toBeNull() }) }) + +it('bounds a large unbroken error without blocking the main process', () => { + const text = `${'!'.repeat(256 * 1024)}@invalid` + const started = performance.now() + + expect(formatAgyErrorForDisplay(text)).toBe(`${'!'.repeat(159)}…`) + expect(performance.now() - started).toBeLessThan(2000) +}) diff --git a/src/main/rate-limits/antigravity-usage-error.ts b/src/main/rate-limits/antigravity-usage-error.ts index 0c0f1d6ed67..f6a1ab0f304 100644 --- a/src/main/rate-limits/antigravity-usage-error.ts +++ b/src/main/rate-limits/antigravity-usage-error.ts @@ -1,4 +1,6 @@ import type { UsageRateLimitFailureKind } from '../../shared/rate-limit-types' +import { stripTerminalControl } from '../../shared/terminal-control-stripping' +import { redactString } from '../observability/redactor' const AGY_ERROR_PREFIX = 'AGY_ERROR:' @@ -17,6 +19,11 @@ const SIGNED_OUT_PHRASES = [ 'authentication required' ] as const +const AGY_MESSAGE_MAX_LENGTH = 160 +// Start at a boundary to avoid quadratic retries inside long, unbroken diagnostics. +const EMAIL_PATTERN = /(^|[\s@])[^\s@]+@[^\s@]+\.[^\s@]+/g +const TOKEN_LIKE_PATTERN = /[\w-]{32,}/g + export type AntigravityUsageFailure = { failureKind: UsageRateLimitFailureKind /** Missing authentication requires sign-in guidance. */ @@ -85,3 +92,21 @@ export function classifyAntigravityUsageFailure(output: string): AntigravityUsag export function stderrShowsInteractiveSignIn(stderr: string): boolean { return stderr.includes('Authentication required. Please visit the URL') } + +/** One bounded line of agy's error text, safe to show in the status bar. */ +export function formatAgyErrorForDisplay(text: string): string | null { + const line = redactString(stripTerminalControl(text)) + .replace(/\s+/g, ' ') + .trim() + .replace(EMAIL_PATTERN, '$1[email]') + .replace(TOKEN_LIKE_PATTERN, '[redacted]') + if (!line) { + return null + } + const chars = Array.from(line) + if (chars.length <= AGY_MESSAGE_MAX_LENGTH) { + return line + } + const kept = chars.slice(0, AGY_MESSAGE_MAX_LENGTH - 1).join('') + return `${kept.trimEnd()}…` +} diff --git a/src/main/rate-limits/antigravity-usage-fetcher.test.ts b/src/main/rate-limits/antigravity-usage-fetcher.test.ts index 9125e5505ec..ccba0f0523b 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.test.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.test.ts @@ -472,3 +472,53 @@ describe('quota read safety and diagnostic precedence', () => { expect(runCommand).toHaveBeenCalledTimes(3) }) }) + +describe("agy's own error envelope", () => { + beforeEach(() => resetAntigravityUsageSupportForTests()) + + const errorEnvelope = (error: string): string => + `${JSON.stringify({ conversation_id: '', status: 'ERROR', response: '', error, num_turns: 0 })}\n` + const LOCATION_ERROR = + 'Eligibility check failed: Your current account is not eligible for Antigravity, because it is not currently available in your location.' + + it('shows agy’s text instead of the generic parse failure', async () => { + const result = await harness({ + result: processResult({ + code: 1, + stdout: errorEnvelope(LOCATION_ERROR), + stderr: `error: ${LOCATION_ERROR}\n` + }) + }).fetch() + + expect(result.status).toBe('error') + expect(result.usageMetadata?.failureKind).toBe('parse') + expect(result.error).toBe(`Antigravity usage is not available. agy reported: ${LOCATION_ERROR}`) + }) + + it('redacts identities and bounds agy’s text to one line', async () => { + const token = 'ya29.a0AfB_byC1234567890abcdefghijklmnopqrstuvwxyz' + const result = await harness({ + result: processResult({ + code: 1, + stdout: errorEnvelope( + `\u001b[31mDenied\u001b[0m for person@example.com\nAGY_SESSION=shortsecret\nwith ${token} ${'x '.repeat(200)}` + ) + }) + }).fetch() + + const shown = result.error?.replace('Antigravity usage is not available. agy reported: ', '') + expect(shown).toMatch(/^Denied for \[email\] AGY_SESSION=\S+ with ya29\.\[redacted\] x x/) + expect(shown).not.toContain('shortsecret') + expect(Array.from(shown ?? '').length).toBeLessThanOrEqual(160) + expect(shown?.endsWith('…')).toBe(true) + }) + + it('keeps sign-in guidance ahead of agy’s text', async () => { + const result = await harness({ + result: processResult({ code: 1, stdout: errorEnvelope('Not authenticated. Run agy login.') }) + }).fetch() + + expect(result.usageMetadata?.failureKind).toBe('missing-credentials') + expect(result.error).toContain('Sign in with `agy`') + }) +}) diff --git a/src/main/rate-limits/antigravity-usage-fetcher.ts b/src/main/rate-limits/antigravity-usage-fetcher.ts index e5fd07a4dad..71873a852c8 100644 --- a/src/main/rate-limits/antigravity-usage-fetcher.ts +++ b/src/main/rate-limits/antigravity-usage-fetcher.ts @@ -14,10 +14,14 @@ import { ANTIGRAVITY_VERSION_TIMEOUT_MS, antigravityCommandName } from './antigravity-usage-command' -import { parseAntigravityUsageStdout, stdoutShowsModelTurn } from './antigravity-usage-response' - +import { + parseAntigravityUsageStdout, + readAntigravityErrorEnvelope, + stdoutShowsModelTurn +} from './antigravity-usage-response' import { classifyAntigravityUsageFailure, + formatAgyErrorForDisplay, stderrShowsInteractiveSignIn } from './antigravity-usage-error' @@ -211,8 +215,10 @@ export async function fetchAntigravityRateLimits( now() ) } - } - if (!reading) { + const agyError = formatAgyErrorForDisplay(readAntigravityErrorEnvelope(result.stdout) ?? '') + if (agyError) { + return failed(`Antigravity usage is not available. agy reported: ${agyError}`, 'parse', now()) + } // runProcess returns nonzero exit as data; classify signed-out first and add exit detail only when payload is missing. const exitDetail = result.code === 0 || result.code === null ? '' : ` (exit ${result.code})` return failed( diff --git a/src/main/rate-limits/antigravity-usage-response.ts b/src/main/rate-limits/antigravity-usage-response.ts index 69a5b1a132c..ceecd48bd36 100644 --- a/src/main/rate-limits/antigravity-usage-response.ts +++ b/src/main/rate-limits/antigravity-usage-response.ts @@ -156,16 +156,16 @@ export function parseAntigravityUsageEnvelope(value: unknown): AntigravityUsageR } /** Allow CLI log noise around the JSON envelope. */ -export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null { +function findInStdoutEnvelopes(stdout: string, read: (value: unknown) => T | null): T | null { for (const line of stdout.split('\n')) { const trimmed = line.trim() if (!trimmed.startsWith('{')) { continue } try { - const reading = parseAntigravityUsageEnvelope(JSON.parse(trimmed)) - if (reading) { - return reading + const found = read(JSON.parse(trimmed)) + if (found !== null) { + return found } } catch { continue @@ -174,6 +174,17 @@ export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageRea return null } +export function parseAntigravityUsageStdout(stdout: string): AntigravityUsageReading | null { + return findInStdoutEnvelopes(stdout, parseAntigravityUsageEnvelope) +} + +/** agy's own error text from a `{"status":"ERROR","error":…}` print envelope. */ +export function readAntigravityErrorEnvelope(stdout: string): string | null { + return findInStdoutEnvelopes(stdout, (value) => + isRecord(value) && readString(value.status) === 'ERROR' ? readString(value.error) : null + ) +} + /** Unknown slash commands can become billed model prompts; conversation/turn evidence must stop future polling. */ export function didRunModelTurn(value: unknown): boolean { if (!isRecord(value)) { @@ -187,18 +198,5 @@ export function didRunModelTurn(value: unknown): boolean { } export function stdoutShowsModelTurn(stdout: string): boolean { - for (const line of stdout.split('\n')) { - const trimmed = line.trim() - if (!trimmed.startsWith('{')) { - continue - } - try { - if (didRunModelTurn(JSON.parse(trimmed))) { - return true - } - } catch { - continue - } - } - return false + return findInStdoutEnvelopes(stdout, (value) => (didRunModelTurn(value) ? true : null)) !== null }