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
82 changes: 82 additions & 0 deletions packages/core/src/core/loggingContentGenerator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,27 @@ describe('LoggingContentGenerator', () => {
});
});
});

it('should NOT log error on AbortError (user cancellation)', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
vi.mocked(wrapped.generateContent).mockRejectedValue(abortError);

await expect(
loggingContentGenerator.generateContent(
req,
userPromptId,
LlmRole.MAIN,
),
).rejects.toThrow(abortError);

expect(logApiError).not.toHaveBeenCalled();
});
});

describe('generateContentStream', () => {
Expand Down Expand Up @@ -462,6 +483,67 @@ describe('LoggingContentGenerator', () => {
expect(errorEvent.duration_ms).toBe(1000);
});

it('should NOT log error on AbortError during connection phase', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
vi.mocked(wrapped.generateContentStream).mockRejectedValue(abortError);

await expect(
loggingContentGenerator.generateContentStream(
req,
userPromptId,
LlmRole.MAIN,
),
).rejects.toThrow(abortError);

expect(logApiError).not.toHaveBeenCalled();
});

it('should NOT log error on AbortError during stream iteration', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';

async function* createAbortingGenerator() {
yield {
candidates: [],
text: undefined,
functionCalls: undefined,
executableCode: undefined,
codeExecutionResult: undefined,
data: undefined,
} as unknown as GenerateContentResponse;
throw abortError;
}

vi.mocked(wrapped.generateContentStream).mockResolvedValue(
createAbortingGenerator(),
);

const stream = await loggingContentGenerator.generateContentStream(
req,
userPromptId,
LlmRole.MAIN,
);

await expect(async () => {
for await (const _ of stream) {
// consume stream
}
}).rejects.toThrow(abortError);

expect(logApiError).not.toHaveBeenCalled();
});

it('should set latest API request in config for main agent requests', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/core/loggingContentGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { toContents } from '../code_assist/converter.js';
import { isStructuredError } from '../utils/quotaErrorDetection.js';
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getErrorType } from '../utils/errors.js';
import { isAbortError, getErrorType } from '../utils/errors.js';
import {
GeminiCliOperation,
GEN_AI_PROMPT_NAME,
Expand Down Expand Up @@ -310,6 +310,10 @@ export class LoggingContentGenerator implements ContentGenerator {
generationConfig?: GenerateContentConfig,
serverDetails?: ServerDetails,
): void {
if (isAbortError(error)) {
// Don't log aborted requests (e.g., user cancellation, internal timeouts) as API errors.
return;
}
const errorMessage = error instanceof Error ? error.message : String(error);
const errorType = getErrorType(error);

Expand Down
24 changes: 24 additions & 0 deletions packages/core/src/utils/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest';
import {
isAuthenticationError,
isAbortError,
UnauthorizedError,
toFriendlyError,
BadRequestError,
Expand Down Expand Up @@ -48,6 +49,29 @@ describe('getErrorMessage', () => {
});
});

describe('isAbortError', () => {
it('should return true for AbortError', () => {
const error = new Error('Aborted');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});

it('should return true for DOMException AbortError', () => {
const error = new DOMException('Aborted', 'AbortError');
expect(isAbortError(error)).toBe(true);
});

it('should return false for other errors', () => {
expect(isAbortError(new Error('Other error'))).toBe(false);
});

it('should return false for non-error objects', () => {
expect(isAbortError({ name: 'AbortError' })).toBe(false);
expect(isAbortError(null)).toBe(false);
expect(isAbortError('AbortError')).toBe(false);
});
});

describe('isAuthenticationError', () => {
it('should detect error with code: 401 property (MCP SDK style)', () => {
const error = { code: 401, message: 'Unauthorized' };
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/utils/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}

/**
* Checks if an error is an AbortError.
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}

export function getErrorMessage(error: unknown): string {
const friendlyError = toFriendlyError(error);
if (friendlyError instanceof Error) {
Expand Down
Loading