diff --git a/apps/web/src/app/api/internal/auto-fix-status/[ticketId]/route.ts b/apps/web/src/app/api/internal/auto-fix-status/[ticketId]/route.ts index d0450e7471..38cda760bc 100644 --- a/apps/web/src/app/api/internal/auto-fix-status/[ticketId]/route.ts +++ b/apps/web/src/app/api/internal/auto-fix-status/[ticketId]/route.ts @@ -20,6 +20,7 @@ import { logExceptInTest, errorExceptInTest } from '@/lib/utils.server'; import { captureException, captureMessage } from '@sentry/nextjs'; import { INTERNAL_API_SECRET } from '@/lib/config.server'; import type { FixStatus } from '@/lib/auto-fix/core/schemas'; +import { formatAutoFixErrorMessage } from '@/lib/auto-fix/core/format-error-message'; interface StatusUpdatePayload { sessionId?: string; @@ -57,6 +58,7 @@ export async function POST( cliSessionId, status, hasError: !!errorMessage, + errorMessage, }); // Get current ticket to check if update is needed @@ -95,7 +97,8 @@ export async function POST( await updateFixTicketStatus(ticketId, status, { sessionId, cliSessionId, - errorMessage, + errorMessage: + errorMessage !== undefined ? formatAutoFixErrorMessage(errorMessage) : undefined, startedAt: status === 'running' ? new Date() : undefined, completedAt: status === 'completed' || status === 'failed' || status === 'cancelled' diff --git a/apps/web/src/app/api/internal/auto-fix/pr-callback/route.ts b/apps/web/src/app/api/internal/auto-fix/pr-callback/route.ts index db5d048698..55a336074e 100644 --- a/apps/web/src/app/api/internal/auto-fix/pr-callback/route.ts +++ b/apps/web/src/app/api/internal/auto-fix/pr-callback/route.ts @@ -37,6 +37,7 @@ import { sanitizePublicErrorMessage, } from '@/lib/auto-fix/github/handle-comment-reply'; import { handleCreateIssuePR } from '@/lib/auto-fix/github/handle-create-issue-pr'; +import { formatAutoFixErrorMessage } from '@/lib/auto-fix/core/format-error-message'; import { z } from 'zod'; const callbackStatusEnum = z.enum(['completed', 'failed', 'interrupted']); @@ -167,6 +168,10 @@ export async function POST(req: NextRequest) { // Handle failure/interruption if (status === 'failed' || status === 'interrupted') { + const failureMessage = formatAutoFixErrorMessage( + errorMessage || `Auto-fix execution ${status}` + ); + logExceptInTest('[auto-fix-pr-callback] Auto-fix execution failed', { ticketId, sessionId, @@ -179,7 +184,7 @@ export async function POST(req: NextRequest) { ticketId, sessionId, outcome: 'failed', - errorMessage: errorMessage || `Auto-fix execution ${status}`, + errorMessage: failureMessage, }); if (!replyResult.ok) { @@ -192,7 +197,7 @@ export async function POST(req: NextRequest) { } else { // Update ticket to failed await updateFixTicketStatus(ticketId, 'failed', { - errorMessage: errorMessage || `Auto-fix execution ${status}`, + errorMessage: failureMessage, completedAt: new Date(), }); } @@ -212,7 +217,7 @@ export async function POST(req: NextRequest) { await postIssueComment({ repoFullName: ticket.repo_full_name, issueNumber: ticket.issue_number, - body: `🤖 **Auto-Fix Update**\n\nI attempted to create a pull request to fix this issue, but encountered an error:\n\n\`\`\`\n${sanitizePublicErrorMessage(errorMessage || 'Unknown error')}\n\`\`\`\n\nThis issue may require manual attention.`, + body: `🤖 **Auto-Fix Update**\n\nI attempted to create a pull request to fix this issue, but encountered an error:\n\n\`\`\`\n${sanitizePublicErrorMessage(failureMessage)}\n\`\`\`\n\nThis issue may require manual attention.`, githubToken: tokenData.token, }); diff --git a/apps/web/src/components/auto-fix/AutoFixTicketsCard.tsx b/apps/web/src/components/auto-fix/AutoFixTicketsCard.tsx index 57aa437aa7..9c16bbce81 100644 --- a/apps/web/src/components/auto-fix/AutoFixTicketsCard.tsx +++ b/apps/web/src/components/auto-fix/AutoFixTicketsCard.tsx @@ -23,6 +23,7 @@ import { useTRPC } from '@/lib/trpc/utils'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { formatDistanceToNow } from 'date-fns'; import { toast } from 'sonner'; +import { formatAutoFixErrorMessage } from '@/lib/auto-fix/core/format-error-message'; type AutoFixTicketsCardProps = { organizationId?: string; @@ -476,7 +477,7 @@ export function AutoFixTicketsCard({ organizationId }: AutoFixTicketsCardProps) {/* Error Message */} {ticket.error_message && (
- Error: {ticket.error_message} + Error: {formatAutoFixErrorMessage(ticket.error_message)}
)} diff --git a/apps/web/src/lib/auto-fix/core/format-error-message.test.ts b/apps/web/src/lib/auto-fix/core/format-error-message.test.ts new file mode 100644 index 0000000000..da1978f806 --- /dev/null +++ b/apps/web/src/lib/auto-fix/core/format-error-message.test.ts @@ -0,0 +1,45 @@ +import { + AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE, + formatAutoFixErrorMessage, + isAutoFixBillingErrorMessage, +} from './format-error-message'; + +describe('formatAutoFixErrorMessage', () => { + it('maps raw initiate 402 payloads to a clear credits message', () => { + const raw = + 'initiateFromKilocodeSessionV2 failed (402): {"error":{"message":"Insufficient credits: $1 minimum required","code":-32002,"data":{"code":"PAYMENT_REQUIRED","httpStatus":402}}}'; + + expect(formatAutoFixErrorMessage(raw)).toBe(AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE); + }); + + it('maps short insufficient-credits text', () => { + expect(formatAutoFixErrorMessage('Insufficient credits: $1 minimum required')).toBe( + AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE + ); + }); + + it('leaves unrelated errors unchanged', () => { + expect(formatAutoFixErrorMessage('Failed to get PR config: 500 - boom')).toBe( + 'Failed to get PR config: 500 - boom' + ); + }); + + it('does not treat generic "minimum required" text as a billing error', () => { + const raw = 'Sandbox image node20 is the minimum required version'; + expect(formatAutoFixErrorMessage(raw)).toBe(raw); + }); + + it('handles empty input', () => { + expect(formatAutoFixErrorMessage(' ')).toBe('Unknown error'); + }); +}); + +describe('isAutoFixBillingErrorMessage', () => { + it('detects payment_required codes in dumped JSON', () => { + expect(isAutoFixBillingErrorMessage('{"data":{"code":"PAYMENT_REQUIRED"}}')).toBe(true); + }); + + it('returns false for non-billing errors', () => { + expect(isAutoFixBillingErrorMessage('timeout waiting for sandbox')).toBe(false); + }); +}); diff --git a/apps/web/src/lib/auto-fix/core/format-error-message.ts b/apps/web/src/lib/auto-fix/core/format-error-message.ts new file mode 100644 index 0000000000..0a16ffde90 --- /dev/null +++ b/apps/web/src/lib/auto-fix/core/format-error-message.ts @@ -0,0 +1,40 @@ +/** + * User-facing auto-fix error formatting. + * + * Raw orchestrator/tRPC failures often include status codes and JSON envelopes. + * Map known billing failures to a clear credit-minimum explanation. + */ + +import { AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE } from '@kilocode/worker-utils/cloud-agent-next-client'; + +export { AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE }; + +const BILLING_ERROR_PATTERNS = [ + 'insufficient credits', + 'payment_required', + 'payment required', + 'credits required', + 'credit balance is too low', + 'insufficient funds', + 'add credits', + 'paid model', +] as const; + +export function isAutoFixBillingErrorMessage(message: string): boolean { + const normalized = message.toLowerCase(); + return BILLING_ERROR_PATTERNS.some(pattern => normalized.includes(pattern)); +} + +/** Map raw auto-fix failure text to a concise user-facing message. */ +export function formatAutoFixErrorMessage(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return 'Unknown error'; + } + + if (isAutoFixBillingErrorMessage(trimmed)) { + return AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE; + } + + return trimmed; +} diff --git a/apps/web/src/lib/auto-fix/github/handle-comment-reply.ts b/apps/web/src/lib/auto-fix/github/handle-comment-reply.ts index a226d4f057..d53020896e 100644 --- a/apps/web/src/lib/auto-fix/github/handle-comment-reply.ts +++ b/apps/web/src/lib/auto-fix/github/handle-comment-reply.ts @@ -7,6 +7,10 @@ */ import { getFixTicketById, updateFixTicketStatus } from '@/lib/auto-fix/db/fix-tickets'; +import { + formatAutoFixErrorMessage, + isAutoFixBillingErrorMessage, +} from '@/lib/auto-fix/core/format-error-message'; import { logExceptInTest, errorExceptInTest } from '@/lib/utils.server'; import { captureException } from '@sentry/nextjs'; import { @@ -79,6 +83,14 @@ type FriendlyFailure = { function getFriendlyFailure(rawError: string): FriendlyFailure { const normalized = rawError.toLowerCase(); + if (isAutoFixBillingErrorMessage(rawError)) { + return { + summary: + 'I could not start this fix because the account needs at least $1 in available credits.', + suggestedAction: 'Add credits to your Kilo account, then retry the fix.', + }; + } + if (normalized.includes('failed to verify balance') || normalized.includes('balance check')) { return { summary: 'I could not start this fix because the account balance check failed.', @@ -312,7 +324,7 @@ export async function handleCommentReply( await updateFixTicketStatus(ticketId, 'failed', { sessionId, - errorMessage: failureReason, + errorMessage: formatAutoFixErrorMessage(failureReason), completedAt: new Date(), }); diff --git a/packages/worker-utils/src/cloud-agent-next-client.ts b/packages/worker-utils/src/cloud-agent-next-client.ts index 5bcecccff8..4f2770af5d 100644 --- a/packages/worker-utils/src/cloud-agent-next-client.ts +++ b/packages/worker-utils/src/cloud-agent-next-client.ts @@ -239,6 +239,13 @@ export const CLOUD_AGENT_NEXT_BILLING_ERROR_PATTERNS = [ 'payment required', ] as const; +/** + * User-facing message for auto-fix billing failures. + * Shared by the web app and auto-fix-infra so the copy stays in sync. + */ +export const AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE = + 'Insufficient credits. Auto-fix requires at least $1 in available credits. Add credits, then retry.'; + export function isCloudAgentNextBillingErrorBody(body: string): boolean { const normalizedBody = body.toLowerCase(); diff --git a/packages/worker-utils/src/index.ts b/packages/worker-utils/src/index.ts index 45e04b84b7..00bbc8c59c 100644 --- a/packages/worker-utils/src/index.ts +++ b/packages/worker-utils/src/index.ts @@ -26,6 +26,7 @@ export { createNotFoundHandler } from './not-found-handler.js'; export type { Owner, MCPServerConfig } from './types.js'; export { + AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE, CLOUD_AGENT_NEXT_BILLING_ERROR_PATTERNS, createCloudAgentNextFetchClient, isCloudAgentNextBillingErrorBody, diff --git a/services/auto-fix-infra/src/services/cloud-agent-next-client.ts b/services/auto-fix-infra/src/services/cloud-agent-next-client.ts index b7c7932672..087514fa6a 100644 --- a/services/auto-fix-infra/src/services/cloud-agent-next-client.ts +++ b/services/auto-fix-infra/src/services/cloud-agent-next-client.ts @@ -1,3 +1,8 @@ +import { + AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE, + isCloudAgentNextBillingErrorBody, +} from '@kilocode/worker-utils'; + type CallbackTarget = { url: string; headers?: Record; @@ -53,6 +58,9 @@ export class CloudAgentNextClient { if (!response.ok) { const errorText = await response.text(); + if (response.status === 402 || isCloudAgentNextBillingErrorBody(errorText)) { + throw new Error(AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE); + } throw new Error(`prepareSession failed (${response.status}): ${errorText}`); } @@ -93,6 +101,9 @@ export class CloudAgentNextClient { if (!response.ok) { const errorText = await response.text(); + if (response.status === 402 || isCloudAgentNextBillingErrorBody(errorText)) { + throw new Error(AUTO_FIX_INSUFFICIENT_CREDITS_MESSAGE); + } throw new Error(`initiateFromKilocodeSessionV2 failed (${response.status}): ${errorText}`); }