From 48b414b31600c3a38ff9c78ee888da26d2453e13 Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Fri, 6 Nov 2026 15:12:12 -0300 Subject: [PATCH 1/3] fix(cli): re-fetch a stale squad roster before refusing an @-mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The squad roster is fetched once at session start (and after a switch or route), so an agent the user links AFTER the session started is missing from the cached copy. Live on the dev CodeAgent Box (2026-11-06): the user linked Antigravity mid-session, sent a start_task routed to `antigravity`, and the CLI logged "start_task routing to antigravity failed: Unknown agent 'antigravity' — not in your squad." — the turn was then answered by Claude. routeSquadTask now re-fetches the roster ONCE, on demand, when the target is absent from the cached roster, and only refuses if it is still absent. No timers or polling. SquadState owns the fetch (refreshRoster), so the runner's existing refresh points reuse the same path. Bead: codeagent-p1ch Co-Authored-By: Claude Opus 5.5 --- .../agents/acp/start-task-routing.test.ts | 35 +++++++++++++++++++ apps/cli/src/agents/acp/command-handlers.ts | 16 +++++++-- apps/cli/src/agents/acp/runner.ts | 14 ++++---- apps/cli/src/agents/acp/squad-roster.ts | 23 +++++++++++- 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/apps/cli/__tests__/agents/acp/start-task-routing.test.ts b/apps/cli/__tests__/agents/acp/start-task-routing.test.ts index 66d5591c..0cfe7c83 100644 --- a/apps/cli/__tests__/agents/acp/start-task-routing.test.ts +++ b/apps/cli/__tests__/agents/acp/start-task-routing.test.ts @@ -348,6 +348,41 @@ describe('start_task — agentId routing', () => { }); }); + // codeagent-p1ch (dev Box, 2026-11-06): the user linked Antigravity AFTER the + // session started, so the roster fetched at session start didn't list it; + // the @-mention was refused "Unknown agent 'antigravity' — not in your squad." + // and Claude answered instead. A miss re-fetches the roster ONCE, on demand. + it('re-fetches a stale roster once when the target is missing, then routes', async () => { + const fetchRoster = vi.fn(async (): Promise => ({ + ...ROSTER, + agents: [...ROSTER.agents, { agentId: 'antigravity', displayName: 'Antigravity' }], + })); + const squad = new SquadState({ sessionId: 's1', homeDir, fetchRoster }); + const { session, calls, routeToAgent, relay } = makeCtx({ squad }); + await dispatchAcpCommand( + assembleAcpCommandContext(session, startTask({ prompt: 'go', agentId: 'antigravity' })), + ); + expect(fetchRoster).toHaveBeenCalledOnce(); + expect(routeToAgent).toHaveBeenCalledWith('antigravity'); + expect(calls).toContain('prompt@antigravity'); + expect(relay.sendResult).not.toHaveBeenCalledWith('cmd-1', 'failed', expect.anything()); + }); + + it('still refuses when the re-fetched roster also lacks the target', async () => { + const fetchRoster = vi.fn(async (): Promise => ROSTER); + const squad = new SquadState({ sessionId: 's1', homeDir, fetchRoster }); + const { session, routeToAgent, client, relay } = makeCtx({ squad }); + await dispatchAcpCommand( + assembleAcpCommandContext(session, startTask({ prompt: 'go', agentId: 'ghost' })), + ); + expect(fetchRoster).toHaveBeenCalledOnce(); + expect(routeToAgent).not.toHaveBeenCalled(); + expect(client.prompt).not.toHaveBeenCalled(); + expect(relay.sendResult).toHaveBeenCalledWith('cmd-1', 'failed', { + error: "Unknown agent 'ghost' — not in your squad.", + }); + }); + it('retries ONCE on the full path when an armed fast path fails, then fails the task', async () => { const squad = new SquadState({ sessionId: 's1', homeDir }); // Member already brought up once this process → the fast path is armed. diff --git a/apps/cli/src/agents/acp/command-handlers.ts b/apps/cli/src/agents/acp/command-handlers.ts index a1f11c44..8fa77b09 100644 --- a/apps/cli/src/agents/acp/command-handlers.ts +++ b/apps/cli/src/agents/acp/command-handlers.ts @@ -69,6 +69,7 @@ import { type IntegrationsManifest, type SquadConfigurePayload, type SquadConfigureResult, + type SquadRosterData, type SquadStatsResult, type StartTaskFailedResult, type StartTaskPayload, @@ -493,9 +494,18 @@ async function routeSquadTask( // The roster is the authority on membership when we have one. Without it // (old backend / offline) `performAgentSwitch`'s own validation still // rejects unknown / non-switchable ids honestly. - const roster = squad?.roster; - if (roster && !roster.agents.some((a) => a.agentId === target)) { - return { ok: false, error: `Unknown agent '${target}' — not in your squad.` }; + // + // The cached roster is fetched at session start, so an agent the user linked + // AFTER that is missing from it: re-fetch ONCE on a miss before refusing + // (codeagent-p1ch — Antigravity linked mid-session was refused "not in your + // squad" and Claude answered the @-mention instead). + const inRoster = (r: SquadRosterData | null | undefined): boolean => + Boolean(r && r.agents.some((a) => a.agentId === target)); + if (squad?.roster && !inRoster(squad.roster)) { + const fresh = await squad.refreshRoster(); + if (!inRoster(fresh)) { + return { ok: false, error: `Unknown agent '${target}' — not in your squad.` }; + } } const member = squad?.member(target); const fastPathArmed = Boolean(member && (member.provisioned || member.binaryVerified)); diff --git a/apps/cli/src/agents/acp/runner.ts b/apps/cli/src/agents/acp/runner.ts index e5b0312e..b45c57c8 100644 --- a/apps/cli/src/agents/acp/runner.ts +++ b/apps/cli/src/agents/acp/runner.ts @@ -1988,19 +1988,19 @@ export async function runAcpSession(opts: AcpRunnerOptions): Promise { const squad = new SquadState({ sessionId: opts.sessionId, auto: getSquadAuto(opts.pluginId), + fetchRoster: () => + fetchSquadRoster({ + sessionId: opts.sessionId, + pluginId: opts.pluginId, + pluginAuthToken: opts.pluginAuthToken, + }), }); // The roster is a BACKEND fact (which agents the user linked + whether the // plan allows handoffs). Fire-and-forget at session start so a slow/offline // roster fetch never delays the first turn; every squad feature no-ops // while `squad.roster` is null (old backend / offline → silently off). const refreshSquadRoster = (): void => { - void fetchSquadRoster({ - sessionId: opts.sessionId, - pluginId: opts.pluginId, - pluginAuthToken: opts.pluginAuthToken, - }).then((roster) => { - if (roster) squad.roster = roster; - }); + void squad.refreshRoster(); }; refreshSquadRoster(); // At most ONE un-resolved agent-proposed handoff at a time (startTaskH emits diff --git a/apps/cli/src/agents/acp/squad-roster.ts b/apps/cli/src/agents/acp/squad-roster.ts index b3b28f35..78bfd5d8 100644 --- a/apps/cli/src/agents/acp/squad-roster.ts +++ b/apps/cli/src/agents/acp/squad-roster.ts @@ -123,12 +123,33 @@ export class SquadState { private turns: SquadJournalEntry[]; private readonly members = new Map(); - constructor(opts: { sessionId: string; homeDir?: string; auto?: SquadAutoConfig | null }) { + /** Backend roster fetch (`/api/plugin/agents/roster`); absent → never refreshes. */ + private readonly fetchRoster: (() => Promise) | null; + + constructor(opts: { + sessionId: string; + homeDir?: string; + auto?: SquadAutoConfig | null; + fetchRoster?: () => Promise; + }) { this.journalPath = journalPathFor(opts.homeDir ?? os.homedir(), opts.sessionId); this.turns = loadJournal(this.journalPath); + this.fetchRoster = opts.fetchRoster ?? null; if (opts.auto) this.setAuto(opts.auto); } + /** + * Re-fetch the roster from the backend (on demand — never on a timer). A + * failed/offline fetch keeps the last known roster. Resolves to the roster + * now in effect. + */ + async refreshRoster(): Promise { + if (!this.fetchRoster) return this.roster; + const fresh = await this.fetchRoster(); + if (fresh) this.roster = fresh; + return this.roster; + } + get auto(): Readonly { return this.autoConfig; } From da221b5717f3d7cfb0a2a161c90dbe4bbb01e283 Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Fri, 6 Nov 2026 15:14:43 -0300 Subject: [PATCH 2/3] fix(cli): classify Antigravity provider errors streamed as a completed reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agy (`agy acp`) does not report a provider/auth failure as a JSON-RPC error. It streams "⚠ Agent execution terminated due to error." plus the provider's message as a normal agent_message_chunk and then ends the turn with a clean end_turn. None of the throw/exit failure paths (failureBubble, reportCredentialInvalid) see that, so with an invalid API key (observed when agy was added: the turn fails with text including "API key not valid") the raw agy text was rendered as the agent's reply, the user got no re-auth bubble, and Profile › Agents kept showing the credential as CONNECTED. An antigravity classifyCompletedReply hook now classifies such a reply: - banner + a Google credential failure (API key not valid / API_KEY_INVALID / expired key, UNAUTHENTICATED / invalid authentication credentials, invalid_grant / token expired or revoked) -> the standard re-auth bubble + reportCredentialInvalid, the same path as Claude's "Not logged in" reply; - banner without an auth cause -> the honest generic TURN_FAILURE_MESSAGE, credential untouched. It requires agy's own banner and a reply of at most 1000 characters, so a substantive reply that merely discusses an API-key error is never misclassified. The Agent Pack path (completedReplyFailureBubble) honours the same classification. Bead: codeagent-6bg9 Co-Authored-By: Claude Opus 5.5 --- .../acp/antigravity-reply-failure.test.ts | 162 ++++++++++++++++++ apps/cli/src/agents/acp/agent-hooks.ts | 44 ++++- apps/cli/src/agents/acp/command-handlers.ts | 22 ++- apps/cli/src/agents/acp/failure-messages.ts | 6 +- 4 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts diff --git a/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts b/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts new file mode 100644 index 00000000..4c7bd035 --- /dev/null +++ b/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts @@ -0,0 +1,162 @@ +/** + * Regression (codeagent-6bg9) — Antigravity (`agy acp`) reports a provider / + * auth failure as a NORMAL agent message chunk ("⚠ Agent execution terminated + * due to error." + the provider's detail) followed by a clean `end_turn`, not a + * JSON-RPC error. None of the throw/exit failure paths see it, so before the + * fix the raw text was rendered as the agent's reply: no re-auth bubble, and + * the LinkedAgent credential was never flagged invalid. + * + * Observed when agy was added: a fake API key fails the turn with text that + * includes "API key not valid". The fixtures below reproduce that shape. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { AcpPublisher } from '../../../src/agents/acp/publisher'; +import { + StreamingState, + handleCommand, + AUTH_FAILURE_MESSAGE, + TURN_FAILURE_MESSAGE, +} from '../../../src/agents/acp/runner'; +import { classifyAntigravityReply } from '../../../src/agents/acp/agent-hooks'; + +vi.mock('../../../src/services/pairing.service', () => ({ + postTurnEvent: vi.fn().mockResolvedValue(undefined), + fetchCurrentPluginAuthToken: vi.fn(), + _postJsonAuthed: vi.fn(), +})); + +const AGY_INVALID_KEY = + '⚠ Agent execution terminated due to error.\n\n' + + 'API key not valid. Please pass a valid API key.'; +const AGY_EXPIRED_OAUTH = + '⚠ Agent execution terminated due to error.\n\n' + + 'Request had invalid authentication credentials. Expected OAuth 2 access token, ' + + 'login cookie or other valid authentication credential.'; +const AGY_GENERIC_ERROR = '⚠ Agent execution terminated due to error.'; + +type OutputCall = { type?: string; content?: string; done?: boolean }; + +function makeHarness(agent: string, replyText: string) { + const publisher = new AcpPublisher({ + sessionId: 'sess-1', + pluginId: 'plugin-1', + pluginAuthToken: 'tok-1', + apiBaseUrl: 'https://api.example.test', + }); + const publishOutput = vi.spyOn(publisher, 'publishOutput').mockResolvedValue(undefined); + vi.spyOn(publisher, 'publishStreamingChunk').mockResolvedValue(undefined); + vi.spyOn(publisher, 'pushConversation').mockResolvedValue(undefined); + vi.spyOn(publisher, 'pushSessionList').mockResolvedValue(undefined); + const streaming = new StreamingState(publisher); + + // agy streams the error as an ordinary chunk, then ends the turn cleanly. + const client = { + prompt: vi.fn(async () => { + streaming.append({ chunkId: 'msg-1', kind: 'text', delta: replyText }); + return { stopReason: 'end_turn' }; + }), + cancel: vi.fn(async () => undefined), + }; + const sendResult = vi.fn(async () => undefined); + const opts = { + agent, + sessionId: 'sess-1', + pluginId: 'plugin-1', + pluginAuthToken: 'tok-1', + adapter: { command: 'noop', args: [] }, + cwd: '/tmp', + }; + const terminalTextFrames = (): OutputCall[] => + publishOutput.mock.calls + .map((c) => c[0] as OutputCall) + .filter((b) => b.type === 'text' && b.done === true); + + const run = () => + handleCommand( + { id: 'cmd-1', type: 'start_task', payload: { prompt: 'hi' } } as never, + client as never, + { sendResult } as never, + 'acp-sess-1', + streaming, + opts as never, + { + appendUserPrompt: vi.fn(), + appendAgentReply: vi.fn(), + flush: vi.fn(async () => undefined), + } as never, + { uploadConversationIfChanged: vi.fn(async () => undefined) } as never, + undefined, + { flushTurn: vi.fn(async () => undefined), peekTurnPaths: vi.fn(() => []) } as never, + () => null, + publisher, + [], + ); + return { run, sendResult, terminalTextFrames }; +} + +describe('Antigravity — provider errors streamed as a completed reply', () => { + let fetchSpy: ReturnType; + const credentialInvalidPosted = (): boolean => + fetchSpy.mock.calls.some((c) => /\/credential-invalid$/.test(String(c[0]))); + + beforeEach(() => { + fetchSpy = vi.fn(async () => ({ ok: true, status: 200 }) as Response); + vi.stubGlobal('fetch', fetchSpy); + }); + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it.each([ + ['invalid API key', AGY_INVALID_KEY], + ['expired OAuth token', AGY_EXPIRED_OAUTH], + ])('%s → re-auth bubble + credential flagged invalid', async (_label, text) => { + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', text); + await run(); + await new Promise((r) => setImmediate(r)); + const finals = terminalTextFrames(); + expect(finals).toHaveLength(1); + expect(finals[0].content).toBe(AUTH_FAILURE_MESSAGE); + expect(credentialInvalidPosted()).toBe(true); + expect(sendResult).toHaveBeenCalledWith('cmd-1', 'failed', expect.any(Object)); + }); + + it('a non-auth "execution terminated" → honest generic failure, credential untouched', async () => { + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', AGY_GENERIC_ERROR); + await run(); + await new Promise((r) => setImmediate(r)); + const finals = terminalTextFrames(); + expect(finals).toHaveLength(1); + expect(finals[0].content).toBe(TURN_FAILURE_MESSAGE); + expect(credentialInvalidPosted()).toBe(false); + expect(sendResult).toHaveBeenCalledWith('cmd-1', 'failed', expect.any(Object)); + }); + + it('a normal reply that merely discusses an invalid API key is NOT misclassified', async () => { + const reply = + 'The 400 you saw comes from the Maps client: "API key not valid" means the key in ' + + '.env is wrong. I updated `config.ts` to read MAPS_API_KEY instead.'; + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', reply); + await run(); + await new Promise((r) => setImmediate(r)); + expect(terminalTextFrames().some((f) => f.content === AUTH_FAILURE_MESSAGE)).toBe(false); + expect(credentialInvalidPosted()).toBe(false); + expect(sendResult).not.toHaveBeenCalledWith('cmd-1', 'failed', expect.anything()); + }); +}); + +describe('classifyAntigravityReply', () => { + it('needs agy’s own termination marker', () => { + expect(classifyAntigravityReply('API key not valid. Please pass a valid API key.')).toBeNull(); + }); + it('classifies auth vs generic termination', () => { + expect(classifyAntigravityReply(AGY_INVALID_KEY)).toBe('auth_failed'); + expect(classifyAntigravityReply(AGY_EXPIRED_OAUTH)).toBe('auth_failed'); + expect(classifyAntigravityReply(AGY_GENERIC_ERROR)).toBe('agent_error'); + }); + it('ignores a long reply that happens to quote the marker', () => { + const long = `${'Here is what I changed. '.repeat(60)}\n${AGY_GENERIC_ERROR}`; + expect(classifyAntigravityReply(long)).toBeNull(); + }); +}); diff --git a/apps/cli/src/agents/acp/agent-hooks.ts b/apps/cli/src/agents/acp/agent-hooks.ts index fe3cdf09..cd3b18ec 100644 --- a/apps/cli/src/agents/acp/agent-hooks.ts +++ b/apps/cli/src/agents/acp/agent-hooks.ts @@ -43,9 +43,12 @@ export interface AcpAgentHooks { * Classify an agent's COMPLETED-turn reply into an agent-specific terminal * outcome. Relocates command-handlers' `agent === 'cursor' && * replyIsCursorUpgradeRequired(...)` branch. `'upgrade_required'` ⇒ Cursor's - * own plan paywall arrived as the reply. + * own plan paywall arrived as the reply. `'auth_failed'` ⇒ the reply IS the + * agent's own invalid/expired-credential error (re-auth bubble + + * reportCredentialInvalid). `'agent_error'` ⇒ the reply IS the agent's own + * non-auth failure notice (generic honest failure frame). */ - classifyCompletedReply?: (finalText: string) => 'upgrade_required' | null; + classifyCompletedReply?: (finalText: string) => CompletedReplyClass | null; /** * Classify a STARTUP-failure haystack (`detail` + recent stderr) into an * agent-specific reason. Relocates failure-messages' `agent === 'gemini' && @@ -75,6 +78,40 @@ export function replyIsCursorUpgradeRequired(finalText: string): boolean { ); } +export type CompletedReplyClass = 'upgrade_required' | 'auth_failed' | 'agent_error'; + +/** agy's own banner for a turn its provider call failed. */ +const AGY_TERMINATED_RE = /Agent execution terminated due to error/i; +/** + * Google credential failures as agy relays them: an invalid/expired API key + * ("API key not valid", reason API_KEY_INVALID / "API key expired") or a dead + * OAuth token (401 UNAUTHENTICATED "invalid authentication credentials", a + * refresh that fails "invalid_grant" / "Token has been expired or revoked"). + */ +const AGY_AUTH_RE = + /API key not valid|API_KEY_INVALID|API key expired|invalid authentication credentials|\bUNAUTHENTICATED\b|token has been expired or revoked|\binvalid_grant\b|authentication required/i; +/** + * agy's notice is the banner plus the provider's message (a Google error body + * can carry a details array), so the bound is wider than replyIsAuthFailure's + * 200 — the required banner keeps a substantive reply from matching. + */ +const AGY_ERROR_REPLY_MAX = 1000; + +/** + * Antigravity (`agy acp`) reports a provider failure as a NORMAL agent message + * chunk — "⚠ Agent execution terminated due to error." plus the provider's + * detail — followed by a clean `end_turn`, never a JSON-RPC error, so no + * throw/exit failure path sees it (codeagent-6bg9). Requires agy's banner and + * a short reply, so a reply that merely discusses an API-key error is never + * misclassified. + */ +export function classifyAntigravityReply(finalText: string): 'auth_failed' | 'agent_error' | null { + const t = finalText.trim(); + if (t.length === 0 || t.length > AGY_ERROR_REPLY_MAX) return null; + if (!AGY_TERMINATED_RE.test(t)) return null; + return AGY_AUTH_RE.test(t) ? 'auth_failed' : 'agent_error'; +} + /** True when a startup failure is Gemini's post-2026-06-18 free-tier death. */ function isGeminiIneligibleTier(haystack: string): boolean { return /IneligibleTierError|UNSUPPORTED_CLIENT|no longer supported for Gemini Code Assist|not eligible for Gemini Code Assist/i.test( @@ -120,6 +157,9 @@ export const ACP_AGENT_HOOKS: Partial> = { classifyCompletedReply: (finalText) => replyIsCursorUpgradeRequired(finalText) ? 'upgrade_required' : null, }, + antigravity: { + classifyCompletedReply: classifyAntigravityReply, + }, }; /** diff --git a/apps/cli/src/agents/acp/command-handlers.ts b/apps/cli/src/agents/acp/command-handlers.ts index 8fa77b09..32e2843b 100644 --- a/apps/cli/src/agents/acp/command-handlers.ts +++ b/apps/cli/src/agents/acp/command-handlers.ts @@ -107,6 +107,7 @@ import { AUTH_FAILURE_MESSAGE, CURSOR_UPGRADE_MESSAGE, ONE_M_CREDITS_MESSAGE, + TURN_FAILURE_MESSAGE, describeError, emptyReplyMessage, failureBubble, @@ -1070,7 +1071,8 @@ async function runStartTaskTurn( // Insufficient credits"). Record the structured external/house marker // BEFORE the classification chain below — observation only, no return. noteProviderBillingSignal({ text: finalText, source: 'reply', agent: opts.agent }); - if (agentHooks(opts.agent)?.classifyCompletedReply?.(finalText) === 'upgrade_required') { + const agentReplyClass = agentHooks(opts.agent)?.classifyCompletedReply?.(finalText) ?? null; + if (agentReplyClass === 'upgrade_required') { // Cursor's OWN plan paywall ("Upgrade your plan to continue"): the // user's Cursor account is on Free, which doesn't include the headless // Agent. NOT a credential problem — swap the bare text for an @@ -1127,7 +1129,7 @@ async function runStartTaskTurn( error: 'provider account has no credits (402)', }); return; - } else if (replyIsAuthFailure(finalText)) { + } else if (agentReplyClass === 'auth_failed' || replyIsAuthFailure(finalText)) { // The agent COMPLETED the turn but its reply IS an auth-failure // notice ("Not logged in · Please run /login") — a missing/expired // credential the agent surfaced as plain text instead of throwing. @@ -1173,6 +1175,22 @@ async function runStartTaskTurn( error: 'agent reply reported 1M-context usage-credits gate', }); return; + } else if (agentReplyClass === 'agent_error') { + // The reply IS the agent's own non-auth failure notice (agy's "⚠ Agent + // execution terminated due to error." + end_turn, codeagent-6bg9). + // Replace it with the honest generic failure frame; the credential is + // fine, so no reportCredentialInvalid. + await streaming.closeWithBubble(TURN_FAILURE_MESSAGE); + turnClosed = true; + history.appendAgentReply(TURN_FAILURE_MESSAGE); + void history.flush(); + turnFiles.flushTurn().catch((err) => { + log.warn('acpRunner', `turnFiles.flushTurn failed: ${describeError(err)}`); + }); + log.info('acpRunner', `start_task ← agent-error-in-reply id=${cmd.id.slice(0, 8)}`); + reportTurn(ctx, 'failed', 'TURN_ERROR'); + await relay.sendResult(cmd.id, 'failed', { error: 'agent reply reported an error' }); + return; } else if ( reply.stopReason === 'end_turn' && finalText.trim().length === 0 && diff --git a/apps/cli/src/agents/acp/failure-messages.ts b/apps/cli/src/agents/acp/failure-messages.ts index 5040c6c5..3393581c 100644 --- a/apps/cli/src/agents/acp/failure-messages.ts +++ b/apps/cli/src/agents/acp/failure-messages.ts @@ -304,7 +304,11 @@ export function completedReplyFailureBubble(opts: { if (replyIsByoProviderBilling(opts.finalText, env)) { return byoProviderBillingMessage(byoProviderName({ env, agent: opts.agent })); } - if (replyIsAuthFailure(opts.finalText)) return AUTH_FAILURE_MESSAGE; + const agentReplyClass = agentHooks(opts.agent)?.classifyCompletedReply?.(opts.finalText); + if (agentReplyClass === 'auth_failed' || replyIsAuthFailure(opts.finalText)) { + return AUTH_FAILURE_MESSAGE; + } + if (agentReplyClass === 'agent_error') return TURN_FAILURE_MESSAGE; return null; } From 57210a8f82c3990371296cbe545ddf07edcbb2eb Mon Sep 17 00:00:00 2001 From: Edgar Durand Date: Fri, 6 Nov 2026 15:19:50 -0300 Subject: [PATCH 3/3] fix(cli): read agy's own log to tell an auth failure from a generic one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first classifier (da221b57) looked for the credential cause in the reply text. A live capture on a CodeAgent Box (agy 1.5.2, `agy acp`, GEMINI_API_KEY = an invalid key, 2026-11-06) shows agy never streams it: the ONLY text is the agent_message_chunk "⚠ Agent execution terminated due to error.", session/prompt resolves {"stopReason":"end_turn"}, and stderr is empty. The cause is only in agy's log, ~/.gemini/antigravity-cli/log/acp-server--.log: E1106 18:16:15.662983 233 errorreport.go:247] agent executor error: generating and executing: Error 400, Message: API key not valid. ... Status: INVALID_ARGUMENT, Details: [... reason:API_KEY_INVALID ...] So an invalid key still got the generic frame and the credential was never flagged. On a banner reply the classifier now (after the cheap reply-text check) reads the last 16 KB of the newest acp-server log under the agy process's HOME (the current agent env), keeps only E/F entries stamped since the turn started, and returns auth_failed when one names a credential failure (API_KEY_INVALID / API key not valid / expired key, UNAUTHENTICATED / 401, invalid_grant / token expired or revoked, PERMISSION_DENIED with a key/token reason). Otherwise, or with no log, the generic honest frame. The matched entry is logged with Google API keys and OAuth tokens redacted. An expired-and-unrefreshable OAuth token or a missing credential fails earlier, at session/new (-32000 "Authentication required"), on the existing startup path; this covers a credential that goes bad mid-session. Bead: codeagent-6bg9 Co-Authored-By: Claude Opus 5.5 --- .../acp/antigravity-reply-failure.test.ts | 135 +++++++++---- .../agents/antigravity/reply-failure.test.ts | 135 +++++++++++++ apps/cli/src/agents/acp/agent-hooks.ts | 47 ++--- apps/cli/src/agents/acp/command-handlers.ts | 8 +- apps/cli/src/agents/acp/failure-messages.ts | 2 +- .../src/agents/antigravity/reply-failure.ts | 177 ++++++++++++++++++ 6 files changed, 431 insertions(+), 73 deletions(-) create mode 100644 apps/cli/__tests__/agents/antigravity/reply-failure.test.ts create mode 100644 apps/cli/src/agents/antigravity/reply-failure.ts diff --git a/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts b/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts index 4c7bd035..6ca903af 100644 --- a/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts +++ b/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts @@ -1,15 +1,19 @@ /** * Regression (codeagent-6bg9) — Antigravity (`agy acp`) reports a provider / - * auth failure as a NORMAL agent message chunk ("⚠ Agent execution terminated - * due to error." + the provider's detail) followed by a clean `end_turn`, not a - * JSON-RPC error. None of the throw/exit failure paths see it, so before the - * fix the raw text was rendered as the agent's reply: no re-auth bubble, and - * the LinkedAgent credential was never flagged invalid. + * auth failure as a NORMAL completed turn. Live capture (CodeAgent Box, agy + * 1.5.2, GEMINI_API_KEY = an invalid key, 2026-11-06): the ONLY streamed text is + * `⚠ Agent execution terminated due to error.\n`, `session/prompt` resolves + * `{"stopReason":"end_turn"}`, nothing on stderr. The cause is only in agy's + * own log, `~/.gemini/antigravity-cli/log/acp-server--.log`. * - * Observed when agy was added: a fake API key fails the turn with text that - * includes "API key not valid". The fixtures below reproduce that shape. + * Before the fix the banner was rendered as the agent's reply: no re-auth + * bubble, credential never flagged invalid. The fixtures reproduce that exact + * shape — banner-only reply, the detail in a fake agy log in a tmp HOME. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { AcpPublisher } from '../../../src/agents/acp/publisher'; import { StreamingState, @@ -17,7 +21,10 @@ import { AUTH_FAILURE_MESSAGE, TURN_FAILURE_MESSAGE, } from '../../../src/agents/acp/runner'; -import { classifyAntigravityReply } from '../../../src/agents/acp/agent-hooks'; +import { + resetCurrentAgentEnvForTests, + setCurrentAgentEnv, +} from '../../../src/agents/current-agent-env'; vi.mock('../../../src/services/pairing.service', () => ({ postTurnEvent: vi.fn().mockResolvedValue(undefined), @@ -25,18 +32,43 @@ vi.mock('../../../src/services/pairing.service', () => ({ _postJsonAuthed: vi.fn(), })); -const AGY_INVALID_KEY = - '⚠ Agent execution terminated due to error.\n\n' + - 'API key not valid. Please pass a valid API key.'; -const AGY_EXPIRED_OAUTH = - '⚠ Agent execution terminated due to error.\n\n' + - 'Request had invalid authentication credentials. Expected OAuth 2 access token, ' + - 'login cookie or other valid authentication credential.'; -const AGY_GENERIC_ERROR = '⚠ Agent execution terminated due to error.'; +/** Exactly what agy streams on a failed turn — nothing else. */ +const AGY_BANNER = '⚠ Agent execution terminated due to error.\n'; + +/** glog prefix for `at` in local time, like agy writes it. */ +function glog(sev: string, at: Date, msg: string): string { + const p = (n: number, w = 2): string => String(n).padStart(w, '0'); + const ts = + `${p(at.getMonth() + 1)}${p(at.getDate())} ${p(at.getHours())}:${p(at.getMinutes())}:` + + `${p(at.getSeconds())}.${p(at.getMilliseconds() * 1000, 6)}`; + return `${sev}${ts} 233 ${msg}`; +} + +/** The real entry agy 1.5.2 logged for an invalid GEMINI_API_KEY. */ +const INVALID_KEY_ENTRY = + 'errorreport.go:247] agent executor error: generating and executing: Error 400, Message: ' + + 'API key not valid. Please pass a valid API key., Status: INVALID_ARGUMENT, Details: ' + + '[{"@type":"type.googleapis.com/google.rpc.ErrorInfo","reason":"API_KEY_INVALID",' + + '"domain":"googleapis.com"}]'; +const NON_AUTH_ENTRY = + 'errorreport.go:247] agent executor error: generating and executing: Error 500, Message: ' + + 'Internal error encountered., Status: INTERNAL'; + +let home: string; + +/** Append agy log lines to the newest acp-server log in the tmp HOME. */ +function writeAgyLog(lines: string[]): void { + const dir = path.join(home, '.gemini', 'antigravity-cli', 'log'); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync( + path.join(dir, 'acp-server-20261106_181600-233.log'), + lines.map((l) => `${l}\n`).join(''), + ); +} type OutputCall = { type?: string; content?: string; done?: boolean }; -function makeHarness(agent: string, replyText: string) { +function makeHarness(agent: string, replyText: string, onPrompt?: () => void) { const publisher = new AcpPublisher({ sessionId: 'sess-1', pluginId: 'plugin-1', @@ -52,6 +84,8 @@ function makeHarness(agent: string, replyText: string) { // agy streams the error as an ordinary chunk, then ends the turn cleanly. const client = { prompt: vi.fn(async () => { + // agy writes the cause to its log DURING the turn, then streams the banner. + onPrompt?.(); streaming.append({ chunkId: 'msg-1', kind: 'text', delta: replyText }); return { stopReason: 'end_turn' }; }), @@ -100,19 +134,25 @@ describe('Antigravity — provider errors streamed as a completed reply', () => fetchSpy.mock.calls.some((c) => /\/credential-invalid$/.test(String(c[0]))); beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'codeam-agy-')); + setCurrentAgentEnv({ HOME: home }); fetchSpy = vi.fn(async () => ({ ok: true, status: 200 }) as Response); vi.stubGlobal('fetch', fetchSpy); }); afterEach(() => { + resetCurrentAgentEnvForTests(); + fs.rmSync(home, { recursive: true, force: true }); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); - it.each([ - ['invalid API key', AGY_INVALID_KEY], - ['expired OAuth token', AGY_EXPIRED_OAUTH], - ])('%s → re-auth bubble + credential flagged invalid', async (_label, text) => { - const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', text); + it('invalid API key (cause only in agy\'s log) → re-auth bubble + credential flagged invalid', async () => { + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', AGY_BANNER, () => + writeAgyLog([ + glog('I', new Date(), 'server.go:88] session/prompt'), + glog('E', new Date(), INVALID_KEY_ENTRY), + ]), + ); await run(); await new Promise((r) => setImmediate(r)); const finals = terminalTextFrames(); @@ -122,8 +162,10 @@ describe('Antigravity — provider errors streamed as a completed reply', () => expect(sendResult).toHaveBeenCalledWith('cmd-1', 'failed', expect.any(Object)); }); - it('a non-auth "execution terminated" → honest generic failure, credential untouched', async () => { - const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', AGY_GENERIC_ERROR); + it('a non-auth agy error → honest generic failure, credential untouched', async () => { + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', AGY_BANNER, () => + writeAgyLog([glog('E', new Date(), NON_AUTH_ENTRY)]), + ); await run(); await new Promise((r) => setImmediate(r)); const finals = terminalTextFrames(); @@ -133,30 +175,47 @@ describe('Antigravity — provider errors streamed as a completed reply', () => expect(sendResult).toHaveBeenCalledWith('cmd-1', 'failed', expect.any(Object)); }); + it('an auth error from an EARLIER turn does not reclassify a new generic failure', async () => { + // Logged 10 minutes before this turn started (since fixed by a re-link). + writeAgyLog([glog('E', new Date(Date.now() - 10 * 60_000), INVALID_KEY_ENTRY)]); + const { run, terminalTextFrames } = makeHarness('antigravity', AGY_BANNER, () => + writeAgyLog([glog('E', new Date(), NON_AUTH_ENTRY)]), + ); + await run(); + await new Promise((r) => setImmediate(r)); + expect(terminalTextFrames()[0].content).toBe(TURN_FAILURE_MESSAGE); + expect(credentialInvalidPosted()).toBe(false); + }); + + it('no agy log at all → generic failure (never a silent raw banner)', async () => { + const { run, terminalTextFrames } = makeHarness('antigravity', AGY_BANNER); + await run(); + await new Promise((r) => setImmediate(r)); + expect(terminalTextFrames()[0].content).toBe(TURN_FAILURE_MESSAGE); + expect(credentialInvalidPosted()).toBe(false); + }); + it('a normal reply that merely discusses an invalid API key is NOT misclassified', async () => { const reply = 'The 400 you saw comes from the Maps client: "API key not valid" means the key in ' + '.env is wrong. I updated `config.ts` to read MAPS_API_KEY instead.'; - const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', reply); + const { run, sendResult, terminalTextFrames } = makeHarness('antigravity', reply, () => + writeAgyLog([glog('E', new Date(), INVALID_KEY_ENTRY)]), + ); await run(); await new Promise((r) => setImmediate(r)); expect(terminalTextFrames().some((f) => f.content === AUTH_FAILURE_MESSAGE)).toBe(false); expect(credentialInvalidPosted()).toBe(false); expect(sendResult).not.toHaveBeenCalledWith('cmd-1', 'failed', expect.anything()); }); -}); -describe('classifyAntigravityReply', () => { - it('needs agy’s own termination marker', () => { - expect(classifyAntigravityReply('API key not valid. Please pass a valid API key.')).toBeNull(); - }); - it('classifies auth vs generic termination', () => { - expect(classifyAntigravityReply(AGY_INVALID_KEY)).toBe('auth_failed'); - expect(classifyAntigravityReply(AGY_EXPIRED_OAUTH)).toBe('auth_failed'); - expect(classifyAntigravityReply(AGY_GENERIC_ERROR)).toBe('agent_error'); - }); - it('ignores a long reply that happens to quote the marker', () => { - const long = `${'Here is what I changed. '.repeat(60)}\n${AGY_GENERIC_ERROR}`; - expect(classifyAntigravityReply(long)).toBeNull(); + it('the same banner on another agent is left alone (scoped to antigravity)', async () => { + const { run, terminalTextFrames } = makeHarness('claude', AGY_BANNER, () => + writeAgyLog([glog('E', new Date(), INVALID_KEY_ENTRY)]), + ); + await run(); + await new Promise((r) => setImmediate(r)); + expect(terminalTextFrames().some((f) => f.content === AUTH_FAILURE_MESSAGE)).toBe(false); + expect(credentialInvalidPosted()).toBe(false); }); }); diff --git a/apps/cli/__tests__/agents/antigravity/reply-failure.test.ts b/apps/cli/__tests__/agents/antigravity/reply-failure.test.ts new file mode 100644 index 00000000..f7e1d38f --- /dev/null +++ b/apps/cli/__tests__/agents/antigravity/reply-failure.test.ts @@ -0,0 +1,135 @@ +/** + * Unit tests for agy's completed-reply failure classifier (codeagent-6bg9): + * the banner gate, the log-tail scan (severity, since-the-turn scoping, tail + * bound) and credential redaction. Behaviour end-to-end through `start_task` + * is in `__tests__/agents/acp/antigravity-reply-failure.test.ts`. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + AGY_LOG_TAIL_BYTES, + antigravityLogDir, + classifyAntigravityReply, + recentErrorEntries, + redactCredentials, +} from '../../../src/agents/antigravity/reply-failure'; + +const BANNER = '⚠ Agent execution terminated due to error.\n'; + +function glog(sev: string, at: Date, msg: string): string { + const p = (n: number, w = 2): string => String(n).padStart(w, '0'); + return ( + `${sev}${p(at.getMonth() + 1)}${p(at.getDate())} ${p(at.getHours())}:${p(at.getMinutes())}:` + + `${p(at.getSeconds())}.${p(at.getMilliseconds() * 1000, 6)} 233 ${msg}` + ); +} + +let home: string; +const env = (): NodeJS.ProcessEnv => ({ HOME: home }); +function writeLog(name: string, body: string): string { + const dir = antigravityLogDir(home); + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, name); + fs.writeFileSync(file, body); + return file; +} + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'codeam-agy-unit-')); +}); +afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); +}); + +describe('classifyAntigravityReply', () => { + it('needs agy’s own banner — a bare error text is a real reply', () => { + expect(classifyAntigravityReply('API key not valid.', { env: env() })).toBeNull(); + }); + + it('ignores a long reply that happens to quote the banner', () => { + const long = `${'Here is what I changed. '.repeat(60)}\n${BANNER}`; + expect(classifyAntigravityReply(long, { env: env() })).toBeNull(); + }); + + it('auth cause in the reply itself → auth without touching the log', () => { + expect(classifyAntigravityReply(`${BANNER}API key not valid.`, { env: env() })).toBe( + 'auth_failed', + ); + }); + + it.each([ + ['API_KEY_INVALID', 'Error 400, Message: API key not valid., Details: [reason:API_KEY_INVALID]'], + ['expired key', 'Error 400, Message: API key expired. Please renew the API key.'], + ['UNAUTHENTICATED', 'Error 401, Message: Request had invalid authentication credentials., Status: UNAUTHENTICATED'], + ['invalid_grant', 'oauth2: "invalid_grant" "Token has been expired or revoked."'], + ['PERMISSION_DENIED + key reason', 'Error 403, Status: PERMISSION_DENIED, Details: [reason:API_KEY_SERVICE_BLOCKED]'], + ])('banner + %s in the newest log → auth_failed', (_label, msg) => { + const since = Date.now(); + writeLog('acp-server-20261106_181600-233.log', `${glog('E', new Date(), `x.go:1] ${msg}`)}\n`); + expect(classifyAntigravityReply(BANNER, { env: env(), turnStartedAt: since })).toBe( + 'auth_failed', + ); + }); + + it('PERMISSION_DENIED without a credential reason is NOT auth', () => { + writeLog( + 'acp-server-20261106_181600-233.log', + `${glog('E', new Date(), 'x.go:1] Error 403, Status: PERMISSION_DENIED, Details: [reason:SERVICE_DISABLED]')}\n`, + ); + expect(classifyAntigravityReply(BANNER, { env: env(), turnStartedAt: Date.now() })).toBe( + 'agent_error', + ); + }); + + it('reads the NEWEST acp-server log only', () => { + const old = writeLog( + 'acp-server-20261105_090000-100.log', + `${glog('E', new Date(), 'x.go:1] API key not valid')}\n`, + ); + fs.utimesSync(old, new Date(Date.now() - 3_600_000), new Date(Date.now() - 3_600_000)); + writeLog('acp-server-20261106_181600-233.log', `${glog('E', new Date(), 'x.go:1] Error 500')}\n`); + expect(classifyAntigravityReply(BANNER, { env: env() })).toBe('agent_error'); + }); + + it('only the last AGY_LOG_TAIL_BYTES are scanned', () => { + const auth = `${glog('E', new Date(), 'x.go:1] API key not valid')}\n`; + const filler = `${glog('I', new Date(), `x.go:2] ${'.'.repeat(200)}`)}\n`.repeat( + Math.ceil((AGY_LOG_TAIL_BYTES * 2) / 230), + ); + writeLog('acp-server-20261106_181600-233.log', auth + filler); + expect(classifyAntigravityReply(BANNER, { env: env() })).toBe('agent_error'); + }); + + it('missing log dir → agent_error', () => { + expect(classifyAntigravityReply(BANNER, { env: env() })).toBe('agent_error'); + }); +}); + +describe('recentErrorEntries', () => { + it('keeps E/F entries with continuation lines, drops I/W and entries before `since`', () => { + const now = Date.now(); + const tail = [ + 'cut-off line from the tail start', + glog('E', new Date(now - 600_000), 'a.go:1] stale API key not valid'), + glog('I', new Date(now), 'b.go:1] info'), + glog('W', new Date(now), 'c.go:1] warn API key not valid'), + glog('E', new Date(now), 'd.go:1] fresh error'), + ' continuation detail', + ].join('\n'); + expect(recentErrorEntries(tail, now - 1_000, now)).toEqual([ + `${glog('E', new Date(now), 'd.go:1] fresh error')}\n continuation detail`, + ]); + }); +}); + +describe('redactCredentials', () => { + it('masks Google API keys and OAuth access/refresh tokens', () => { + const out = redactCredentials( + 'key=AIzaSyA1234567890abcdefGHIJ tok=ya29.a0AfH6SMBxyz-123 refresh=1//0gAbCdEfGhIjKlMn', + ); + expect(out).not.toMatch(/AIzaSyA1234567890|ya29\.a0AfH6|1\/\/0gAbCdEf/); + expect(out).toContain('[redacted]'); + }); +}); diff --git a/apps/cli/src/agents/acp/agent-hooks.ts b/apps/cli/src/agents/acp/agent-hooks.ts index cd3b18ec..53ea1392 100644 --- a/apps/cli/src/agents/acp/agent-hooks.ts +++ b/apps/cli/src/agents/acp/agent-hooks.ts @@ -15,7 +15,8 @@ * pure DATA; the CLI-behavioural predicates below (classify a reply, build * spawn env) don't belong there. * - * This module is a strict LEAF — it imports ONLY the `AgentId` type. In + * This module is a LEAF — besides the `AgentId` type it imports only agy's + * self-contained log classifier (`../antigravity/reply-failure`). In * particular it does NOT import `failure-messages.ts` (which imports THIS * module), so there is no runtime import cycle. The one agent-specific message * that stays in `failure-messages.ts` (the Cursor upgrade bubble) is applied at @@ -23,6 +24,12 @@ */ import type { AgentId } from '@codeam/shared'; +// The one non-type import: agy's failure cause lives only in its own log file, +// so its classifier reads it (node builtins + the logger only — no cycle). +import { + classifyAntigravityReply, + type AntigravityReplyContext, +} from '../antigravity/reply-failure'; export interface AcpAgentHooks { /** @@ -48,7 +55,10 @@ export interface AcpAgentHooks { * reportCredentialInvalid). `'agent_error'` ⇒ the reply IS the agent's own * non-auth failure notice (generic honest failure frame). */ - classifyCompletedReply?: (finalText: string) => CompletedReplyClass | null; + classifyCompletedReply?: ( + finalText: string, + ctx?: CompletedReplyContext, + ) => CompletedReplyClass | null; /** * Classify a STARTUP-failure haystack (`detail` + recent stderr) into an * agent-specific reason. Relocates failure-messages' `agent === 'gemini' && @@ -80,37 +90,8 @@ export function replyIsCursorUpgradeRequired(finalText: string): boolean { export type CompletedReplyClass = 'upgrade_required' | 'auth_failed' | 'agent_error'; -/** agy's own banner for a turn its provider call failed. */ -const AGY_TERMINATED_RE = /Agent execution terminated due to error/i; -/** - * Google credential failures as agy relays them: an invalid/expired API key - * ("API key not valid", reason API_KEY_INVALID / "API key expired") or a dead - * OAuth token (401 UNAUTHENTICATED "invalid authentication credentials", a - * refresh that fails "invalid_grant" / "Token has been expired or revoked"). - */ -const AGY_AUTH_RE = - /API key not valid|API_KEY_INVALID|API key expired|invalid authentication credentials|\bUNAUTHENTICATED\b|token has been expired or revoked|\binvalid_grant\b|authentication required/i; -/** - * agy's notice is the banner plus the provider's message (a Google error body - * can carry a details array), so the bound is wider than replyIsAuthFailure's - * 200 — the required banner keeps a substantive reply from matching. - */ -const AGY_ERROR_REPLY_MAX = 1000; - -/** - * Antigravity (`agy acp`) reports a provider failure as a NORMAL agent message - * chunk — "⚠ Agent execution terminated due to error." plus the provider's - * detail — followed by a clean `end_turn`, never a JSON-RPC error, so no - * throw/exit failure path sees it (codeagent-6bg9). Requires agy's banner and - * a short reply, so a reply that merely discusses an API-key error is never - * misclassified. - */ -export function classifyAntigravityReply(finalText: string): 'auth_failed' | 'agent_error' | null { - const t = finalText.trim(); - if (t.length === 0 || t.length > AGY_ERROR_REPLY_MAX) return null; - if (!AGY_TERMINATED_RE.test(t)) return null; - return AGY_AUTH_RE.test(t) ? 'auth_failed' : 'agent_error'; -} +/** What a completed-reply classifier may need beyond the text (agy reads its own log). */ +export type CompletedReplyContext = AntigravityReplyContext; /** True when a startup failure is Gemini's post-2026-06-18 free-tier death. */ function isGeminiIneligibleTier(haystack: string): boolean { diff --git a/apps/cli/src/agents/acp/command-handlers.ts b/apps/cli/src/agents/acp/command-handlers.ts index 32e2843b..1b941a14 100644 --- a/apps/cli/src/agents/acp/command-handlers.ts +++ b/apps/cli/src/agents/acp/command-handlers.ts @@ -119,6 +119,7 @@ import { replyIsHouseAgentLimit, } from './failure-messages'; import { agentHooks } from './agent-hooks'; +import { currentAgentEnv } from '../current-agent-env'; import { reportCredentialInvalid } from './backend-reports'; import type { AcpHistory, AcpRunnerOptions, StreamingState } from './runner'; @@ -1059,6 +1060,7 @@ async function runStartTaskTurn( // that CLOBBERS the already-delivered reply (2026-08-04, codeagent-dtz7). let turnClosed = false; try { + const turnStartedAt = Date.now(); const reply = await promptWithContextFallback(ctx, client, turnBlocks, turnPieces); // Close with interactive-detection so a trailing // "question + numbered options" pattern in the reply gets @@ -1071,7 +1073,11 @@ async function runStartTaskTurn( // Insufficient credits"). Record the structured external/house marker // BEFORE the classification chain below — observation only, no return. noteProviderBillingSignal({ text: finalText, source: 'reply', agent: opts.agent }); - const agentReplyClass = agentHooks(opts.agent)?.classifyCompletedReply?.(finalText) ?? null; + const agentReplyClass = + agentHooks(opts.agent)?.classifyCompletedReply?.(finalText, { + turnStartedAt, + env: currentAgentEnv(), + }) ?? null; if (agentReplyClass === 'upgrade_required') { // Cursor's OWN plan paywall ("Upgrade your plan to continue"): the // user's Cursor account is on Free, which doesn't include the headless diff --git a/apps/cli/src/agents/acp/failure-messages.ts b/apps/cli/src/agents/acp/failure-messages.ts index 3393581c..986e92b3 100644 --- a/apps/cli/src/agents/acp/failure-messages.ts +++ b/apps/cli/src/agents/acp/failure-messages.ts @@ -304,7 +304,7 @@ export function completedReplyFailureBubble(opts: { if (replyIsByoProviderBilling(opts.finalText, env)) { return byoProviderBillingMessage(byoProviderName({ env, agent: opts.agent })); } - const agentReplyClass = agentHooks(opts.agent)?.classifyCompletedReply?.(opts.finalText); + const agentReplyClass = agentHooks(opts.agent)?.classifyCompletedReply?.(opts.finalText, { env }); if (agentReplyClass === 'auth_failed' || replyIsAuthFailure(opts.finalText)) { return AUTH_FAILURE_MESSAGE; } diff --git a/apps/cli/src/agents/antigravity/reply-failure.ts b/apps/cli/src/agents/antigravity/reply-failure.ts new file mode 100644 index 00000000..67bb2f02 --- /dev/null +++ b/apps/cli/src/agents/antigravity/reply-failure.ts @@ -0,0 +1,177 @@ +/** + * Classify an Antigravity (`agy acp`) turn that "completed" with agy's failure + * banner (codeagent-6bg9). + * + * agy never reports a provider failure as a JSON-RPC error. Captured live on a + * CodeAgent Box (agy 1.5.2, GEMINI_API_KEY = an invalid key, 2026-11-06): the + * ONLY thing streamed is the agent_message_chunk + * `⚠ Agent execution terminated due to error.` and `session/prompt` resolves + * `{"stopReason":"end_turn"}` — nothing on stderr, no cause in the reply. The + * cause lives only in agy's own log, + * `~/.gemini/antigravity-cli/log/acp-server--.log`: + * + * E1106 18:16:15.662983 233 errorreport.go:247] agent executor error: generating and + * executing: Error 400, Message: API key not valid. Please pass a valid API key., + * Status: INVALID_ARGUMENT, Details: [... reason:API_KEY_INVALID ...] + * + * So on a banner reply we read the TAIL of the newest acp-server log under the + * agy process's HOME (sync, bounded, only on a failed turn) and decide auth vs + * generic from the error entries written since the turn started. + * + * A missing or expired-and-unrefreshable credential fails EARLIER, at + * `session/new` (`-32000 Authentication required`), on the existing startup + * path — this only matters for a credential that goes bad mid-session. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { log } from '../../services/logger'; + +/** agy's own banner for a turn its provider call failed. */ +const AGY_TERMINATED_RE = /Agent execution terminated due to error/i; +/** + * agy's notice is the banner (plus, possibly in a future agy, the provider's + * message). The bound keeps a substantive reply that merely QUOTES the banner + * from being classified; the required banner keeps a reply that merely + * discusses an API-key error from matching. + */ +const AGY_ERROR_REPLY_MAX = 1000; +/** Google credential failures, as agy logs them (or might one day stream them). */ +const AUTH_CAUSE_RE = new RegExp( + [ + 'API_KEY_INVALID', + 'API key not valid', + 'API key expired', + '\\bUNAUTHENTICATED\\b', + 'invalid authentication credentials', + '(?:Error|code|status)[:\\s]*401\\b', + '\\binvalid_grant\\b', + 'Token has been expired or revoked', + // PERMISSION_DENIED is auth only with a credential reason (a blocked or + // unregistered key, an under-scoped token) — not e.g. a disabled API. + 'PERMISSION_DENIED[^\\n]{0,400}?(?:API_KEY_\\w+|ACCESS_TOKEN_\\w+|CREDENTIALS_MISSING|CONSUMER_INVALID|unregistered callers)', + ].join('|'), + 'i', +); + +/** Bytes of the log tail inspected — the failing turn's entries are at the end. */ +export const AGY_LOG_TAIL_BYTES = 16 * 1024; +/** Clock slack between the turn start (our clock) and agy's log timestamps. */ +const SINCE_SLACK_MS = 2_000; +/** glog prefix: `E1106 18:16:15.662983 …` — severity, MMDD, local time. */ +const GLOG_PREFIX_RE = /^([IWEF])(\d{2})(\d{2}) (\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?\s/; + +export function antigravityLogDir(home: string): string { + return path.join(home, '.gemini', 'antigravity-cli', 'log'); +} + +function newestAcpServerLog(home: string): string | null { + const dir = antigravityLogDir(home); + let best: { file: string; mtime: number } | null = null; + for (const name of fs.readdirSync(dir)) { + if (!/^acp-server-.*\.log$/.test(name)) continue; + const file = path.join(dir, name); + const mtime = fs.statSync(file).mtimeMs; + if (!best || mtime > best.mtime) best = { file, mtime }; + } + return best?.file ?? null; +} + +function readTail(file: string, bytes: number): string { + const fd = fs.openSync(file, 'r'); + try { + const size = fs.fstatSync(fd).size; + const start = Math.max(0, size - bytes); + const buf = Buffer.alloc(size - start); + fs.readSync(fd, buf, 0, buf.length, start); + return buf.toString('utf8'); + } finally { + fs.closeSync(fd); + } +} + +/** glog timestamps carry no year: take the current one, or last year when that lands in the future. */ +function glogTime(m: RegExpExecArray, now: number): number { + const year = new Date(now).getFullYear(); + const ms = Number((m[7] ?? '0').padEnd(3, '0').slice(0, 3)); + const at = (y: number): number => + new Date(y, Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), Number(m[6]), ms).getTime(); + const t = at(year); + return t > now + 86_400_000 ? at(year - 1) : t; +} + +/** + * The ERROR/FATAL entries (with their continuation lines) in `tail`, keeping + * only those stamped at/after `since` when given. A line before the first + * parseable prefix (the cut at the tail's start) belongs to an unknown entry + * and is dropped. + */ +export function recentErrorEntries(tail: string, since: number | undefined, now = Date.now()): string[] { + const out: string[] = []; + let keep = false; + for (const line of tail.split('\n')) { + const m = GLOG_PREFIX_RE.exec(line); + if (m) { + const severe = m[1] === 'E' || m[1] === 'F'; + keep = severe && (since === undefined || glogTime(m, now) >= since - SINCE_SLACK_MS); + if (keep) out.push(line); + } else if (keep && out.length > 0) { + out[out.length - 1] += `\n${line}`; + } + } + return out; +} + +/** Mask credential-shaped tokens (Google API keys, OAuth access/refresh tokens). */ +export function redactCredentials(text: string): string { + return text + .replace(/AIza[0-9A-Za-z_-]{10,}/g, 'AIza…[redacted]') + .replace(/ya29\.[0-9A-Za-z._-]+/g, 'ya29.…[redacted]') + .replace(/1\/\/[0-9A-Za-z._-]{10,}/g, '1//…[redacted]'); +} + +/** The newest agy error entry since the turn started that names a credential failure. */ +function authCauseFromLog(home: string, since: number | undefined): string | null { + try { + const file = newestAcpServerLog(home); + if (!file) return null; + const entries = recentErrorEntries(readTail(file, AGY_LOG_TAIL_BYTES), since); + for (let i = entries.length - 1; i >= 0; i--) { + if (AUTH_CAUSE_RE.test(entries[i])) return entries[i]; + } + return null; + } catch { + // No log dir / unreadable → we can't tell; fall back to the generic frame. + return null; + } +} + +export interface AntigravityReplyContext { + /** Epoch ms the failing turn's prompt was sent — scopes the log scan. */ + turnStartedAt?: number; + /** Env the agy process runs with (its HOME locates the log). */ + env?: NodeJS.ProcessEnv; +} + +/** + * `'auth_failed'` → re-auth bubble + reportCredentialInvalid; `'agent_error'` + * → the honest generic failure frame; `null` → a real reply. + */ +export function classifyAntigravityReply( + finalText: string, + ctx: AntigravityReplyContext = {}, +): 'auth_failed' | 'agent_error' | null { + const t = finalText.trim(); + if (t.length === 0 || t.length > AGY_ERROR_REPLY_MAX) return null; + if (!AGY_TERMINATED_RE.test(t)) return null; + // Cheap first check: the cause in the reply itself. + if (AUTH_CAUSE_RE.test(t)) return 'auth_failed'; + const home = ctx.env?.HOME || os.homedir(); + const cause = authCauseFromLog(home, ctx.turnStartedAt); + if (!cause) return 'agent_error'; + log.info( + 'acpRunner', + `antigravity: credential failure in agy log — ${redactCredentials(cause).slice(0, 300)}`, + ); + return 'auth_failed'; +}