From 0346b706ff626975ce12a59088b5401543e01b23 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 13 Mar 2026 18:53:21 -0700 Subject: [PATCH 1/5] freebuff: Allow New Zealand --- web/src/app/api/v1/chat/completions/_post.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index d77b06292a..bf36ae417f 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -68,7 +68,7 @@ import { extractApiKeyFromHeader } from '@/util/auth' const FREE_MODE_ALLOWED_COUNTRIES = new Set([ 'US', 'CA', - 'GB', 'AU', + 'GB', 'AU', 'NZ', 'NO', 'SE', 'NL', 'DK', 'DE', 'FI', 'BE', 'LU', 'CH', 'IE', 'IS', ]) From 098c79ffbf4cf67e1a9b9688a43c7d241e45b879 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 13 Mar 2026 19:49:52 -0700 Subject: [PATCH 2/5] Add /interview command! --- cli/src/commands/command-registry.ts | 26 +++++++++++++++++++++++++- cli/src/commands/prompt-builders.ts | 16 ++++++++++++++++ cli/src/commands/router.ts | 18 +++++++++++++++++- cli/src/data/slash-commands.ts | 5 +++++ cli/src/utils/input-modes.ts | 11 +++++++++++ 5 files changed, 74 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/command-registry.ts b/cli/src/commands/command-registry.ts index da423000c3..0732ed3b7c 100644 --- a/cli/src/commands/command-registry.ts +++ b/cli/src/commands/command-registry.ts @@ -3,7 +3,7 @@ import { CLAUDE_OAUTH_ENABLED } from '@codebuff/common/constants/claude-oauth' import open from 'open' import { handleAdsEnable, handleAdsDisable } from './ads' -import { buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders' +import { buildInterviewPrompt, buildPlanPrompt, buildReviewPromptFromArgs } from './prompt-builders' import { useThemeStore } from '../hooks/use-theme' import { handleHelpCommand } from './help' import { handleImageCommand } from './image' @@ -572,6 +572,30 @@ const ALL_COMMANDS: CommandDefinition[] = [ useChatStore.getState().setInputMode('plan') }, }), + defineCommandWithArgs({ + name: 'interview', + handler: (params, args) => { + const trimmedArgs = args.trim() + + params.saveToHistory(params.inputValue.trim()) + clearInput(params) + + // If user provided text directly, send it immediately + if (trimmedArgs) { + params.sendMessage({ + content: buildInterviewPrompt(trimmedArgs), + agentMode: params.agentMode, + }) + setTimeout(() => { + params.scrollToLatest() + }, 0) + return + } + + // Otherwise enter interview mode + useChatStore.getState().setInputMode('interview') + }, + }), defineCommandWithArgs({ name: 'review', handler: (params, args) => { diff --git a/cli/src/commands/prompt-builders.ts b/cli/src/commands/prompt-builders.ts index 81817b0281..805d286e8c 100644 --- a/cli/src/commands/prompt-builders.ts +++ b/cli/src/commands/prompt-builders.ts @@ -22,6 +22,22 @@ export function buildPlanPrompt(input: string): string { return `${PLAN_BASE_PROMPT}\n\n${trimmedInput}` } +// Base prompt for interview command - asks clarifying questions before acting +export const INTERVIEW_BASE_PROMPT = 'Interview me to better understand my request and then create a spec file. First, gather any relevant context (read files, do research, etc.). Then, use several rounds of the ask_user tool to ask non-obvious clarifying questions — things you cannot easily infer from the codebase or my initial message. Ask about edge cases, preferences, constraints, and design decisions. All questions should be directed through the ask_user tool -- not written out as text. Keep coming up with new questions that get at unique aspects of the request. Aim for at least **3 rounds** with multiple questions each round. When satisfied, write a [INSERT_REQUEST_SHORT_NAME]-spec.md file with all the information you have gathered about the request. Aim for as much detail as possible. You should NOT make any code changes yet. Stop after creating the spec file. End by using the suggest_followups tool with ways to flesh out the spec file. Here is my request:' + +/** + * Build an interview prompt from user input. + * @param input - The user's request to be interviewed about + * @returns The full prompt to send to the agent + */ +export function buildInterviewPrompt(input: string): string { + const trimmedInput = input.trim() + if (!trimmedInput) { + return INTERVIEW_BASE_PROMPT + } + return `${INTERVIEW_BASE_PROMPT}\n\n${trimmedInput}` +} + /** * Review scope presets for the review screen. */ diff --git a/cli/src/commands/router.ts b/cli/src/commands/router.ts index 64cd0d9096..126531e09d 100644 --- a/cli/src/commands/router.ts +++ b/cli/src/commands/router.ts @@ -19,7 +19,7 @@ import { } from './router-utils' import { handleClaudeAuthCode } from '../components/claude-connect-banner' import { handleChatGptAuthCode } from '../components/chatgpt-connect-banner' -import { buildPlanPrompt, buildReviewPrompt } from './prompt-builders' +import { buildInterviewPrompt, buildPlanPrompt, buildReviewPrompt } from './prompt-builders' import { getProjectRoot } from '../project-files' import { useChatStore } from '../state/chat-store' import { trackEvent } from '../utils/analytics' @@ -328,6 +328,22 @@ export async function routeUserPrompt( return } + // Handle interview mode input + if (inputMode === 'interview') { + if (!trimmed) return + saveToHistory(trimmed) + setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + setInputMode('default') + setInputFocused(true) + inputRef.current?.focus() + + sendMessage({ content: buildInterviewPrompt(trimmed), agentMode }) + setTimeout(() => { + scrollToLatest() + }, 0) + return + } + // Handle review mode input if (inputMode === 'review') { if (!trimmed) return diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 8382afc066..283e8195ee 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -133,6 +133,11 @@ const ALL_SLASH_COMMANDS: SlashCommand[] = [ label: 'review', description: 'Review code changes with GPT 5.4', }, + { + id: 'interview', + label: 'interview', + description: 'AI asks a series of questions to flesh out request into a spec', + }, { id: 'new', label: 'new', diff --git a/cli/src/utils/input-modes.ts b/cli/src/utils/input-modes.ts index 7bcd351993..3b96ded5bf 100644 --- a/cli/src/utils/input-modes.ts +++ b/cli/src/utils/input-modes.ts @@ -11,6 +11,7 @@ export type InputMode = | 'homeDir' | 'plan' | 'review' + | 'interview' | 'referral' | 'usage' | 'image' @@ -82,6 +83,16 @@ export const INPUT_MODE_CONFIGS: Record = { disableSlashSuggestions: false, blockKeyboardExit: false, }, + interview: { + icon: null, + label: 'Interview', + color: 'info', + placeholder: 'describe a feature/bug or other request to be fleshed out...', + widthAdjustment: 12, + showAgentModeToggle: false, + disableSlashSuggestions: true, + blockKeyboardExit: false, + }, plan: { icon: null, label: 'Plan', From f207306524ce77cab5e158e7969cbba2fb673788 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 02:52:02 +0000 Subject: [PATCH 3/5] Bump Freebuff version to 0.0.13 --- freebuff/cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 0cdb664069..4ce60b9bf5 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.12", + "version": "0.0.13", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { From 476bfd7de04762076a2ba6a386c57dd90efacf22 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 13 Mar 2026 21:52:30 -0700 Subject: [PATCH 4/5] Show better error message if someone uses freebuff in unsupported country --- .../utils/__tests__/error-handling.test.ts | 46 +++++++ sdk/src/__tests__/run-cancellation.test.ts | 115 ++++++++++++++++++ sdk/src/run.ts | 27 +++- 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/cli/src/utils/__tests__/error-handling.test.ts b/cli/src/utils/__tests__/error-handling.test.ts index bd74b95a59..7fafccb484 100644 --- a/cli/src/utils/__tests__/error-handling.test.ts +++ b/cli/src/utils/__tests__/error-handling.test.ts @@ -2,7 +2,9 @@ import { describe, test, expect } from 'bun:test' import { isOutOfCreditsError, + isFreeModeUnavailableError, OUT_OF_CREDITS_MESSAGE, + FREE_MODE_UNAVAILABLE_MESSAGE, createErrorMessage, } from '../error-handling' @@ -66,6 +68,50 @@ describe('error-handling', () => { }) }) + describe('isFreeModeUnavailableError', () => { + test('returns true for error with statusCode 403 and error free_mode_unavailable', () => { + const error = { statusCode: 403, error: 'free_mode_unavailable', message: 'Free mode is not available in your country.' } + expect(isFreeModeUnavailableError(error)).toBe(true) + }) + + test('returns false for 403 without error field', () => { + const error = { statusCode: 403, message: 'Forbidden' } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for 403 with different error code', () => { + const error = { statusCode: 403, error: 'account_suspended', message: 'Suspended' } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for non-403 status with free_mode_unavailable error', () => { + const error = { statusCode: 400, error: 'free_mode_unavailable', message: 'Bad request' } + expect(isFreeModeUnavailableError(error)).toBe(false) + }) + + test('returns false for null', () => { + expect(isFreeModeUnavailableError(null)).toBe(false) + }) + + test('returns false for undefined', () => { + expect(isFreeModeUnavailableError(undefined)).toBe(false) + }) + + test('returns false for plain Error object', () => { + expect(isFreeModeUnavailableError(new Error('Forbidden'))).toBe(false) + }) + }) + + describe('FREE_MODE_UNAVAILABLE_MESSAGE', () => { + test('mentions free mode', () => { + expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('free mode') + }) + + test('mentions paid plan', () => { + expect(FREE_MODE_UNAVAILABLE_MESSAGE.toLowerCase()).toContain('paid plan') + }) + }) + describe('OUT_OF_CREDITS_MESSAGE', () => { test('contains usage URL', () => { expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage') diff --git a/sdk/src/__tests__/run-cancellation.test.ts b/sdk/src/__tests__/run-cancellation.test.ts index 9ebfbb8614..ad121c75f2 100644 --- a/sdk/src/__tests__/run-cancellation.test.ts +++ b/sdk/src/__tests__/run-cancellation.test.ts @@ -184,6 +184,121 @@ describe('Run Cancellation Handling', () => { expect(messageHistory.length).toBe(3) }) + it('extracts error code and message from AI SDK responseBody on 403', async () => { + spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + discord_id: null, + referral_code: null, + stripe_customer_id: null, + banned: false, + }) + spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) + spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') + spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined) + spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1') + + // Simulate AI SDK's AI_APICallError with responseBody (what the server returns for free_mode_unavailable) + const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string } + apiError.statusCode = 403 + apiError.responseBody = JSON.stringify({ + error: 'free_mode_unavailable', + message: 'Free mode is not available in your country.', + }) + + spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError) + + const client = new CodebuffClient({ + apiKey: 'test-key', + }) + + const result = await client.run({ + agent: 'base2', + prompt: 'hello', + }) + + expect(result.output.type).toBe('error') + const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string } + // Should use the message from the response body, not the generic "Forbidden" + expect(output.message).toBe('Free mode is not available in your country.') + expect(output.statusCode).toBe(403) + // Should propagate the error code so isFreeModeUnavailableError can match + expect(output.error).toBe('free_mode_unavailable') + }) + + it('extracts error code from responseBody for account_suspended 403', async () => { + spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + discord_id: null, + referral_code: null, + stripe_customer_id: null, + banned: false, + }) + spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) + spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') + spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined) + spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1') + + const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string } + apiError.statusCode = 403 + apiError.responseBody = JSON.stringify({ + error: 'account_suspended', + message: 'Your account has been suspended due to billing issues.', + }) + + spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError) + + const client = new CodebuffClient({ + apiKey: 'test-key', + }) + + const result = await client.run({ + agent: 'base2', + prompt: 'hello', + }) + + const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string } + expect(output.message).toBe('Your account has been suspended due to billing issues.') + expect(output.statusCode).toBe(403) + expect(output.error).toBe('account_suspended') + }) + + it('falls back to error.message when responseBody is not valid JSON', async () => { + spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ + id: 'user-123', + email: 'test@example.com', + discord_id: null, + referral_code: null, + stripe_customer_id: null, + banned: false, + }) + spyOn(databaseModule, 'fetchAgentFromDatabase').mockResolvedValue(null) + spyOn(databaseModule, 'startAgentRun').mockResolvedValue('run-1') + spyOn(databaseModule, 'finishAgentRun').mockResolvedValue(undefined) + spyOn(databaseModule, 'addAgentStep').mockResolvedValue('step-1') + + const apiError = new Error('Forbidden') as Error & { statusCode: number; responseBody: string } + apiError.statusCode = 403 + apiError.responseBody = 'not valid json' + + spyOn(mainPromptModule, 'callMainPrompt').mockRejectedValue(apiError) + + const client = new CodebuffClient({ + apiKey: 'test-key', + }) + + const result = await client.run({ + agent: 'base2', + prompt: 'hello', + }) + + const output = result.output as { type: 'error'; message: string; statusCode?: number; error?: string } + expect(output.message).toBe('Forbidden') + expect(output.statusCode).toBe(403) + expect(output.error).toBeUndefined() + }) + it('preserves user message when callMainPrompt throws an error', async () => { spyOn(databaseModule, 'getUserInfoFromApiKey').mockResolvedValue({ id: 'user-123', diff --git a/sdk/src/run.ts b/sdk/src/run.ts index 4db516a479..13b6562624 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -510,15 +510,40 @@ async function runOnce({ userId, signal: signal ?? new AbortController().signal, }).catch((error) => { - const errorMessage = + let errorMessage = error instanceof Error ? error.message : String(error ?? '') const statusCode = getErrorStatusCode(error) + + // Extract structured error details from the API response body + // (e.g., AI SDK's AI_APICallError includes a responseBody with the server's JSON response) + let errorCode: string | undefined + const responseBody = + error && typeof error === 'object' && 'responseBody' in error + ? (error as { responseBody: unknown }).responseBody + : undefined + if (typeof responseBody === 'string') { + try { + const parsed: unknown = JSON.parse(responseBody) + if (parsed && typeof parsed === 'object') { + if ('error' in parsed && typeof (parsed as { error: unknown }).error === 'string') { + errorCode = (parsed as { error: string }).error + } + if ('message' in parsed && typeof (parsed as { message: unknown }).message === 'string') { + errorMessage = (parsed as { message: string }).message + } + } + } catch { + // responseBody wasn't valid JSON; keep original errorMessage + } + } + resolve({ sessionState: getCancelledSessionState(errorMessage), output: { type: 'error', message: errorMessage, ...(statusCode !== undefined && { statusCode }), + ...(errorCode !== undefined && { error: errorCode }), }, }) }) From 85d963b0b23509e7a25a821df5b5d88bf29d9cfe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 14 Mar 2026 04:55:14 +0000 Subject: [PATCH 5/5] Bump Freebuff version to 0.0.14 --- freebuff/cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 4ce60b9bf5..c893ed5cab 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.13", + "version": "0.0.14", "description": "The world's strongest free coding agent", "license": "MIT", "bin": {