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
26 changes: 25 additions & 1 deletion cli/src/commands/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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) => {
Expand Down
16 changes: 16 additions & 0 deletions cli/src/commands/prompt-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
18 changes: 17 additions & 1 deletion cli/src/commands/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions cli/src/data/slash-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
46 changes: 46 additions & 0 deletions cli/src/utils/__tests__/error-handling.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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')
Expand Down
11 changes: 11 additions & 0 deletions cli/src/utils/input-modes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type InputMode =
| 'homeDir'
| 'plan'
| 'review'
| 'interview'
| 'referral'
| 'usage'
| 'image'
Expand Down Expand Up @@ -82,6 +83,16 @@ export const INPUT_MODE_CONFIGS: Record<InputMode, InputModeConfig> = {
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',
Expand Down
2 changes: 1 addition & 1 deletion freebuff/cli/release/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "freebuff",
"version": "0.0.12",
"version": "0.0.14",
"description": "The world's strongest free coding agent",
"license": "MIT",
"bin": {
Expand Down
115 changes: 115 additions & 0 deletions sdk/src/__tests__/run-cancellation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 26 additions & 1 deletion sdk/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
},
})
})
Expand Down
2 changes: 1 addition & 1 deletion web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
])

Expand Down
Loading