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}`);
}