mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-19 07:43:09 +00:00
feat(providers): add Codebuff provider
Adds the Codebuff provider (formerly Manicode) as a single-file plugin under src/providers/codebuff.ts, following the conventions used by the pi and opencode providers. Data source: - Walks ~/.config/manicode/projects/<project>/chats/<chatId>/ - chat-messages.json holds the serialized ChatMessage[] - run-state.json is consulted to recover the real cwd so sessions group by the originating project directory - manicode-dev and manicode-staging channels are walked automatically - Honors CODEBUFF_DATA_DIR for a custom root Cost model: - Codebuff bills in credits, not tokens. Each completed assistant message records credits on message.credits; CodeBurn approximates cost using the public PAYG rate ($0.01 / credit) as a conservative upper bound. - When Codebuff routes a call through an upstream provider and the stashed RunState records real token totals (providerOptions.usage or providerOptions.codebuff.usage in messageHistory), the LiteLLM-based calculation takes precedence. Tool normalization maps Codebuff-native names (read_files, str_replace, run_terminal_command, spawn_agents, etc.) to the canonical set used by the classifier (Read, Edit, Bash, Agent, TodoWrite, ...). Tests: - 18 provider tests covering discovery, parsing, multi-turn sessions, providerOptions fallback, dedup, malformed files, display names - provider-registry.test.ts updated to assert codebuff is registered and its tool/model display names Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
parent
d69aa344ab
commit
3c0302b938
7 changed files with 863 additions and 6 deletions
|
|
@ -17,7 +17,7 @@
|
|||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="620" />
|
||||
</p>
|
||||
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **cursor-agent**, **OpenCode**, **Pi**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
|
||||
By task type, tool, model, MCP server, and project. Supports **Claude Code**, **Codex** (OpenAI), **Cursor**, **cursor-agent**, **OpenCode**, **Pi**, **Codebuff**, and **GitHub Copilot** with a provider plugin system. Tracks one-shot success rate per activity type so you can see where the AI nails it first try vs. burns tokens on edit/test/fix retries. Interactive TUI dashboard with gradient charts, responsive panels, and keyboard navigation. Native macOS menubar app in `mac/`. CSV/JSON export.
|
||||
|
||||
Works by reading session data directly from disk. No wrapper, no proxy, no API keys. Pricing from LiteLLM (auto-cached, all models supported).
|
||||
|
||||
|
|
@ -92,6 +92,7 @@ codeburn report --provider cursor # Cursor only
|
|||
codeburn report --provider cursor-agent # cursor-agent CLI only
|
||||
codeburn report --provider opencode # OpenCode only
|
||||
codeburn report --provider pi # Pi only
|
||||
codeburn report --provider codebuff # Codebuff only
|
||||
codeburn report --provider copilot # GitHub Copilot only
|
||||
codeburn today --provider codex # Codex today
|
||||
codeburn export --provider claude # export Claude data only
|
||||
|
|
@ -136,6 +137,7 @@ Either flag alone is valid. Inverted or malformed dates exit with a clear error.
|
|||
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Supported |
|
||||
| OpenCode | `~/.local/share/opencode/` (SQLite) | Supported |
|
||||
| Pi | `~/.pi/agent/sessions/` | Supported |
|
||||
| Codebuff | `~/.config/manicode/` | Supported (credits-based cost) |
|
||||
| GitHub Copilot | `~/.copilot/session-state/` | Supported (output tokens only) |
|
||||
| Amp | -- | Planned (provider plugin system) |
|
||||
|
||||
|
|
@ -308,7 +310,9 @@ All metrics are computed from your local session data. No LLM calls, fully deter
|
|||
|
||||
**Pi** stores sessions as JSONL at `~/.pi/agent/sessions/<sanitized-cwd>/*.jsonl`. Each assistant message carries token usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` content blocks. CodeBurn extracts token counts, normalizes Pi's lowercase tool names to the standard set (`bash` -> `Bash`, `dispatch_agent` -> `Agent`), and pulls bash commands from `toolCall.arguments.command` for the shell breakdown.
|
||||
|
||||
CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi), filters by date range per entry, and classifies each turn.
|
||||
**Codebuff** (formerly Manicode) stores per-chat history as JSON at `~/.config/manicode/projects/<project>/chats/<chatId>/chat-messages.json`. Codebuff bills in credits rather than tokens, so CodeBurn records each completed assistant message (via `msg.credits`) and approximates cost at the public pay-as-you-go rate ($0.01 / credit). When Codebuff routes a call through an upstream provider and the stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM-calculated cost take precedence. Codebuff-native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`, etc.) normalize to the canonical set (`Read`, `Edit`, `Bash`, `Agent`). The `manicode-dev` and `manicode-staging` channels are walked automatically when present. Honors `CODEBUFF_DATA_DIR` for a custom root.
|
||||
|
||||
CodeBurn reads these files, deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session+message ID for OpenCode, by responseId for Pi, by chat folder + message ID for Codebuff), filters by date range per entry, and classifies each turn.
|
||||
|
||||
## Environment variables
|
||||
|
||||
|
|
@ -316,6 +320,7 @@ CodeBurn reads these files, deduplicates messages (by API message ID for Claude,
|
|||
|----------|-------------|
|
||||
| `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) |
|
||||
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
|
||||
| `CODEBUFF_DATA_DIR` | Override Codebuff data directory (default: `~/.config/manicode`) |
|
||||
|
||||
## Project structure
|
||||
|
||||
|
|
|
|||
13
package-lock.json
generated
13
package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.5",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeburn",
|
||||
"version": "0.7.3",
|
||||
"version": "0.8.5",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
|
|
@ -904,6 +904,7 @@
|
|||
"integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
|
|
@ -914,6 +915,7 @@
|
|||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
|
|
@ -1356,6 +1358,7 @@
|
|||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
|
|
@ -1769,6 +1772,7 @@
|
|||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
|
|
@ -1818,6 +1822,7 @@
|
|||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
|
|
@ -1875,6 +1880,7 @@
|
|||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
|
@ -2331,6 +2337,7 @@
|
|||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
|
|
@ -2366,6 +2373,7 @@
|
|||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
|
@ -2394,6 +2402,7 @@
|
|||
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"codex",
|
||||
"opencode",
|
||||
"pi",
|
||||
"codebuff",
|
||||
"ai-coding",
|
||||
"token-usage",
|
||||
"cost-tracking",
|
||||
|
|
|
|||
435
src/providers/codebuff.ts
Normal file
435
src/providers/codebuff.ts
Normal file
|
|
@ -0,0 +1,435 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// Codebuff (formerly Manicode) uses a credit-based billing system. The local
|
||||
// chat-messages.json doesn't record per-call token counts the way Claude Code
|
||||
// or Codex do -- only `credits` on completed assistant messages. We convert
|
||||
// credits to USD using Codebuff's retail pay-as-you-go rate so the cost shows
|
||||
// up in the dashboard even when tokens are absent. The rate intentionally
|
||||
// rounds up to the public PAYG tier ($0.01 / credit) so we never understate
|
||||
// spend; users on a subscription plan get a conservative upper bound.
|
||||
const USD_PER_CREDIT = 0.01
|
||||
|
||||
// Codebuff's chat history lives under `~/.config/manicode/` (the legacy
|
||||
// product name is still on disk). Development and staging channels use
|
||||
// `manicode-dev` and `manicode-staging` -- we walk all three when present.
|
||||
const CHANNELS = ['manicode', 'manicode-dev', 'manicode-staging'] as const
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
codebuff: 'Codebuff',
|
||||
'codebuff-base': 'Codebuff Base',
|
||||
'codebuff-base2': 'Codebuff Base 2',
|
||||
'codebuff-lite': 'Codebuff Lite',
|
||||
'codebuff-max': 'Codebuff Max',
|
||||
}
|
||||
|
||||
// Codebuff's native tool names map to codeburn's canonical tool set so
|
||||
// classifier heuristics (edit/read/bash/etc.) behave consistently with the
|
||||
// other providers.
|
||||
const toolNameMap: Record<string, string> = {
|
||||
read_files: 'Read',
|
||||
read_file: 'Read',
|
||||
code_search: 'Grep',
|
||||
glob: 'Glob',
|
||||
find_files: 'Glob',
|
||||
str_replace: 'Edit',
|
||||
edit_file: 'Edit',
|
||||
write_file: 'Write',
|
||||
run_terminal_command: 'Bash',
|
||||
terminal: 'Bash',
|
||||
spawn_agents: 'Agent',
|
||||
spawn_agent: 'Agent',
|
||||
write_todos: 'TodoWrite',
|
||||
create_plan: 'TodoWrite',
|
||||
browser_logs: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
fetch_url: 'WebFetch',
|
||||
}
|
||||
|
||||
// Tool names we ignore for classification -- they're not useful signals for
|
||||
// distinguishing "coding" vs "exploration" vs "planning" work.
|
||||
const IGNORED_TOOLS = new Set(['suggest_followups', 'end_turn'])
|
||||
|
||||
type CodebuffUsage = {
|
||||
inputTokens?: number
|
||||
input_tokens?: number
|
||||
promptTokens?: number
|
||||
prompt_tokens?: number
|
||||
outputTokens?: number
|
||||
output_tokens?: number
|
||||
completionTokens?: number
|
||||
completion_tokens?: number
|
||||
cacheCreationInputTokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cacheReadInputTokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
promptTokensDetails?: { cachedTokens?: number }
|
||||
prompt_tokens_details?: { cached_tokens?: number }
|
||||
}
|
||||
|
||||
type CodebuffBlock = {
|
||||
type?: string
|
||||
content?: string
|
||||
toolName?: string
|
||||
input?: Record<string, unknown>
|
||||
output?: string
|
||||
agentName?: string
|
||||
agentType?: string
|
||||
status?: string
|
||||
blocks?: CodebuffBlock[]
|
||||
}
|
||||
|
||||
type CodebuffHistoryMessage = {
|
||||
role?: string
|
||||
providerOptions?: {
|
||||
codebuff?: { model?: string; usage?: CodebuffUsage }
|
||||
usage?: CodebuffUsage
|
||||
}
|
||||
}
|
||||
|
||||
type CodebuffMetadata = {
|
||||
model?: string
|
||||
modelId?: string
|
||||
timestamp?: string | number
|
||||
usage?: CodebuffUsage
|
||||
codebuff?: { model?: string; usage?: CodebuffUsage }
|
||||
runState?: {
|
||||
cwd?: string
|
||||
sessionState?: {
|
||||
cwd?: string
|
||||
projectContext?: { cwd?: string }
|
||||
fileContext?: { cwd?: string }
|
||||
mainAgentState?: {
|
||||
agentType?: string
|
||||
messageHistory?: CodebuffHistoryMessage[]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type CodebuffChatMessage = {
|
||||
id?: string
|
||||
variant?: string
|
||||
role?: string
|
||||
content?: string
|
||||
timestamp?: string | number
|
||||
credits?: number
|
||||
blocks?: CodebuffBlock[]
|
||||
metadata?: CodebuffMetadata
|
||||
}
|
||||
|
||||
function getCodebuffBaseDir(override?: string): string {
|
||||
if (override && override.trim()) return override
|
||||
const envPath = process.env['CODEBUFF_DATA_DIR']
|
||||
if (envPath && envPath.trim()) return envPath
|
||||
return join(homedir(), '.config', 'manicode')
|
||||
}
|
||||
|
||||
function pickNumber(...vals: Array<number | undefined>): number | undefined {
|
||||
for (const v of vals) {
|
||||
if (typeof v === 'number' && Number.isFinite(v)) return v
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeUsage(u: CodebuffUsage | undefined): {
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
} {
|
||||
if (!u) return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
return {
|
||||
input: pickNumber(u.inputTokens, u.input_tokens, u.promptTokens, u.prompt_tokens) ?? 0,
|
||||
output: pickNumber(u.outputTokens, u.output_tokens, u.completionTokens, u.completion_tokens) ?? 0,
|
||||
cacheRead:
|
||||
pickNumber(
|
||||
u.cacheReadInputTokens,
|
||||
u.cache_read_input_tokens,
|
||||
u.promptTokensDetails?.cachedTokens,
|
||||
u.prompt_tokens_details?.cached_tokens,
|
||||
) ?? 0,
|
||||
cacheWrite: pickNumber(u.cacheCreationInputTokens, u.cache_creation_input_tokens) ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
function coerceTimestamp(value: string | number | undefined): string {
|
||||
if (value == null) return ''
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? new Date(value).toISOString() : ''
|
||||
}
|
||||
const parsed = Date.parse(value)
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : value
|
||||
}
|
||||
|
||||
function parseChatIdToIso(chatId: string): string {
|
||||
const iso = chatId.replace(/(\d{4}-\d{2}-\d{2}T\d{2})-(\d{2})-(\d{2})/, '$1:$2:$3')
|
||||
const parsed = Date.parse(iso)
|
||||
return Number.isFinite(parsed) ? new Date(parsed).toISOString() : ''
|
||||
}
|
||||
|
||||
function extractCwd(meta: CodebuffMetadata | undefined): string | null {
|
||||
const rs = meta?.runState
|
||||
if (!rs) return null
|
||||
return (
|
||||
rs.sessionState?.projectContext?.cwd ??
|
||||
rs.sessionState?.fileContext?.cwd ??
|
||||
rs.sessionState?.cwd ??
|
||||
rs.cwd ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function extractAgentType(meta: CodebuffMetadata | undefined): string | null {
|
||||
return meta?.runState?.sessionState?.mainAgentState?.agentType ?? null
|
||||
}
|
||||
|
||||
function collectBlockTools(blocks: CodebuffBlock[] | undefined, acc: { tools: string[]; bash: string[] }): void {
|
||||
if (!Array.isArray(blocks)) return
|
||||
for (const block of blocks) {
|
||||
if (!block || typeof block !== 'object') continue
|
||||
if (block.type === 'tool' && typeof block.toolName === 'string') {
|
||||
const raw = block.toolName
|
||||
if (!IGNORED_TOOLS.has(raw)) {
|
||||
acc.tools.push(toolNameMap[raw] ?? raw)
|
||||
}
|
||||
if ((raw === 'run_terminal_command' || raw === 'terminal') && block.input) {
|
||||
const cmd = block.input['command']
|
||||
if (typeof cmd === 'string') {
|
||||
acc.bash.push(...extractBashCommands(cmd))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (block.type === 'agent' && Array.isArray(block.blocks)) {
|
||||
collectBlockTools(block.blocks, acc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveModel(meta: CodebuffMetadata | undefined, stashedModel: string | null): string {
|
||||
const direct = meta?.model ?? meta?.modelId ?? meta?.codebuff?.model
|
||||
if (direct) return direct
|
||||
if (stashedModel) return stashedModel
|
||||
const agentType = extractAgentType(meta)
|
||||
if (agentType) return `codebuff-${agentType}`
|
||||
return 'codebuff'
|
||||
}
|
||||
|
||||
function usageFromHistory(meta: CodebuffMetadata | undefined): {
|
||||
model: string | null
|
||||
input: number
|
||||
output: number
|
||||
cacheRead: number
|
||||
cacheWrite: number
|
||||
} {
|
||||
const hist = meta?.runState?.sessionState?.mainAgentState?.messageHistory
|
||||
if (!Array.isArray(hist)) return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
for (let i = hist.length - 1; i >= 0; i--) {
|
||||
const entry = hist[i]
|
||||
if (!entry || entry.role !== 'assistant' || !entry.providerOptions) continue
|
||||
const u = normalizeUsage(entry.providerOptions.usage ?? entry.providerOptions.codebuff?.usage)
|
||||
if (u.input > 0 || u.output > 0 || u.cacheRead > 0 || u.cacheWrite > 0) {
|
||||
return { model: entry.providerOptions.codebuff?.model ?? null, ...u }
|
||||
}
|
||||
}
|
||||
return { model: null, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
}
|
||||
|
||||
async function readJson<T>(filePath: string): Promise<T | null> {
|
||||
try {
|
||||
const raw = await readFile(filePath, 'utf-8')
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverChannel(root: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
const projectsDir = join(root, 'projects')
|
||||
|
||||
let projectNames: string[]
|
||||
try {
|
||||
projectNames = await readdir(projectsDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const projectName of projectNames) {
|
||||
const chatsDir = join(projectsDir, projectName, 'chats')
|
||||
let chatIds: string[]
|
||||
try {
|
||||
chatIds = await readdir(chatsDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const chatId of chatIds) {
|
||||
const chatDir = join(chatsDir, chatId)
|
||||
const dirStat = await stat(chatDir).catch(() => null)
|
||||
if (!dirStat?.isDirectory()) continue
|
||||
|
||||
const messagesPath = join(chatDir, 'chat-messages.json')
|
||||
const messagesStat = await stat(messagesPath).catch(() => null)
|
||||
if (!messagesStat?.isFile()) continue
|
||||
|
||||
// Resolve the real cwd from run-state.json so sessions group by the
|
||||
// originating project directory instead of the sanitized chat folder
|
||||
// name (which is often the same for many users).
|
||||
const runState = await readJson<CodebuffMetadata['runState']>(
|
||||
join(chatDir, 'run-state.json'),
|
||||
)
|
||||
const cwd = extractCwd({ runState: runState ?? undefined })
|
||||
const project = cwd ? basename(cwd) : projectName
|
||||
|
||||
sources.push({ path: chatDir, project, provider: 'codebuff' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
async function discoverSessionsInBase(baseDir: string): Promise<SessionSource[]> {
|
||||
const results: SessionSource[] = []
|
||||
|
||||
// Honor an explicit override: walk only the provided directory even if it
|
||||
// matches one of the channel names literally.
|
||||
if (process.env['CODEBUFF_DATA_DIR'] || baseDir !== join(homedir(), '.config', 'manicode')) {
|
||||
const rootStat = await stat(baseDir).catch(() => null)
|
||||
if (!rootStat?.isDirectory()) return results
|
||||
results.push(...await discoverChannel(baseDir))
|
||||
return results
|
||||
}
|
||||
|
||||
const configDir = join(homedir(), '.config')
|
||||
for (const channel of CHANNELS) {
|
||||
const root = join(configDir, channel)
|
||||
const rootStat = await stat(root).catch(() => null)
|
||||
if (!rootStat?.isDirectory()) continue
|
||||
results.push(...await discoverChannel(root))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const chatDir = source.path
|
||||
const chatId = basename(chatDir)
|
||||
const sessionId = `${basename(chatDir)}`
|
||||
const fallbackTs = parseChatIdToIso(chatId)
|
||||
|
||||
const messages = await readJson<CodebuffChatMessage[]>(
|
||||
join(chatDir, 'chat-messages.json'),
|
||||
)
|
||||
if (!Array.isArray(messages)) return
|
||||
|
||||
let pendingUserMessage = ''
|
||||
|
||||
for (const [idx, msg] of messages.entries()) {
|
||||
if (!msg || typeof msg !== 'object') continue
|
||||
|
||||
const variant = msg.variant ?? msg.role
|
||||
if (variant === 'user') {
|
||||
if (typeof msg.content === 'string' && msg.content.length > 0) {
|
||||
pendingUserMessage = msg.content
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (variant !== 'ai' && variant !== 'agent' && variant !== 'assistant') continue
|
||||
|
||||
const credits = typeof msg.credits === 'number' && Number.isFinite(msg.credits) ? msg.credits : 0
|
||||
const directUsage = normalizeUsage(msg.metadata?.usage ?? msg.metadata?.codebuff?.usage)
|
||||
const stashedUsage = usageFromHistory(msg.metadata)
|
||||
|
||||
const hasDirect =
|
||||
directUsage.input > 0 ||
|
||||
directUsage.output > 0 ||
|
||||
directUsage.cacheRead > 0 ||
|
||||
directUsage.cacheWrite > 0
|
||||
const usage = hasDirect ? directUsage : stashedUsage
|
||||
const stashedModel = stashedUsage.model
|
||||
|
||||
// Skip messages with neither credits nor tokens -- they're typically
|
||||
// in-progress mode dividers or empty framing blocks.
|
||||
if (credits === 0 && usage.input === 0 && usage.output === 0 && usage.cacheRead === 0 && usage.cacheWrite === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const model = resolveModel(msg.metadata, stashedModel)
|
||||
const timestamp = coerceTimestamp(msg.timestamp ?? msg.metadata?.timestamp) || fallbackTs
|
||||
|
||||
const dedupId = msg.id ?? String(idx)
|
||||
const dedupKey = `codebuff:${chatDir}:${dedupId}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const acc = { tools: [] as string[], bash: [] as string[] }
|
||||
collectBlockTools(msg.blocks, acc)
|
||||
|
||||
// Prefer calculated cost from tokens when available (multi-provider
|
||||
// models routed through Codebuff still show up in LiteLLM); otherwise
|
||||
// fall back to the credit-based approximation.
|
||||
let costUSD = calculateCost(model, usage.input, usage.output, usage.cacheWrite, usage.cacheRead, 0)
|
||||
if (costUSD === 0 && credits > 0) {
|
||||
costUSD = credits * USD_PER_CREDIT
|
||||
}
|
||||
|
||||
yield {
|
||||
provider: 'codebuff',
|
||||
model,
|
||||
inputTokens: usage.input,
|
||||
outputTokens: usage.output,
|
||||
cacheCreationInputTokens: usage.cacheWrite,
|
||||
cacheReadInputTokens: usage.cacheRead,
|
||||
cachedInputTokens: usage.cacheRead,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: acc.tools,
|
||||
bashCommands: acc.bash,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
}
|
||||
|
||||
pendingUserMessage = ''
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createCodebuffProvider(baseDir?: string): Provider {
|
||||
const dir = getCodebuffBaseDir(baseDir)
|
||||
|
||||
return {
|
||||
name: 'codebuff',
|
||||
displayName: 'Codebuff',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return modelDisplayNames[model] ?? model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInBase(dir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const codebuff = createCodebuffProvider()
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { claude } from './claude.js'
|
||||
import { codebuff } from './codebuff.js'
|
||||
import { codex } from './codex.js'
|
||||
import { copilot } from './copilot.js'
|
||||
import { pi } from './pi.js'
|
||||
|
|
@ -49,7 +50,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, pi]
|
||||
const coreProviders: Provider[] = [claude, codex, codebuff, copilot, pi]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
|
||||
|
|
|
|||
|
|
@ -3,7 +3,25 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'codebuff', 'copilot', 'pi'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
const codebuff = providers.find(p => p.name === 'codebuff')!
|
||||
expect(codebuff.toolDisplayName('read_files')).toBe('Read')
|
||||
expect(codebuff.toolDisplayName('code_search')).toBe('Grep')
|
||||
expect(codebuff.toolDisplayName('str_replace')).toBe('Edit')
|
||||
expect(codebuff.toolDisplayName('run_terminal_command')).toBe('Bash')
|
||||
expect(codebuff.toolDisplayName('spawn_agents')).toBe('Agent')
|
||||
expect(codebuff.toolDisplayName('write_todos')).toBe('TodoWrite')
|
||||
expect(codebuff.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
|
||||
it('codebuff model display names cover known agent tiers', () => {
|
||||
const codebuff = providers.find(p => p.name === 'codebuff')!
|
||||
expect(codebuff.modelDisplayName('codebuff')).toBe('Codebuff')
|
||||
expect(codebuff.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2')
|
||||
expect(codebuff.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
388
tests/providers/codebuff.test.ts
Normal file
388
tests/providers/codebuff.test.ts
Normal file
|
|
@ -0,0 +1,388 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createCodebuffProvider } from '../../src/providers/codebuff.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type ToolBlock = {
|
||||
type: 'tool'
|
||||
toolName: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TextBlock = { type: 'text'; content: string }
|
||||
|
||||
type Block = ToolBlock | TextBlock
|
||||
|
||||
type AiOpts = {
|
||||
id?: string
|
||||
credits?: number
|
||||
timestamp?: string
|
||||
blocks?: Block[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function aiMessage(opts: AiOpts = {}) {
|
||||
const m: Record<string, unknown> = {
|
||||
id: opts.id ?? 'msg-ai-1',
|
||||
variant: 'ai',
|
||||
content: '',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
|
||||
}
|
||||
if (opts.blocks !== undefined) m['blocks'] = opts.blocks
|
||||
if (opts.credits !== undefined) m['credits'] = opts.credits
|
||||
if (opts.metadata !== undefined) m['metadata'] = opts.metadata
|
||||
return m
|
||||
}
|
||||
|
||||
function userMessage(content: string, timestamp?: string) {
|
||||
return {
|
||||
id: 'msg-user-1',
|
||||
variant: 'user',
|
||||
content,
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
async function writeChat(
|
||||
baseDir: string,
|
||||
projectName: string,
|
||||
chatId: string,
|
||||
messages: unknown[],
|
||||
runState?: unknown,
|
||||
): Promise<string> {
|
||||
const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId)
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages))
|
||||
if (runState !== undefined) {
|
||||
await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState))
|
||||
}
|
||||
return chatDir
|
||||
}
|
||||
|
||||
describe('codebuff provider - session discovery', () => {
|
||||
it('discovers sessions under projects/<name>/chats/<chatId>/', async () => {
|
||||
await writeChat(
|
||||
tmpDir,
|
||||
'myproject',
|
||||
'2026-04-14T10-00-00.000Z',
|
||||
[userMessage('hi'), aiMessage({ credits: 10 })],
|
||||
{ sessionState: { projectContext: { cwd: '/Users/test/myproject' } } },
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('codebuff')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z')
|
||||
})
|
||||
|
||||
it('uses the cwd basename from run-state.json when present', async () => {
|
||||
await writeChat(
|
||||
tmpDir,
|
||||
'sanitized-folder',
|
||||
'2026-04-14T11-00-00.000Z',
|
||||
[aiMessage({ credits: 5 })],
|
||||
{ sessionState: { projectContext: { cwd: '/Users/test/real-project' } } },
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('real-project')
|
||||
})
|
||||
|
||||
it('falls back to the folder name when run-state.json is missing', async () => {
|
||||
await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [
|
||||
aiMessage({ credits: 3 }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('fallback-project')
|
||||
})
|
||||
|
||||
it('discovers sessions across multiple projects', async () => {
|
||||
await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })])
|
||||
await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
const projects = sessions.map(s => s.project).sort()
|
||||
expect(projects).toEqual(['proj-a', 'proj-b'])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent directory', async () => {
|
||||
const provider = createCodebuffProvider('/nonexistent/codebuff-path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips chat folders without chat-messages.json', async () => {
|
||||
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
// No chat-messages.json created.
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('codebuff provider - JSONL parsing', () => {
|
||||
it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => {
|
||||
const chatDir = await writeChat(
|
||||
tmpDir,
|
||||
'proj',
|
||||
'2026-04-14T10-00-00.000Z',
|
||||
[
|
||||
userMessage('implement the feature'),
|
||||
aiMessage({
|
||||
credits: 42,
|
||||
metadata: {
|
||||
runState: { sessionState: { mainAgentState: { agentType: 'base2' } } },
|
||||
},
|
||||
blocks: [
|
||||
{ type: 'tool', toolName: 'read_files', input: {} },
|
||||
{ type: 'tool', toolName: 'str_replace', input: {} },
|
||||
{ type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } },
|
||||
{ type: 'tool', toolName: 'suggest_followups', input: {} },
|
||||
],
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('codebuff')
|
||||
expect(call.model).toBe('codebuff-base2')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
// `suggest_followups` is intentionally dropped from the tool breakdown.
|
||||
expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(call.bashCommands).toContain('npm')
|
||||
// Credits × $0.01 = $0.42 when token counts are absent.
|
||||
expect(call.costUSD).toBeCloseTo(0.42, 6)
|
||||
expect(call.inputTokens).toBe(0)
|
||||
expect(call.outputTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('prefers direct metadata.usage tokens when available and still records credits', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({
|
||||
credits: 10,
|
||||
metadata: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
usage: {
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2000,
|
||||
cacheCreationInputTokens: 1000,
|
||||
cacheReadInputTokens: 500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('claude-haiku-4-5-20251001')
|
||||
expect(call.inputTokens).toBe(5000)
|
||||
expect(call.outputTokens).toBe(2000)
|
||||
expect(call.cacheCreationInputTokens).toBe(1000)
|
||||
expect(call.cacheReadInputTokens).toBe(500)
|
||||
expect(call.cachedInputTokens).toBe(500)
|
||||
// With real token counts the calculated cost takes precedence over credits.
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({
|
||||
credits: 7,
|
||||
metadata: {
|
||||
runState: {
|
||||
sessionState: {
|
||||
mainAgentState: {
|
||||
messageHistory: [
|
||||
{ role: 'user' },
|
||||
{
|
||||
role: 'assistant',
|
||||
providerOptions: {
|
||||
codebuff: {
|
||||
model: 'openai/gpt-4o',
|
||||
usage: {
|
||||
prompt_tokens: 2000,
|
||||
completion_tokens: 800,
|
||||
prompt_tokens_details: { cached_tokens: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('openai/gpt-4o')
|
||||
expect(calls[0]!.inputTokens).toBe(2000)
|
||||
expect(calls[0]!.outputTokens).toBe(800)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(400)
|
||||
})
|
||||
|
||||
it('skips assistant messages with no credits and no tokens', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({ id: 'msg-dup', credits: 3 }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const firstRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
firstRun.push(call)
|
||||
}
|
||||
|
||||
const secondRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
secondRun.push(call)
|
||||
}
|
||||
|
||||
expect(firstRun).toHaveLength(1)
|
||||
expect(secondRun).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message in a multi-turn chat, preserving user messages', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
userMessage('first question'),
|
||||
aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }),
|
||||
userMessage('second question', '2026-04-14T10:01:00.000Z'),
|
||||
aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.05, 6)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6)
|
||||
})
|
||||
|
||||
it('handles a missing chat-messages.json gracefully', async () => {
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = {
|
||||
path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'),
|
||||
project: 'missing',
|
||||
provider: 'codebuff',
|
||||
}
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips a malformed chat-messages.json without throwing', async () => {
|
||||
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json')
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('codebuff provider - display names', () => {
|
||||
const provider = createCodebuffProvider('/tmp')
|
||||
|
||||
it('has the correct identifiers', () => {
|
||||
expect(provider.name).toBe('codebuff')
|
||||
expect(provider.displayName).toBe('Codebuff')
|
||||
})
|
||||
|
||||
it('maps known Codebuff tiers to readable names', () => {
|
||||
expect(provider.modelDisplayName('codebuff')).toBe('Codebuff')
|
||||
expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2')
|
||||
expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite')
|
||||
})
|
||||
|
||||
it('returns the raw name for unknown models', () => {
|
||||
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('normalizes tool names to the canonical set', () => {
|
||||
expect(provider.toolDisplayName('read_files')).toBe('Read')
|
||||
expect(provider.toolDisplayName('str_replace')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue