diff --git a/agents/__tests__/base2.test.ts b/agents/__tests__/base2.test.ts new file mode 100644 index 0000000000..fe102f0326 --- /dev/null +++ b/agents/__tests__/base2.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, test } from 'bun:test' + +import { + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + FREEBUFF_MINIMAX_MODEL_ID, +} from '@codebuff/common/constants/freebuff-models' + +import { createBase2 } from '../base2/base2' + +describe('base2 reviewer selection', () => { + test.each([ + [FREEBUFF_MINIMAX_MODEL_ID, 'code-reviewer-minimax'], + [FREEBUFF_KIMI_MODEL_ID, 'code-reviewer-kimi'], + [FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 'code-reviewer-deepseek'], + ])('uses matching reviewer for model %p', (model, expectedReviewer) => { + const base2 = createBase2('free', { model }) + + expect(base2.spawnableAgents).toContain(expectedReviewer) + expect(base2.instructionsPrompt).toContain(`Spawn a ${expectedReviewer}`) + expect(base2.stepPrompt).toContain(`spawn a ${expectedReviewer}`) + }) +}) diff --git a/agents/base2/base2-free-deepseek.ts b/agents/base2/base2-free-deepseek.ts index c62aa2a8d5..6b40e34894 100644 --- a/agents/base2/base2-free-deepseek.ts +++ b/agents/base2/base2-free-deepseek.ts @@ -6,7 +6,6 @@ const definition = { ...createBase2('free', { noAskUser: true, model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, - freeCodeReviewerAgentId: 'code-reviewer-deepseek', }), id: 'base2-free-deepseek', displayName: 'Buffy the DeepSeek Free Orchestrator', diff --git a/agents/base2/base2-free-kimi.ts b/agents/base2/base2-free-kimi.ts index a769b81c47..fc31625eef 100644 --- a/agents/base2/base2-free-kimi.ts +++ b/agents/base2/base2-free-kimi.ts @@ -5,7 +5,6 @@ import { createBase2 } from './base2' const definition = { ...createBase2('free', { model: FREEBUFF_KIMI_MODEL_ID, - freeCodeReviewerAgentId: 'code-reviewer-kimi', }), id: 'base2-free-kimi', displayName: 'Buffy the Kimi Free Orchestrator', diff --git a/agents/base2/base2-free.ts b/agents/base2/base2-free.ts index ee3a4cca05..464defff24 100644 --- a/agents/base2/base2-free.ts +++ b/agents/base2/base2-free.ts @@ -1,9 +1,7 @@ import { createBase2 } from './base2' const definition = { - ...createBase2('free', { - freeCodeReviewerAgentId: 'code-reviewer-minimax', - }), + ...createBase2('free'), id: 'base2-free', displayName: 'Buffy the Free Orchestrator', } diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 18e216ebd7..f9b94b9328 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -5,6 +5,7 @@ import { FREEBUFF_GEMINI_THINKER_STEP_PROMPT, FREEBUFF_GEMINI_THINKER_SYSTEM_INSTRUCTION, } from '@codebuff/common/constants/freebuff-gemini-thinker' +import { FREEBUFF_REVIEWER_AGENT_ID_BY_MODEL } from '@codebuff/common/constants/free-agents' import { canFreebuffModelSpawnGeminiThinker, FREEBUFF_MINIMAX_MODEL_ID, @@ -24,7 +25,6 @@ export function createBase2( noAskUser?: boolean model?: SecretAgentDefinition['model'] providerOptions?: SecretAgentDefinition['providerOptions'] - freeCodeReviewerAgentId?: string }, ): Omit { const { @@ -33,7 +33,6 @@ export function createBase2( noAskUser = false, model: modelOverride, providerOptions, - freeCodeReviewerAgentId = 'code-reviewer-lite', } = options ?? {} const isDefault = mode === 'default' const isFast = mode === 'fast' @@ -56,6 +55,8 @@ export function createBase2( // reasoning. Fast MiniMax omits the extra round trip by construction. const hasFreeGeminiThinker = isFree && canFreebuffModelSpawnGeminiThinker(model) + const freeCodeReviewerAgentId = + FREEBUFF_REVIEWER_AGENT_ID_BY_MODEL[model] ?? 'code-reviewer-lite' const defaultProviderOptions = isFree ? { data_collection: 'deny' as const, diff --git a/cli/release/package.json b/cli/release/package.json index b6d6c62fa9..eca1cf503a 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.671", + "version": "1.0.673", "description": "AI coding agent", "license": "MIT", "bin": { diff --git a/cli/src/components/chat-history-screen.tsx b/cli/src/components/chat-history-screen.tsx index 5c9f256e16..01f3e03322 100644 --- a/cli/src/components/chat-history-screen.tsx +++ b/cli/src/components/chat-history-screen.tsx @@ -7,7 +7,11 @@ import { SelectableList } from './selectable-list' import { useSearchableList } from '../hooks/use-searchable-list' import { useTerminalLayout } from '../hooks/use-terminal-layout' import { useTheme } from '../hooks/use-theme' -import { getAllChats, formatRelativeTime } from '../utils/chat-history' +import { + deleteChatSession, + formatRelativeTime, + getAllChats, +} from '../utils/chat-history' import type { SelectableListItem } from './selectable-list' @@ -21,6 +25,7 @@ const LAYOUT = { MAX_RENDERED_CHATS: 100, // Only render this many in the list TIME_COL_WIDTH: 12, // e.g., "2 hours ago" MSGS_COL_WIDTH: 8, // e.g., "99 msgs" + DELETE_COL_WIDTH: 6, // e.g., "[×]" + marginRight GAP_WIDTH: 3, // gap between columns } as const @@ -42,34 +47,39 @@ export const ChatHistoryScreen: React.FC = ({ const contentWidth = terminalWidth - LAYOUT.CONTENT_PADDING // Two-phase loading: load initial chats immediately, then more in background - const initialChats = useMemo(() => getAllChats(LAYOUT.INITIAL_CHATS), []) - const [backgroundChats, setBackgroundChats] = useState( - [], - ) + const [chats, setChats] = useState(() => getAllChats(LAYOUT.INITIAL_CHATS)) + const [statusMessage, setStatusMessage] = useState(null) // Load more chats in the background after initial render useEffect(() => { // Use setTimeout to defer the expensive loading to after first paint const timer = setTimeout(() => { - const moreChats = getAllChats( - LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS, - ) - // Only keep the chats beyond the initial set - setBackgroundChats(moreChats.slice(LAYOUT.INITIAL_CHATS)) + setChats(getAllChats(LAYOUT.INITIAL_CHATS + LAYOUT.BACKGROUND_CHATS)) }, 0) return () => clearTimeout(timer) }, []) - // Combine initial and background chats - const chats = useMemo( - () => [...initialChats, ...backgroundChats], - [initialChats, backgroundChats], - ) + const handleDeleteChat = useCallback((chatId: string) => { + const deleted = deleteChatSession(chatId) + if (deleted) { + setChats((prev) => prev.filter((chat) => chat.chatId !== chatId)) + setStatusMessage('Chat deleted') + return + } + + setStatusMessage('Could not delete chat') + }, []) // Calculate available width for the prompt text (last column, variable width) - // Format: "[time] [msgs] [prompt...]" + // Format: "[time] [msgs] [prompt...] [×]" + // reservedWidth accounts for: time col, msgs col, delete button area, + // 2 gaps between columns, list border (2), scrollbar (1), and button padding (2) const reservedWidth = - LAYOUT.TIME_COL_WIDTH + LAYOUT.MSGS_COL_WIDTH + LAYOUT.GAP_WIDTH * 2 + 2 // +2 for padding + LAYOUT.TIME_COL_WIDTH + + LAYOUT.MSGS_COL_WIDTH + + LAYOUT.DELETE_COL_WIDTH + + LAYOUT.GAP_WIDTH * 2 + + 5 // border + scrollbar + button padding const maxPromptWidth = Math.max(20, contentWidth - reservedWidth) // Truncate text to fit single line @@ -81,8 +91,10 @@ export const ChatHistoryScreen: React.FC = ({ // Pad text to fixed width (right-pad with spaces) const padRight = (text: string, width: number): string => { - if (text.length >= width) return text.slice(0, width) - return text + ' '.repeat(width - text.length) + // Use Array.from to count code points so emoji/wide chars don't break padding + const len = Array.from(text).length + if (len >= width) return text + return text + ' '.repeat(width - len) } // Convert chats to SelectableListItem format with aligned columns @@ -98,7 +110,10 @@ export const ChatHistoryScreen: React.FC = ({ `${chat.messageCount} msgs`, LAYOUT.MSGS_COL_WIDTH, ) - const prompt = truncateText(chat.lastPrompt, maxPromptWidth) + const prompt = padRight( + truncateText(chat.lastPrompt, maxPromptWidth), + maxPromptWidth, + ) return { id: chat.chatId, @@ -146,6 +161,13 @@ export const ChatHistoryScreen: React.FC = ({ [onSelectChat], ) + const handleChatDelete = useCallback( + (item: SelectableListItem) => { + handleDeleteChat(item.id) + }, + [handleDeleteChat], + ) + // Handle keyboard input const handleKeyIntercept = useCallback( (key: { name?: string; shift?: boolean; ctrl?: boolean }) => { @@ -275,9 +297,11 @@ export const ChatHistoryScreen: React.FC = ({ items={filteredItems.slice(0, LAYOUT.MAX_RENDERED_CHATS)} focusedIndex={focusedIndex} onSelect={handleChatSelect} + actionLabel="[×]" + onAction={handleChatDelete} onFocusChange={handleFocusChange} emptyMessage={ - initialChats.length === 0 + chats.length === 0 ? 'No chat history yet' : searchQuery ? 'No matching chats' @@ -314,8 +338,14 @@ export const ChatHistoryScreen: React.FC = ({ {/* Help text */} - ↑↓ navigate · Enter select · Esc cancel + ↑↓ navigate · Enter select · Click [×] to remove · Esc cancel + {statusMessage && ( + + {' · '} + {statusMessage} + + )} {/* Buttons - hidden on narrow screens */} diff --git a/cli/src/components/freebuff-model-selector.tsx b/cli/src/components/freebuff-model-selector.tsx index edc889b1c4..294a4b32f8 100644 --- a/cli/src/components/freebuff-model-selector.tsx +++ b/cli/src/components/freebuff-model-selector.tsx @@ -11,6 +11,7 @@ import { isFreebuffModelAvailable, isFreebuffPremiumModelId, } from '@codebuff/common/constants/freebuff-models' +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import { joinFreebuffQueue } from '../hooks/use-freebuff-session' import { useNow } from '../hooks/use-now' @@ -127,10 +128,7 @@ export const FreebuffModelSelector: React.FC = () => { }, [now, selectedModel, session, setSelectedModel]) const committedModelId = session?.status === 'queued' ? session.model : null - const rateLimitsByModel = - session && 'rateLimitsByModel' in session - ? session.rateLimitsByModel - : undefined + const rateLimitsByModel = getRateLimitsByModel(session) const BUTTON_CHROME = 4 // 2 border + 2 padding const NAME_GAP = 2 // spaces between name column and details column diff --git a/cli/src/components/selectable-list.tsx b/cli/src/components/selectable-list.tsx index 99291097f1..e7a75d4763 100644 --- a/cli/src/components/selectable-list.tsx +++ b/cli/src/components/selectable-list.tsx @@ -40,6 +40,8 @@ export interface SelectableListProps { /** Optional max height - if not provided, list fills available space */ maxHeight?: number onSelect: (item: SelectableListItem, index: number) => void + actionLabel?: string + onAction?: (item: SelectableListItem, index: number) => void onFocusChange?: (index: number) => void emptyMessage?: string } @@ -53,7 +55,16 @@ export const SelectableList = forwardRef< SelectableListProps >( ( - { items, focusedIndex, maxHeight, onSelect, onFocusChange, emptyMessage = 'No items' }, + { + items, + focusedIndex, + maxHeight, + onSelect, + actionLabel, + onAction, + onFocusChange, + emptyMessage = 'No items', + }, ref, ) => { const theme = useTheme() @@ -141,13 +152,21 @@ export const SelectableList = forwardRef< const isHighlighted = isFocused || isHovered // Use subtle highlight that works in both light and dark themes - const backgroundColor = isHighlighted ? theme.surfaceHover : 'transparent' + const backgroundColor = isHighlighted + ? theme.surfaceHover + : 'transparent' const textColor = isHighlighted ? theme.foreground : theme.muted return ( - + {actionLabel && onAction && ( + )} - + ) })} diff --git a/cli/src/components/session-ended-banner.tsx b/cli/src/components/session-ended-banner.tsx index 7482cbdf50..278729f956 100644 --- a/cli/src/components/session-ended-banner.tsx +++ b/cli/src/components/session-ended-banner.tsx @@ -1,3 +1,4 @@ +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import { TextAttributes } from '@opentui/core' import { useKeyboard } from '@opentui/react' import React, { useCallback, useState } from 'react' @@ -8,6 +9,8 @@ import { returnToFreebuffLanding, } from '../hooks/use-freebuff-session' import { useTheme } from '../hooks/use-theme' +import { useFreebuffSessionStore } from '../state/freebuff-session-store' +import { formatSessionUnits } from '../utils/format-session-units' import { BORDER_CHARS } from '../utils/ui-constants' import type { KeyEvent } from '@opentui/core' @@ -32,6 +35,19 @@ export const SessionEndedBanner: React.FC = ({ 'waiting-room' | 'same-chat' | null >(null) + // All premium models share one daily pool; the server replicates the same + // snapshot under each premium model id, so the first entry has the right + // count. + const premiumQuota = useFreebuffSessionStore( + (s) => Object.values(getRateLimitsByModel(s.session) ?? {})[0] ?? null, + ) + const isQuotaExhausted = premiumQuota + ? premiumQuota.recentCount >= premiumQuota.limit + : false + const bannerTitle = premiumQuota + ? `Session ended · ${formatSessionUnits(premiumQuota.recentCount)} of ${premiumQuota.limit} premium sessions used today` + : 'Session ended' + // While a request is still streaming, restart is disabled: it would // unmount and abort the in-flight agent run. The promise is "we // let the agent finish" — honoring that means Enter does nothing until @@ -78,12 +94,15 @@ export const SessionEndedBanner: React.FC = ({ return ( = ({ gap: 0, }} > - - Your freebuff session has ended. - {isStreaming ? ( Agent is wrapping up. Rejoin the wait room after it's finished. @@ -115,7 +131,7 @@ export const SessionEndedBanner: React.FC = ({ fg: pendingAction === 'same-chat' ? theme.muted - : theme.primary, + : theme.foreground, }} attributes={TextAttributes.BOLD} > @@ -144,11 +160,14 @@ export const SessionEndedBanner: React.FC = ({ ? theme.muted : theme.foreground, }} - attributes={TextAttributes.BOLD} > - {pendingAction === 'waiting-room' - ? 'Opening model selection…' - : 'Change model (ESC)'} + {pendingAction === 'waiting-room' ? ( + 'Opening model selection…' + ) : ( + <> + Change model{' Esc'} + + )} diff --git a/cli/src/components/tools/__tests__/apply-patch.test.tsx b/cli/src/components/tools/__tests__/apply-patch.test.tsx index 6e177757f5..75154bd964 100644 --- a/cli/src/components/tools/__tests__/apply-patch.test.tsx +++ b/cli/src/components/tools/__tests__/apply-patch.test.tsx @@ -47,7 +47,7 @@ describe('ApplyPatchComponent', () => { expect(markup).toContain('src/new-file.ts') }) - test('renders update_file operation without diff content while diff rendering is disabled', () => { + test('renders update_file operation with diff content', () => { const toolBlock = createToolBlock({ type: 'update_file', path: 'src/existing.ts', @@ -62,8 +62,8 @@ describe('ApplyPatchComponent', () => { const markup = renderToStaticMarkup(result?.content as React.ReactElement) expect(markup).toContain('Edit') expect(markup).toContain('src/existing.ts') - expect(markup).not.toContain('-oldLine') - expect(markup).not.toContain('+newLine') + expect(markup).toContain('-oldLine') + expect(markup).toContain('+newLine') }) test('renders delete_file operation', () => { diff --git a/cli/src/components/tools/diff-viewer.tsx b/cli/src/components/tools/diff-viewer.tsx index 37d613a9ab..0e2c6cce64 100644 --- a/cli/src/components/tools/diff-viewer.tsx +++ b/cli/src/components/tools/diff-viewer.tsx @@ -6,8 +6,6 @@ interface DiffViewerProps { diffText: string } -const RENDER_DIFFS = false - const DIFF_LINE_COLORS = { dark: { added: '#7ACC35', @@ -53,10 +51,6 @@ const lineColor = ( export const DiffViewer = ({ diffText }: DiffViewerProps) => { const theme = useTheme() - if (!RENDER_DIFFS) { - return null - } - const lines = diffText.trim().split('\n') return ( diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 8c6e120944..a07971cab8 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -15,8 +15,10 @@ import { useSheenAnimation } from '../hooks/use-sheen-animation' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' import { exitFreebuffCleanly } from '../utils/freebuff-exit' +import { formatSessionUnits } from '../utils/format-session-units' import { getLogoAccentColor, getLogoBlockColor } from '../utils/theme-system' import { FREEBUFF_PREMIUM_SESSION_LIMIT } from '@codebuff/common/constants/freebuff-models' +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import type { FreebuffSessionResponse } from '../types/freebuff-session' import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session' @@ -59,9 +61,6 @@ const formatRetryAfter = (ms: number): string => { return rem === 0 ? `${hours}h` : `${hours}h ${rem}m` } -const formatSessionUnits = (units: number): string => - Number.isInteger(units) ? String(units) : units.toFixed(1) - const PRIVACY_SIGNAL_LABELS: Partial> = { anonymous: 'anonymized network', @@ -268,18 +267,19 @@ export const WaitingRoomScreen: React.FC = ({ // pool; the server replicates the same snapshot under each premium model // id, so any entry has the right count. Renders amber when exhausted so // the limit reads as "you've hit it" rather than just another count. - const rateLimitsByModel = - session && 'rateLimitsByModel' in session - ? session.rateLimitsByModel - : undefined + const rateLimitsByModel = getRateLimitsByModel(session) const sharedPremiumUsed = rateLimitsByModel ? (Object.values(rateLimitsByModel)[0]?.recentCount ?? 0) : 0 - const premiumLeft = Math.max( - 0, - FREEBUFF_PREMIUM_SESSION_LIMIT - sharedPremiumUsed, - ) - const premiumLeftColor = premiumLeft === 0 ? theme.secondary : theme.muted + const isPremiumExhausted = + sharedPremiumUsed >= FREEBUFF_PREMIUM_SESSION_LIMIT + const premiumUsedColor = isPremiumExhausted ? theme.secondary : theme.muted + // Pad the used count so the title's centered container doesn't shift width + // as the count ticks from "0" → "1.3" → "2" while loading. + const sessionUnitWidth = String(FREEBUFF_PREMIUM_SESSION_LIMIT).length + 2 + const formattedSharedPremiumUsed = formatSessionUnits( + sharedPremiumUsed, + ).padStart(sessionUnitWidth) return ( = ({ Pick a model to start - + {' · '} - {premiumLeft} premium left today + {formattedSharedPremiumUsed} of{' '} + {FREEBUFF_PREMIUM_SESSION_LIMIT} premium sessions used today diff --git a/cli/src/hooks/use-freebuff-session.ts b/cli/src/hooks/use-freebuff-session.ts index baa8a2b13e..3211acb7a7 100644 --- a/cli/src/hooks/use-freebuff-session.ts +++ b/cli/src/hooks/use-freebuff-session.ts @@ -3,6 +3,7 @@ import { FALLBACK_FREEBUFF_MODEL_ID, resolveFreebuffModel, } from '@codebuff/common/constants/freebuff-models' +import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' import { useEffect } from 'react' import { @@ -351,11 +352,16 @@ export function markFreebuffSessionCountryBlocked(params: { } /** Flip into the local `ended` state without an instanceId (server has lost - * our row). The chat surface stays mounted with the rejoin banner. */ + * our row). The chat surface stays mounted with the rejoin banner. + * Preserves any `rateLimitsByModel` snapshot from the prior session so the + * banner can show today's premium-session count without an extra fetch. */ export function markFreebuffSessionEnded(): void { if (!IS_FREEBUFF) return controller?.abort() - controller?.apply({ status: 'ended' }) + const rateLimitsByModel = getRateLimitsByModel( + useFreebuffSessionStore.getState().session, + ) + controller?.apply({ status: 'ended', rateLimitsByModel }) } interface UseFreebuffSessionResult { @@ -508,12 +514,18 @@ export function useFreebuffSession(): UseFreebuffSessionResult { // active|ended → none means we've passed the server's hard cutoff. // Synthesize a no-instanceId ended state so the chat surface stays // mounted with the Enter-to-rejoin banner instead of looping back - // through the waiting room. + // through the waiting room. Carry forward whichever rate-limit + // snapshot we have — preferring the fresh `none` snapshot, falling + // back to whatever was on the prior active/ended row — so the + // banner's "N of M used today" line stays populated. if ( (previousStatus === 'active' || previousStatus === 'ended') && next.status === 'none' ) { - apply({ status: 'ended' }) + const rateLimitsByModel = + next.rateLimitsByModel ?? + getRateLimitsByModel(useFreebuffSessionStore.getState().session) + apply({ status: 'ended', rateLimitsByModel }) return } diff --git a/cli/src/utils/__tests__/chat-history.test.ts b/cli/src/utils/__tests__/chat-history.test.ts new file mode 100644 index 0000000000..31acf47f34 --- /dev/null +++ b/cli/src/utils/__tests__/chat-history.test.ts @@ -0,0 +1,74 @@ +import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test' +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +let tempDataDir = '' + +mock.module('../../project-files', () => ({ + getProjectDataDir: () => tempDataDir, +})) + +mock.module('../logger', () => ({ + logger: { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + fatal: () => {}, + }, +})) + +import { deleteChatSession, getAllChats } from '../chat-history' + +function writeChat(chatId: string, prompt: string) { + const chatDir = path.join(tempDataDir, 'chats', chatId) + fs.mkdirSync(chatDir, { recursive: true }) + fs.writeFileSync( + path.join(chatDir, 'chat-messages.json'), + JSON.stringify([ + { + id: `${chatId}-message`, + variant: 'user', + content: prompt, + timestamp: new Date().toISOString(), + blocks: [], + }, + ]), + ) +} + +describe('chat-history', () => { + beforeEach(() => { + tempDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codebuff-history-')) + }) + + afterEach(() => { + fs.rmSync(tempDataDir, { recursive: true, force: true }) + }) + + test('deleteChatSession removes a saved chat directory', () => { + writeChat('chat-a', 'hello from chat a') + writeChat('chat-b', 'hello from chat b') + + expect(deleteChatSession('chat-a')).toBe(true) + + expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-a'))).toBe(false) + expect(fs.existsSync(path.join(tempDataDir, 'chats', 'chat-b'))).toBe(true) + expect(getAllChats().map((chat) => chat.chatId)).toEqual(['chat-b']) + }) + + test('deleteChatSession rejects invalid chat ids', () => { + const outsideDir = path.join(tempDataDir, 'outside') + fs.mkdirSync(outsideDir, { recursive: true }) + + expect(deleteChatSession('../outside')).toBe(false) + expect(deleteChatSession('..')).toBe(false) + + expect(fs.existsSync(outsideDir)).toBe(true) + }) + + test('deleteChatSession returns false when the chat does not exist', () => { + expect(deleteChatSession('missing-chat')).toBe(false) + }) +}) diff --git a/cli/src/utils/chat-history.ts b/cli/src/utils/chat-history.ts index 1a97101a81..2a4a51612c 100644 --- a/cli/src/utils/chat-history.ts +++ b/cli/src/utils/chat-history.ts @@ -13,6 +13,10 @@ export interface ChatHistoryEntry { messageCount: number } +function getChatsDir(): string { + return path.join(getProjectDataDir(), 'chats') +} + /** * Get the first user message from a list of chat messages */ @@ -43,14 +47,14 @@ interface ChatDirInfo { */ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { try { - const chatsDir = path.join(getProjectDataDir(), 'chats') - + const chatsDir = getChatsDir() + if (!fs.existsSync(chatsDir)) { return [] } const chatDirs = fs.readdirSync(chatsDir) - + // First pass: get mtime for all chat directories (fast, no file reading) const chatDirInfos: ChatDirInfo[] = [] for (const chatId of chatDirs) { @@ -58,7 +62,7 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { try { const stat = fs.statSync(chatPath) if (!stat.isDirectory()) continue - + chatDirInfos.push({ chatId, chatPath, @@ -69,14 +73,14 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { // Skip directories we can't stat } } - + // Sort by mtime first (most recent first) chatDirInfos.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) - + // Second pass: only read message content for the top N chats const chats: ChatHistoryEntry[] = [] const chatsToLoad = chatDirInfos.slice(0, maxChats) - + for (const info of chatsToLoad) { try { let messageCount = 0 @@ -100,8 +104,11 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { } } catch (error) { logger.debug( - { chatId: info.chatId, error: error instanceof Error ? error.message : String(error) }, - 'Failed to read chat messages' + { + chatId: info.chatId, + error: error instanceof Error ? error.message : String(error), + }, + 'Failed to read chat messages', ) } } @@ -110,12 +117,55 @@ export function getAllChats(maxChats: number = 500): ChatHistoryEntry[] { } catch (error) { logger.error( { error: error instanceof Error ? error.message : String(error) }, - 'Failed to list chats' + 'Failed to list chats', ) return [] } } +/** + * Delete a saved chat session from local history. + */ +export function deleteChatSession(chatId: string): boolean { + try { + const safeChatId = chatId.trim() + if ( + !safeChatId || + safeChatId === '.' || + safeChatId === '..' || + path.basename(safeChatId) !== safeChatId + ) { + logger.warn({ chatId }, 'Refusing to delete invalid chat id') + return false + } + + const chatsDir = getChatsDir() + const chatPath = path.join(chatsDir, safeChatId) + + if (!fs.existsSync(chatPath)) { + return false + } + + const stat = fs.statSync(chatPath) + if (!stat.isDirectory()) { + logger.warn( + { chatId, chatPath }, + 'Refusing to delete non-directory chat path', + ) + return false + } + + fs.rmSync(chatPath, { recursive: true, force: false }) + return true + } catch (error) { + logger.error( + { chatId, error: error instanceof Error ? error.message : String(error) }, + 'Failed to delete chat session', + ) + return false + } +} + /** * Format a timestamp relative to now (e.g., "2 hours ago", "yesterday") */ diff --git a/cli/src/utils/format-session-units.ts b/cli/src/utils/format-session-units.ts new file mode 100644 index 0000000000..75532df80c --- /dev/null +++ b/cli/src/utils/format-session-units.ts @@ -0,0 +1,6 @@ +/** Premium-session counts come back from the server as `recentCount` units + * that may be fractional (a long agent run can consume 1.3 sessions). Render + * integers without a trailing `.0`, fractionals at one decimal — matches the + * `limit` field which is always integer. */ +export const formatSessionUnits = (units: number): string => + Number.isInteger(units) ? String(units) : units.toFixed(1) diff --git a/common/src/__tests__/free-agents.test.ts b/common/src/__tests__/free-agents.test.ts index 3a8072d490..003e179b54 100644 --- a/common/src/__tests__/free-agents.test.ts +++ b/common/src/__tests__/free-agents.test.ts @@ -30,9 +30,15 @@ describe('free mode agent model allowlist', () => { expect( isFreeModeAllowedAgentModel('base2-free', FREEBUFF_MINIMAX_MODEL_ID), ).toBe(true) + expect( + isFreeModeAllowedAgentModel( + 'base2-free', + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + ), + ).toBe(true) expect( isFreeModeAllowedAgentModel('base2-free', FREEBUFF_KIMI_MODEL_ID), - ).toBe(false) + ).toBe(true) expect( isFreeModeAllowedAgentModel('base2-free-kimi', FREEBUFF_KIMI_MODEL_ID), ).toBe(true) diff --git a/common/src/constants/free-agents.ts b/common/src/constants/free-agents.ts index 8ff8f80ed8..0159132d9b 100644 --- a/common/src/constants/free-agents.ts +++ b/common/src/constants/free-agents.ts @@ -62,7 +62,12 @@ export function getFreebuffRootAgentIdForModel(model: string): string { */ export const FREE_MODE_AGENT_MODELS: Record> = { // Root orchestrator - 'base2-free': new Set([FREEBUFF_MINIMAX_MODEL_ID, FREEBUFF_GLM_MODEL_ID]), + 'base2-free': new Set([ + FREEBUFF_MINIMAX_MODEL_ID, + FREEBUFF_GLM_MODEL_ID, + FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + FREEBUFF_KIMI_MODEL_ID, + ]), 'base2-free-kimi': new Set([FREEBUFF_KIMI_MODEL_ID]), 'base2-free-deepseek': new Set([FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID]), diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index 8d4eebd366..9dbf191492 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -31,6 +31,20 @@ export type FreebuffSessionRateLimitByModel = Record< FreebuffSessionRateLimit > +/** Pull the per-model premium quota snapshot off whichever session statuses + * carry it (queued, active, ended, none). Returns undefined for terminal / + * pre-join states that have no quota field. The parameter is intentionally + * loose so the CLI can pass its `FreebuffSessionResponse` (which adds the + * client-only `takeover_prompt` variant) without a discriminated-union + * ceremony at every call site. */ +export const getRateLimitsByModel = ( + session: { status: string } | null | undefined, +): FreebuffSessionRateLimitByModel | undefined => + session && 'rateLimitsByModel' in session + ? (session as { rateLimitsByModel?: FreebuffSessionRateLimitByModel }) + .rateLimitsByModel + : undefined + export type FreebuffCountryBlockReason = | 'country_not_allowed' | 'anonymized_or_unknown_country' @@ -119,6 +133,10 @@ export type FreebuffSessionServerResponse = expiresAt?: string gracePeriodEndsAt?: string gracePeriodRemainingMs?: number + /** Snapshot of the user's premium-session quota at the moment the + * session ended. Lets the post-session banner show "N of M premium + * sessions used today" without an extra round-trip. */ + rateLimitsByModel?: FreebuffSessionRateLimitByModel } | { /** Another CLI on the same account rotated our instance id. Polling diff --git a/evals/buffbench/README.md b/evals/buffbench/README.md index 2707cdd2b2..9e6dc4d303 100644 --- a/evals/buffbench/README.md +++ b/evals/buffbench/README.md @@ -139,6 +139,7 @@ BuffBench supports running external CLI coding agents for comparison: - **Claude Code**: Use `external:claude` - requires `claude` CLI installed - **Codex**: Use `external:codex` - requires `codex` CLI installed +- **OpenCode**: Use `external:opencode` - requires `opencode` CLI installed Example comparing Codebuff vs Claude Code: @@ -164,6 +165,13 @@ npm install -g @openai/codex # Set OPENAI_API_KEY environment variable ``` +**OpenCode CLI:** +```bash +# Install from https://opencode.ai/docs/install +# Set OPENCODE_API_KEY environment variable +# BuffBench uses opencode/kimi-k2.6 by default; override with OPENCODE_MODEL if needed. +``` + ## Directory Structure ``` diff --git a/evals/buffbench/agent-runner.ts b/evals/buffbench/agent-runner.ts index f4564f3c53..57f2fa1e50 100644 --- a/evals/buffbench/agent-runner.ts +++ b/evals/buffbench/agent-runner.ts @@ -1,15 +1,15 @@ -import { execSync , exec } from 'child_process' +import { execSync, exec } from 'child_process' import { promisify } from 'util' const execAsync = promisify(exec) import { withTimeout } from '@codebuff/common/util/promise' - import { withTestRepo } from '../subagents/test-repo-utils' import { ClaudeRunner } from './runners/claude' import { CodebuffRunner } from './runners/codebuff' import { CodexRunner } from './runners/codex' +import { OpenCodeRunner } from './runners/opencode' import type { Runner, AgentStep } from './runners/runner' import type { EvalCommitV2, FinalCheckOutput } from './types' @@ -17,7 +17,7 @@ import type { CodebuffClient } from '@codebuff/sdk' export type { AgentStep } -export type ExternalAgentType = 'claude' | 'codex' +export type ExternalAgentType = 'claude' | 'codex' | 'opencode' export async function runAgentOnCommit({ client, @@ -76,6 +76,8 @@ export async function runAgentOnCommit({ runner = new ClaudeRunner(repoDir, env) } else if (externalAgentType === 'codex') { runner = new CodexRunner(repoDir, env) + } else if (externalAgentType === 'opencode') { + runner = new OpenCodeRunner(repoDir, env) } else { runner = new CodebuffRunner({ cwd: repoDir, diff --git a/evals/buffbench/main.ts b/evals/buffbench/main.ts index 5508dccbed..0173a09fba 100644 --- a/evals/buffbench/main.ts +++ b/evals/buffbench/main.ts @@ -8,6 +8,7 @@ async function main() { // Compare Codebuff agents against external CLI agents // Use 'external:claude' for Claude Code CLI // Use 'external:codex' for OpenAI Codex CLI + // Use 'external:opencode' for OpenCode CLI await runBuffBench({ evalDataPaths: [path.join(__dirname, 'eval-codebuff.json')], agents: ['base2-free-evals'], diff --git a/evals/buffbench/run-buffbench.ts b/evals/buffbench/run-buffbench.ts index c501425dd2..b94ab04278 100644 --- a/evals/buffbench/run-buffbench.ts +++ b/evals/buffbench/run-buffbench.ts @@ -27,9 +27,13 @@ function parseAgentId(agent: string): { } { if (agent.startsWith('external:')) { const externalType = agent.slice('external:'.length) as ExternalAgentType - if (externalType !== 'claude' && externalType !== 'codex') { + if ( + externalType !== 'claude' && + externalType !== 'codex' && + externalType !== 'opencode' + ) { throw new Error( - `Unknown external agent type: ${externalType}. Supported: claude, codex`, + `Unknown external agent type: ${externalType}. Supported: claude, codex, opencode`, ) } return { agentId: agent, externalAgentType: externalType } @@ -187,7 +191,10 @@ async function runTask(options: { tracesDir, `${index + 1}-${safeTaskId}-${safeAgentId}-${safeCommitShort}-agent.json`, ) - fs.writeFileSync(agentTracePath, JSON.stringify(agentResult.trace, null, 2)) + fs.writeFileSync( + agentTracePath, + JSON.stringify(agentResult.trace, null, 2), + ) } fs.writeFileSync( diff --git a/evals/buffbench/runners/index.ts b/evals/buffbench/runners/index.ts index 99adc3d28a..0567543ccc 100644 --- a/evals/buffbench/runners/index.ts +++ b/evals/buffbench/runners/index.ts @@ -1,3 +1,4 @@ export { ClaudeRunner } from './claude' export { CodexRunner } from './codex' +export { OpenCodeRunner } from './opencode' export type { Runner, RunnerResult } from './runner' diff --git a/evals/buffbench/runners/opencode.ts b/evals/buffbench/runners/opencode.ts new file mode 100644 index 0000000000..a34aaf815f --- /dev/null +++ b/evals/buffbench/runners/opencode.ts @@ -0,0 +1,252 @@ +import { execSync, spawn } from 'child_process' + +import type { AgentStep, Runner, RunnerResult } from './runner' +import type { + PrintModeToolCall, + PrintModeToolResult, +} from '@codebuff/common/types/print-mode' +import type { JSONValue } from '@codebuff/common/types/json' + +const OPENCODE_MODEL = 'opencode/kimi-k2.6' + +function toJsonValue(value: unknown): JSONValue { + if ( + value === null || + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value + } + + if (Array.isArray(value)) { + return value.map(toJsonValue) + } + + if (typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, toJsonValue(entry)]), + ) + } + + return String(value) +} + +type OpenCodeEvent = { + type?: string + sessionID?: string + error?: { + name?: string + message?: string + statusCode?: number + data?: { + message?: string + } + } + part?: { + id?: string + type?: string + text?: string + tool?: string + callID?: string + state?: { + input?: unknown + output?: unknown + } + cost?: number + } +} + +function formatOpenCodeError(error: OpenCodeEvent['error']): string { + const message = + error?.data?.message || + error?.message || + error?.name || + 'OpenCode emitted an error event.' + + return error?.statusCode ? `${message} (status ${error.statusCode})` : message +} + +export class OpenCodeRunner implements Runner { + private cwd: string + private env: Record + + constructor(cwd: string, env: Record = {}) { + this.cwd = cwd + this.env = env + } + + async run(prompt: string): Promise { + const steps: AgentStep[] = [] + let totalCostUsd = 0 + + return new Promise((resolve, reject) => { + let openCodeError: string | undefined + const model = + this.env.OPENCODE_MODEL || process.env.OPENCODE_MODEL || OPENCODE_MODEL + const args = [ + 'run', + '--model', + model, + '--format', + 'json', + '--agent', + 'build', + prompt, + ] + + console.log(`[OpenCodeRunner] Running: opencode run --model ${model}`) + + const child = spawn('opencode', args, { + cwd: this.cwd, + env: { + ...process.env, + ...this.env, + OPENCODE_API_KEY: + this.env.OPENCODE_API_KEY || process.env.OPENCODE_API_KEY, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + + let stdoutBuffer = '' + let stderr = '' + + const processEvent = (event: OpenCodeEvent) => { + if (event.type === 'error') { + openCodeError = formatOpenCodeError(event.error) + steps.push({ + type: 'text', + text: `[OpenCode error] ${openCodeError}`, + }) + return + } + + const part = event.part + if (!part) { + return + } + + if (event.type === 'text' || part.type === 'text') { + const text = part.text ?? '' + if (text.length > 0) { + steps.push({ type: 'text', text }) + process.stdout.write(text) + } + return + } + + if (event.type === 'step_finish' || part.type === 'step-finish') { + if (typeof part.cost === 'number') { + totalCostUsd += part.cost + } + return + } + + if (part.type === 'tool') { + const toolName = part.tool ?? 'unknown' + const toolCallId = part.callID ?? part.id ?? `opencode-${Date.now()}` + const input = part.state?.input ?? {} + + const toolCall: PrintModeToolCall = { + type: 'tool_call', + toolName, + toolCallId, + input: + input && typeof input === 'object' + ? (input as Record) + : { input }, + } + steps.push(toolCall) + + if (part.state && 'output' in part.state) { + const toolResult: PrintModeToolResult = { + type: 'tool_result', + toolName, + toolCallId, + output: [ + { + type: 'json', + value: toJsonValue(part.state.output ?? ''), + }, + ], + } + steps.push(toolResult) + } + } + } + + const processLine = (line: string) => { + if (!line.trim()) { + return + } + + try { + processEvent(JSON.parse(line)) + } catch { + steps.push({ type: 'text', text: line }) + } + } + + child.stdout.on('data', (data: Buffer) => { + stdoutBuffer += data.toString() + + const lines = stdoutBuffer.split('\n') + stdoutBuffer = lines.pop() ?? '' + for (const line of lines) { + processLine(line) + } + }) + + child.stderr.on('data', (data: Buffer) => { + stderr += data.toString() + process.stderr.write(data) + }) + + child.on('error', (error) => { + reject( + new Error( + `OpenCode CLI failed to start: ${error.message}. Make sure 'opencode' is installed and in PATH.`, + ), + ) + }) + + child.on('close', (code) => { + if (stdoutBuffer.trim()) { + processLine(stdoutBuffer) + } + + let diff = '' + try { + execSync('git add .', { cwd: this.cwd, stdio: 'ignore' }) + diff = execSync('git diff HEAD', { + cwd: this.cwd, + encoding: 'utf-8', + maxBuffer: 10 * 1024 * 1024, + }) + } catch { + // Ignore git errors + } + + if (code !== 0) { + reject( + new Error( + `OpenCode CLI exited with code ${code}. stderr: ${stderr}`, + ), + ) + return + } + + if (openCodeError) { + reject(new Error(openCodeError)) + return + } + + resolve({ + steps, + totalCostUsd, + diff, + }) + }) + }) + } +} diff --git a/freebuff/cli/release/package.json b/freebuff/cli/release/package.json index 0b810c6576..ab5597722a 100644 --- a/freebuff/cli/release/package.json +++ b/freebuff/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "freebuff", - "version": "0.0.82", + "version": "0.0.84", "description": "The world's strongest free coding agent", "license": "MIT", "bin": { diff --git a/freebuff/e2e/tests/agent-startup.e2e.test.ts b/freebuff/e2e/tests/agent-startup.e2e.test.ts index 04a10e7332..95340b127a 100644 --- a/freebuff/e2e/tests/agent-startup.e2e.test.ts +++ b/freebuff/e2e/tests/agent-startup.e2e.test.ts @@ -72,12 +72,12 @@ describe('Freebuff: Agent-driven E2E', () => { expect(result.output.type).not.toBe('error') - // Verify the agent used the tmux tools + // Verify the agent exercised the startup path. The afterEach cleanup + // handles stopping Freebuff deterministically if the agent finishes early. const toolCalls = events.filter((e) => e.type === 'tool_call') const toolNames = toolCalls.map((e) => e.toolName) expect(toolNames).toContain('start_freebuff') expect(toolNames).toContain('capture_freebuff_output') - expect(toolNames).toContain('stop_freebuff') }, AGENT_TEST_TIMEOUT, ) diff --git a/web/src/server/free-session/__tests__/public-api.test.ts b/web/src/server/free-session/__tests__/public-api.test.ts index 2ac2ad75ad..351e17ac07 100644 --- a/web/src/server/free-session/__tests__/public-api.test.ts +++ b/web/src/server/free-session/__tests__/public-api.test.ts @@ -960,6 +960,38 @@ describe('getSessionState', () => { expect(state.gracePeriodRemainingMs).toBe(GRACE_MS - 60_000) }) + test('ended view carries the full premium-quota snapshot', async () => { + // The post-session banner reads any entry from rateLimitsByModel since + // all premium models share one daily pool. Unlike queued/active, the + // ended view ships the full unfiltered map so a single banner read is + // always safe. + await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) + const row = deps.rows.get('u1')! + row.status = 'active' + row.admitted_at = new Date(deps._now().getTime() - SESSION_LEN - 60_000) + row.expires_at = new Date(deps._now().getTime() - 60_000) + deps.admits.push({ + user_id: 'u1', + model: FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, + admitted_at: new Date(deps._now().getTime() - 30 * 60_000), + }) + + const state = await getSessionState({ + userId: 'u1', + claimedInstanceId: row.active_instance_id, + deps, + }) + if (state.status !== 'ended') throw new Error('unreachable') + expect( + state.rateLimitsByModel?.[FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID], + ).toEqual(expectedRateLimit(FREEBUFF_DEEPSEEK_V4_PRO_MODEL_ID, 1)) + // Every premium model is present (sharing the same recentCount) so the + // banner can read any entry without caring which model the user was on. + expect(state.rateLimitsByModel?.[FREEBUFF_KIMI_MODEL_ID]).toEqual( + expectedRateLimit(FREEBUFF_KIMI_MODEL_ID, 1), + ) + }) + test('row past grace window returns none', async () => { await requestSession({ userId: 'u1', model: DEFAULT_MODEL, deps }) const row = deps.rows.get('u1')! diff --git a/web/src/server/free-session/public-api.ts b/web/src/server/free-session/public-api.ts index 59af4db819..68a0f59bce 100644 --- a/web/src/server/free-session/public-api.ts +++ b/web/src/server/free-session/public-api.ts @@ -416,21 +416,31 @@ export async function requestSession(params: { return attachRateLimit(params.userId, view, deps) } -/** Thread the current quota snapshot onto queued/active views so the CLI can - * render "N of M sessions used". Other statuses pass through unchanged. - * Called on both POST and GET so the line stays live across polls. */ +/** Thread the current quota snapshot onto queued/active/ended views so the + * CLI can render "N of M sessions used" — both during the session and on + * the post-session banner. Other statuses pass through unchanged. Called on + * both POST and GET so the line stays live across polls. */ async function attachRateLimit( userId: string, view: SessionStateResponse, deps: SessionDeps, ): Promise { - if (view.status !== 'queued' && view.status !== 'active') return view - if (view.status === 'active') { - const snapshot = await fetchRateLimitSnapshot(userId, view.model, deps) - return snapshot ? { ...view, rateLimit: snapshot.info } : view + if ( + view.status !== 'queued' && + view.status !== 'active' && + view.status !== 'ended' + ) { + return view } - const allRateLimitsByModel = await fetchRateLimitsByModel(userId, deps) + // The ended view doesn't carry a model id, so it gets the full snapshot + // unfiltered — the banner reads any entry's recentCount (they all share the + // same daily premium pool). Queued/active filter out unused models so the + // landing screen and waiting-room title don't list every premium model with + // a "0 used today" hint. + if (view.status === 'ended') { + return { ...view, rateLimitsByModel: allRateLimitsByModel } + } const rateLimit = allRateLimitsByModel[view.model] return { ...view,