Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
221 changes: 221 additions & 0 deletions apps/cli/__tests__/agents/acp/antigravity-reply-failure.test.ts
Original file line number Diff line number Diff line change
@@ -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-<ts>-<pid>.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<typeof vi.fn>;
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);
});
});
35 changes: 35 additions & 0 deletions apps/cli/__tests__/agents/acp/start-task-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SquadRosterData> => ({
...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<SquadRosterData> => 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.
Expand Down
135 changes: 135 additions & 0 deletions apps/cli/__tests__/agents/antigravity/reply-failure.test.ts
Original file line number Diff line number Diff line change
@@ -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]');
});
});
Loading
Loading