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..6ca903af --- /dev/null +++ b/apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts @@ -0,0 +1,221 @@ +/** + * Regression (codeagent-6bg9) — Antigravity (`agy acp`) reports a provider / + * 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`. + * + * 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, + handleCommand, + AUTH_FAILURE_MESSAGE, + TURN_FAILURE_MESSAGE, +} from '../../../src/agents/acp/runner'; +import { + resetCurrentAgentEnvForTests, + setCurrentAgentEnv, +} from '../../../src/agents/current-agent-env'; + +vi.mock('../../../src/services/pairing.service', () => ({ + postTurnEvent: vi.fn().mockResolvedValue(undefined), + fetchCurrentPluginAuthToken: vi.fn(), + _postJsonAuthed: vi.fn(), +})); + +/** 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, onPrompt?: () => void) { + 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 () => { + // 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' }; + }), + 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(() => { + 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('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(); + 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 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(); + 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('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, () => + 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()); + }); + + 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/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/__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 fe3cdf09..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 { /** @@ -43,9 +50,15 @@ 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, + ctx?: CompletedReplyContext, + ) => CompletedReplyClass | null; /** * Classify a STARTUP-failure haystack (`detail` + recent stderr) into an * agent-specific reason. Relocates failure-messages' `agent === 'gemini' && @@ -75,6 +88,11 @@ export function replyIsCursorUpgradeRequired(finalText: string): boolean { ); } +export type CompletedReplyClass = 'upgrade_required' | '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 { return /IneligibleTierError|UNSUPPORTED_CLIENT|no longer supported for Gemini Code Assist|not eligible for Gemini Code Assist/i.test( @@ -120,6 +138,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 a1f11c44..1b941a14 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, @@ -106,6 +107,7 @@ import { AUTH_FAILURE_MESSAGE, CURSOR_UPGRADE_MESSAGE, ONE_M_CREDITS_MESSAGE, + TURN_FAILURE_MESSAGE, describeError, emptyReplyMessage, failureBubble, @@ -117,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'; @@ -493,9 +496,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)); @@ -1048,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 @@ -1060,7 +1073,12 @@ 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, { + 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 // Agent. NOT a credential problem — swap the bare text for an @@ -1117,7 +1135,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. @@ -1163,6 +1181,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..986e92b3 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, { env }); + if (agentReplyClass === 'auth_failed' || replyIsAuthFailure(opts.finalText)) { + return AUTH_FAILURE_MESSAGE; + } + if (agentReplyClass === 'agent_error') return TURN_FAILURE_MESSAGE; return null; } 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; } 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'; +}