diff --git a/packages/cli/src/providers/cline.ts b/packages/cli/src/providers/cline.ts index 73177064..797f496f 100644 --- a/packages/cli/src/providers/cline.ts +++ b/packages/cli/src/providers/cline.ts @@ -2,8 +2,12 @@ import { stat } from 'fs/promises' import { homedir } from 'os' import { basename, join } from 'path' -import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePath } from './vscode-cline-parser.js' -import type { Provider, SessionSource, SessionParser } from './types.js' +import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' +import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' + +import { createBridgedProvider } from './bridge.js' +import { discoverClineTasks, getVSCodeGlobalStoragePath, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' +import type { Provider, SessionSource } from './types.js' const EXTENSION_ID = 'saoudrizwan.claude-dev' @@ -39,7 +43,7 @@ async function dedupeTaskSources(sources: SessionSource[]): Promise({ name: 'cline', displayName: 'Cline', @@ -64,10 +68,10 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider return dedupeTaskSources(sources.flat()) }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createClineParser(source, seenKeys, 'cline') - }, - } + readRecords: readClineRecords, + decode: input => decodeVscodeCline(input), + toProviderCall: toClineProviderCall, + }) } export const cline = createClineProvider() diff --git a/packages/cli/src/providers/ibm-bob.ts b/packages/cli/src/providers/ibm-bob.ts index 5aec0f65..24779423 100644 --- a/packages/cli/src/providers/ibm-bob.ts +++ b/packages/cli/src/providers/ibm-bob.ts @@ -1,9 +1,13 @@ import { join } from 'path' import { homedir } from 'os' +import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' +import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' + +import { createBridgedProvider } from './bridge.js' import { getShortModelName } from '../models.js' -import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js' -import type { Provider, SessionSource, SessionParser } from './types.js' +import { discoverClineTasksInBaseDirs, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' +import type { Provider, SessionSource } from './types.js' const PROVIDER_NAME = 'ibm-bob' const DISPLAY_NAME = 'IBM Bob' @@ -33,7 +37,7 @@ export function getIBMBobGlobalStorageDirs(): string[] { } export function createIBMBobProvider(overrideDir?: string): Provider { - return { + return createBridgedProvider({ name: PROVIDER_NAME, displayName: DISPLAY_NAME, @@ -50,10 +54,10 @@ export function createIBMBobProvider(overrideDir?: string): Provider { return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL) - }, - } + decode: input => decodeVscodeCline({ ...input, fallbackModel: FALLBACK_MODEL }), + readRecords: readClineRecords, + toProviderCall: toClineProviderCall, + }) } export const ibmBob = createIBMBobProvider() diff --git a/packages/cli/src/providers/roo-code.ts b/packages/cli/src/providers/roo-code.ts index 4059d96a..f1485137 100644 --- a/packages/cli/src/providers/roo-code.ts +++ b/packages/cli/src/providers/roo-code.ts @@ -1,10 +1,14 @@ -import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' -import type { Provider, SessionSource, SessionParser } from './types.js' +import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' +import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' + +import { createBridgedProvider } from './bridge.js' +import { discoverClineTasks, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' +import type { Provider, SessionSource } from './types.js' const EXTENSION_ID = 'rooveterinaryinc.roo-cline' export function createRooCodeProvider(overrideDir?: string | string[]): Provider { - return { + return createBridgedProvider({ name: 'roo-code', displayName: 'Roo Code', @@ -20,10 +24,10 @@ export function createRooCodeProvider(overrideDir?: string | string[]): Provider return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir) }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createClineParser(source, seenKeys, 'roo-code') - }, - } + readRecords: readClineRecords, + decode: input => decodeVscodeCline(input), + toProviderCall: toClineProviderCall, + }) } export const rooCode = createRooCodeProvider() diff --git a/packages/cli/src/providers/vscode-cline-parser.ts b/packages/cli/src/providers/vscode-cline-parser.ts index b94b0803..7f692bbe 100644 --- a/packages/cli/src/providers/vscode-cline-parser.ts +++ b/packages/cli/src/providers/vscode-cline-parser.ts @@ -2,14 +2,10 @@ import { readdir, readFile, stat } from 'fs/promises' import { basename, join, posix, win32 } from 'path' import { homedir } from 'os' -import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' +import type { ClineRecordEnvelope, VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' -type UiMessage = { - type?: string - say?: string - text?: string - ts?: number -} +import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homedir(), platform = process.platform): string[] { const pathJoin = platform === 'win32' ? win32.join : posix.join @@ -87,136 +83,66 @@ async function discoverClineTasksInBaseDir(baseDir: string, providerName: string return sources } -const MODEL_TAG_RE = /([^<]+)<\/model>/ -const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/ - -type HistoryMeta = { model: string; workspace: string | null } - -function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise { - return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8') - .then(raw => { - const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }> - if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null } - let model: string | null = null - let workspace: string | null = null - for (const msg of msgs) { - if (msg.role !== 'user' || !Array.isArray(msg.content)) continue - for (const block of msg.content) { - if (typeof block.text !== 'string') continue - if (!model) { - const mm = MODEL_TAG_RE.exec(block.text) - if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1] - } - if (!workspace) { - const wm = WORKSPACE_DIR_RE.exec(block.text) - if (wm) workspace = wm[1] - } - if (model && workspace) break - } - if (model && workspace) break - } - return { model: model ?? fallbackModel, workspace } - }) - .catch(() => ({ model: fallbackModel, workspace: null })) +/** I/O adapter: read one task directory into the single envelope core decodes. */ +export async function readClineRecords(source: SessionSource): Promise { + const taskDir = source.path + let uiRaw: string + try { + uiRaw = await readFile(join(taskDir, 'ui_messages.json'), 'utf-8') + } catch { + return null // reproduces the early `return` at :133-138 + } + const historyRaw = await readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8') + .catch(() => null) // reproduces the .catch at :120 + const envelope: ClineRecordEnvelope = { kind: 'cline-task', taskId: basename(taskDir), uiRaw, historyRaw } + return [envelope] } -function workspaceToProject(workspace: string): string { - return basename(workspace) || workspace +/** Host-side map: rich call -> ParsedProviderCall. Cost re-enters here. */ +export function toClineProviderCall(rich: VscodeClineDecodedCall): ParsedProviderCall { + return { + provider: rich.provider, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + ...(rich.measuredCostUSD !== undefined + ? { costUSD: rich.measuredCostUSD, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), + tools: rich.tools, + bashCommands: rich.rawBashCommands, + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + project: rich.project, + projectPath: rich.projectPath, + } } -export function createClineParser(source: SessionSource, seenKeys: Set, providerName: string, fallbackModel = 'cline-auto'): SessionParser { +/** + * Legacy-shaped adapter retained for kilo-code, whose second (SQLite) arm does + * not move to core until batch S2. It is I/O + map over the core decode — no + * decode logic lives here. Deleted once kilo-code becomes a bridged provider. + */ +export function createClineParser( + source: SessionSource, + seenKeys: Set, + providerName: string, + fallbackModel = 'cline-auto', +): SessionParser { return { async *parse(): AsyncGenerator { - const taskDir = source.path - const taskId = basename(taskDir) - - let uiRaw: string - try { - uiRaw = await readFile(join(taskDir, 'ui_messages.json'), 'utf-8') - } catch { - return - } - - let uiMessages: UiMessage[] - try { - uiMessages = JSON.parse(uiRaw) - } catch { - return - } - - if (!Array.isArray(uiMessages)) return - - const meta = await extractHistoryMeta(taskDir, fallbackModel) - const model = meta.model - const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined - const projectPath = meta.workspace ?? undefined - - let userMessage = '' - for (const msg of uiMessages) { - if (msg.type === 'say' && (msg.say === 'user_feedback' || msg.say === 'text')) { - userMessage = (msg.text ?? '').slice(0, 500) - break - } - } - - const apiReqEntries = uiMessages.filter(m => m.type === 'say' && m.say === 'api_req_started') - - for (const [index, entry] of apiReqEntries.entries()) { - const dedupKey = `${providerName}:${taskId}:${index}` - if (seenKeys.has(dedupKey)) continue - seenKeys.add(dedupKey) - - let tokensIn = 0 - let tokensOut = 0 - let cacheReads = 0 - let cacheWrites = 0 - let cost: number | undefined - - if (entry.text) { - try { - const parsed = JSON.parse(entry.text) as { - tokensIn?: number - tokensOut?: number - cacheReads?: number - cacheWrites?: number - cost?: number - } - tokensIn = parsed.tokensIn ?? 0 - tokensOut = parsed.tokensOut ?? 0 - cacheReads = parsed.cacheReads ?? 0 - cacheWrites = parsed.cacheWrites ?? 0 - cost = parsed.cost - } catch {} - } - - if (tokensIn === 0 && tokensOut === 0) continue - - const timestamp = entry.ts ? new Date(entry.ts).toISOString() : '' - - yield { - provider: providerName, - model, - inputTokens: tokensIn, - outputTokens: tokensOut, - cacheCreationInputTokens: cacheWrites, - cacheReadInputTokens: cacheReads, - cachedInputTokens: cacheReads, - reasoningTokens: 0, - webSearchRequests: 0, - ...(cost != null - ? { costUSD: cost, costBasis: 'measured' as const } - : { costBasis: 'estimated' as const }), - tools: [], - bashCommands: [], - timestamp, - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: index === 0 ? userMessage : '', - sessionId: taskId, - project, - projectPath, - } - } + const records = await readClineRecords(source) + if (records === null) return + const context = { privacyKey: '', providerId: providerName, sourceRef: source.path } + const { calls } = decodeVscodeCline({ records, context, seenKeys, fallbackModel }) + for (const rich of calls) yield toClineProviderCall(rich) }, } } diff --git a/packages/cli/tests/providers/vscode-cline-shared-bridge.test.ts b/packages/cli/tests/providers/vscode-cline-shared-bridge.test.ts new file mode 100644 index 00000000..9a644c3a --- /dev/null +++ b/packages/cli/tests/providers/vscode-cline-shared-bridge.test.ts @@ -0,0 +1,559 @@ +// These goldens were captured from the pre-migration in-CLI decode and serve as +// the byte-parity gate for the vscode-cline shared bridge migration. Do not +// change the expected arrays except to add new coverage mandated by the brief. + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { cline } from '../../src/providers/cline.js' +import { ibmBob } from '../../src/providers/ibm-bob.js' +import { rooCode } from '../../src/providers/roo-code.js' +import { kiloCode } from '../../src/providers/kilo-code.js' +import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'vscode-cline-shared-bridge-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +async function writeTask( + baseDir: string, + taskId: string, + opts: { + uiMessages?: unknown[] + uiRaw?: string + history?: unknown[] + historyRaw?: string + skipUi?: boolean + skipHistory?: boolean + }, +): Promise { + const taskDir = join(baseDir, 'tasks', taskId) + await mkdir(taskDir, { recursive: true }) + + if (!opts.skipUi) { + const uiRaw = opts.uiRaw ?? JSON.stringify(opts.uiMessages ?? []) + await writeFile(join(taskDir, 'ui_messages.json'), uiRaw) + } + + if (!opts.skipHistory) { + const historyRaw = opts.historyRaw ?? JSON.stringify(opts.history ?? []) + await writeFile(join(taskDir, 'api_conversation_history.json'), historyRaw) + } + + return taskDir +} + +async function makeSources(providerName: string): Promise { + const baseDir = join(tmpDir, providerName) + + // G1 (model slash-strip), G3 (workspace), G8 (measured cost), + // G9 (estimated cost), G11 (userMessage on index 0 only), + // and index-is-apiReqEntries-index (interleaved non-api message). + await writeTask(baseDir, 'task-main', { + uiMessages: [ + { type: 'say', say: 'user_feedback', text: 'hello world', ts: 1_700_000_000_000 }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 200, tokensOut: 100, cacheReads: 50, cacheWrites: 30, cost: 0.05 }), + ts: 1_700_000_001_000, + }, + { type: 'say', say: 'text', text: 'interleaved note', ts: 1_700_000_001_500 }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 10, tokensOut: 5 }), + ts: 1_700_000_002_000, + }, + ], + history: [ + { + role: 'user', + content: [ + { + type: 'text', + text: 'hello\n\nanthropic/claude-sonnet-4-6\nCurrent Workspace Directory (/home/user/projects/acme-corp)\n', + }, + ], + }, + ], + }) + + // G1 (model without workspace marker) + G4 (project absent). + await writeTask(baseDir, 'task-model-only', { + uiMessages: [ + { type: 'say', say: 'text', text: 'model only fixture', ts: 1_700_000_000_000 }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 20, tokensOut: 10 }), + ts: 1_700_000_003_000, + }, + ], + history: [ + { + role: 'user', + content: [{ type: 'text', text: '\nanthropic/claude-opus-5\n' }], + }, + ], + }) + + // G2 + G5: history file missing entirely -> fallback model, no workspace. + await writeTask(baseDir, 'task-nohistory', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 15, tokensOut: 7 }), + ts: 1_700_000_004_000, + }, + ], + skipHistory: true, + }) + + // G2 + G6: history file malformed JSON -> fallback model, no workspace. + await writeTask(baseDir, 'task-badhistory-json', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 16, tokensOut: 8 }), + ts: 1_700_000_005_000, + }, + ], + historyRaw: 'not valid json', + }) + + // G2 + G7: history parses to non-array -> fallback model, no workspace. + await writeTask(baseDir, 'task-badhistory-shape', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 17, tokensOut: 9 }), + ts: 1_700_000_006_000, + }, + ], + historyRaw: JSON.stringify({ not: 'an array' }), + }) + + // G16: ts absent -> timestamp ''. + await writeTask(baseDir, 'task-nots', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 18, tokensOut: 10 }), + }, + ], + }) + + // G10: zero-token entry skipped even with cache buckets. + // G17: the zero-token index 0 burns its dedup key. + await writeTask(baseDir, 'task-zerotoken', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 0, tokensOut: 0, cacheReads: 7, cacheWrites: 3 }), + ts: 1_700_000_007_000, + }, + { type: 'say', say: 'text', text: 'interleaved in zero-token fixture', ts: 1_700_000_007_500 }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 5, tokensOut: 2 }), + ts: 1_700_000_008_000, + }, + ], + }) + + // G15: entry text is malformed JSON -> tokens stay 0 -> skipped. + await writeTask(baseDir, 'task-malformedtext', { + uiMessages: [{ type: 'say', say: 'api_req_started', text: 'not valid json', ts: 1_700_000_009_000 }], + }) + + // G12: ui_messages.json missing -> yields nothing. + await writeTask(baseDir, 'task-noui', { + skipUi: true, + history: [], + }) + + // G13: ui_messages.json malformed JSON -> yields nothing. + await writeTask(baseDir, 'task-badui-json', { + uiRaw: 'not valid json', + history: [], + }) + + // G14: ui_messages.json parses to non-array -> yields nothing. + await writeTask(baseDir, 'task-badui-shape', { + uiRaw: JSON.stringify({ not: 'an array' }), + history: [], + }) + + const ids = [ + 'task-main', + 'task-model-only', + 'task-nohistory', + 'task-badhistory-json', + 'task-badhistory-shape', + 'task-nots', + 'task-zerotoken', + 'task-malformedtext', + 'task-noui', + 'task-badui-json', + 'task-badui-shape', + ] + + return ids.map(id => ({ + path: join(baseDir, 'tasks', id), + project: providerName, + provider: providerName, + })) +} + +async function collect(providerName: string, source: SessionSource, seenKeys: Set): Promise { + const calls: ParsedProviderCall[] = [] + let parser: ReturnType + switch (providerName) { + case 'cline': + parser = cline.createSessionParser(source, seenKeys) + break + case 'ibm-bob': + parser = ibmBob.createSessionParser(source, seenKeys) + break + case 'roo-code': + parser = rooCode.createSessionParser(source, seenKeys) + break + case 'kilo-code': + parser = kiloCode.createSessionParser(source, seenKeys) + break + default: + throw new Error(`unknown provider ${providerName}`) + } + for await (const call of parser.parse()) calls.push(call) + return calls +} + +async function collectAll(providerName: string, sources: SessionSource[]): Promise { + const seenKeys = new Set() + const all: ParsedProviderCall[] = [] + for (const source of sources) { + const calls = await collect(providerName, source, seenKeys) + all.push(...calls) + } + return all +} + +function goldenFor(providerName: string, fallbackModel: string): ParsedProviderCall[] { + return [ + { + provider: providerName, + model: 'claude-sonnet-4-6', + inputTokens: 200, + outputTokens: 100, + cacheCreationInputTokens: 30, + cacheReadInputTokens: 50, + cachedInputTokens: 50, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.05, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:21.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-main:0`, + userMessage: 'hello world', + sessionId: 'task-main', + project: 'acme-corp', + projectPath: '/home/user/projects/acme-corp', + }, + { + provider: providerName, + model: 'claude-sonnet-4-6', + inputTokens: 10, + outputTokens: 5, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:22.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-main:1`, + userMessage: '', + sessionId: 'task-main', + project: 'acme-corp', + projectPath: '/home/user/projects/acme-corp', + }, + { + provider: providerName, + model: 'claude-opus-5', + inputTokens: 20, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:23.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-model-only:0`, + userMessage: 'model only fixture', + sessionId: 'task-model-only', + project: undefined, + projectPath: undefined, + }, + { + provider: providerName, + model: fallbackModel, + inputTokens: 15, + outputTokens: 7, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:24.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-nohistory:0`, + userMessage: '', + sessionId: 'task-nohistory', + project: undefined, + projectPath: undefined, + }, + { + provider: providerName, + model: fallbackModel, + inputTokens: 16, + outputTokens: 8, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:25.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-badhistory-json:0`, + userMessage: '', + sessionId: 'task-badhistory-json', + project: undefined, + projectPath: undefined, + }, + { + provider: providerName, + model: fallbackModel, + inputTokens: 17, + outputTokens: 9, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:26.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-badhistory-shape:0`, + userMessage: '', + sessionId: 'task-badhistory-shape', + project: undefined, + projectPath: undefined, + }, + { + provider: providerName, + model: fallbackModel, + inputTokens: 18, + outputTokens: 10, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '', + speed: 'standard', + deduplicationKey: `${providerName}:task-nots:0`, + userMessage: '', + sessionId: 'task-nots', + project: undefined, + projectPath: undefined, + }, + { + provider: providerName, + model: fallbackModel, + inputTokens: 5, + outputTokens: 2, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costBasis: 'estimated', + tools: [], + bashCommands: [], + timestamp: '2023-11-14T22:13:28.000Z', + speed: 'standard', + deduplicationKey: `${providerName}:task-zerotoken:1`, + userMessage: '', + sessionId: 'task-zerotoken', + project: undefined, + projectPath: undefined, + }, + ] satisfies ParsedProviderCall[] +} + +const GOLDEN_CLINE = goldenFor('cline', 'cline-auto') +const GOLDEN_IBM_BOB = goldenFor('ibm-bob', 'ibm-bob-auto') +const GOLDEN_ROO_CODE = goldenFor('roo-code', 'cline-auto') +const GOLDEN_KILO_CODE = goldenFor('kilo-code', 'cline-auto') + +// `toEqual` treats an absent key and a present-but-`undefined` key as equal, so +// the goldens above cannot see a key-shape regression. These are the arms where +// presence is load-bearing: `costUSD` is conditionally spread (absent when +// estimated) while `project` / `projectPath` are written unconditionally +// (present-with-`undefined` when there is no workspace marker). +function expectGoldenKeyShape(calls: ParsedProviderCall[]): void { + const measured = calls.find(c => c.costBasis === 'measured')! + const estimated = calls.find(c => c.costBasis === 'estimated')! + expect(Object.keys(measured)).toContain('costUSD') + expect(Object.keys(estimated)).not.toContain('costUSD') + for (const call of calls) { + expect(Object.keys(call)).toContain('project') + expect(Object.keys(call)).toContain('projectPath') + } +} + +describe('vscode-cline shared bridge - goldens (unmodified code)', () => { + it('pins cline output byte-for-byte', async () => { + const sources = await makeSources('cline') + const calls = await collectAll('cline', sources) + expect(calls).toEqual(GOLDEN_CLINE) + expectGoldenKeyShape(calls) + }) + + it('pins ibm-bob output byte-for-byte', async () => { + const sources = await makeSources('ibm-bob') + const calls = await collectAll('ibm-bob', sources) + expect(calls).toEqual(GOLDEN_IBM_BOB) + expectGoldenKeyShape(calls) + }) + + it('pins roo-code output byte-for-byte', async () => { + const sources = await makeSources('roo-code') + const calls = await collectAll('roo-code', sources) + expect(calls).toEqual(GOLDEN_ROO_CODE) + expectGoldenKeyShape(calls) + }) + + it('pins kilo-code output byte-for-byte', async () => { + const sources = await makeSources('kilo-code') + const calls = await collectAll('kilo-code', sources) + expect(calls).toEqual(GOLDEN_KILO_CODE) + expectGoldenKeyShape(calls) + }) +}) + +describe('vscode-cline shared bridge - kilo-code dispatch', () => { + it('still routes .db: sources to the SQLite arm instead of createClineParser', async () => { + const baseDir = join(tmpDir, 'kilo-code-sqlite-dispatch') + const taskDir = await writeTask(baseDir, 'task-sqlite', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 99, tokensOut: 99 }), + ts: 1_700_000_000_000, + }, + ], + }) + + const dbSource: SessionSource = { path: `${taskDir}.db:session-id`, project: 'kilo-code', provider: 'kilo-code' } + const seenKeys = new Set() + const parser = kiloCode.createSessionParser(dbSource, seenKeys) + const calls: ParsedProviderCall[] = [] + + // Positive proof the SQLite arm ran: its pre-existing open-failure path (in + // the untouched sqlite-session-parser.ts) writes this line for a database + // that does not exist. Capturing it also keeps the line out of test output. + const written: string[] = [] + const writeSpy = vi.spyOn(process.stderr, 'write').mockImplementation(chunk => { + written.push(String(chunk)) + return true + }) + try { + for await (const call of parser.parse()) calls.push(call) + } catch { + // The SQLite arm is allowed to fail for a non-existent database; the point + // is that it must not have produced the cline-arm golden for this source. + } finally { + writeSpy.mockRestore() + } + + expect(written.join('')).toContain('cannot open KiloCode database') + // If the source had gone through createClineParser, it would have yielded a + // call with sessionId 'task-sqlite' and these token counts. + expect(calls).toEqual([]) + expect(seenKeys.has('kilo-code:task-sqlite:0')).toBe(false) + }) +}) + +describe('vscode-cline shared bridge - dedup arms', () => { + it('burns dedup key for zero-token entries and deduplicates across runs', async () => { + for (const providerName of ['cline', 'ibm-bob', 'roo-code', 'kilo-code'] as const) { + const baseDir = join(tmpDir, providerName, 'dedup') + const taskDir = await writeTask(baseDir, 'task-dedup', { + uiMessages: [ + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 0, tokensOut: 0, cacheReads: 7, cacheWrites: 3 }), + ts: 1_700_000_010_000, + }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 25, tokensOut: 12 }), + ts: 1_700_000_011_000, + }, + ], + }) + + const source: SessionSource = { path: taskDir, project: providerName, provider: providerName } + const seenKeys = new Set() + + const first = await collect(providerName, source, seenKeys) + expect(first).toHaveLength(1) + expect(first[0]!.deduplicationKey).toBe(`${providerName}:task-dedup:1`) + expect(seenKeys.has(`${providerName}:task-dedup:0`)).toBe(true) + expect(seenKeys.has(`${providerName}:task-dedup:1`)).toBe(true) + + const second = await collect(providerName, source, seenKeys) + expect(second).toHaveLength(0) + } + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 9d8719a3..5ff4c7e3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -142,6 +142,10 @@ "./providers/copilot": { "types": "./dist/providers/copilot/index.d.ts", "import": "./dist/providers/copilot/index.js" + }, + "./providers/vscode-cline": { + "types": "./dist/providers/vscode-cline/index.d.ts", + "import": "./dist/providers/vscode-cline/index.js" } }, "files": [ diff --git a/packages/core/src/providers/vscode-cline/decode.ts b/packages/core/src/providers/vscode-cline/decode.ts new file mode 100644 index 00000000..adacbfd1 --- /dev/null +++ b/packages/core/src/providers/vscode-cline/decode.ts @@ -0,0 +1,167 @@ +// @codeburn/core vscode-cline decoder: pure decode over host-supplied task +// envelopes. The host reads ui_messages.json and api_conversation_history.json; +// this decoder performs no I/O. + +import { basename } from 'node:path' + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { ClineHistoryMessage, ClineRecordEnvelope, ClineUiMessage, VscodeClineDecodedCall } from './types.js' + +const MODEL_TAG_RE = /([^<]+)<\/model>/ +const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/ + +type HistoryMeta = { model: string; workspace: string | null } + +function extractHistoryMeta(historyRaw: string | null, fallbackModel: string): HistoryMeta { + if (historyRaw === null) return { model: fallbackModel, workspace: null } + try { + const msgs = JSON.parse(historyRaw) as ClineHistoryMessage[] + if (!Array.isArray(msgs)) return { model: fallbackModel, workspace: null } + let model: string | null = null + let workspace: string | null = null + for (const msg of msgs) { + if (msg.role !== 'user' || !Array.isArray(msg.content)) continue + for (const block of msg.content) { + if (typeof block.text !== 'string') continue + if (!model) { + const mm = MODEL_TAG_RE.exec(block.text) + if (mm) model = mm[1].includes('/') ? mm[1].split('/').pop()! : mm[1] + } + if (!workspace) { + const wm = WORKSPACE_DIR_RE.exec(block.text) + if (wm) workspace = wm[1] + } + if (model && workspace) break + } + if (model && workspace) break + } + return { model: model ?? fallbackModel, workspace } + } catch { + return { model: fallbackModel, workspace: null } + } +} + +function workspaceToProject(workspace: string): string { + return basename(workspace) || workspace +} + +export interface VscodeClineDecodeInput { + records: unknown[] + context: DecodeContext + seenKeys?: Set + /** Per-consumer knob. Defaults to 'cline-auto'; ibm-bob passes 'ibm-bob-auto'. */ + fallbackModel?: string +} + +export interface VscodeClineDecodeResult { + calls: VscodeClineDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +/** + * Decode vscode-cline task envelopes into rich, cost-free-or-measured calls. + * Dedup is keyed on `::` against the live + * `seenKeys` set (host-owned). A zero-token entry burns its dedup key before it + * is skipped, matching the pre-migration behavior. + */ +export function decodeVscodeCline(input: VscodeClineDecodeInput): VscodeClineDecodeResult { + const seenKeys = input.seenKeys ?? new Set() + const providerName = input.context.providerId + const fallbackModel = input.fallbackModel ?? 'cline-auto' + const calls: VscodeClineDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + for (const [index, raw] of input.records.entries()) { + const envelope = raw as ClineRecordEnvelope + if (!envelope || envelope.kind !== 'cline-task') { + diagnostics.push({ index, code: 'unknown-shape' }) + continue + } + + let uiMessages: ClineUiMessage[] + try { + uiMessages = JSON.parse(envelope.uiRaw) + } catch { + diagnostics.push({ index, code: 'malformed-json' }) + continue + } + + if (!Array.isArray(uiMessages)) { + diagnostics.push({ index, code: 'unknown-shape' }) + continue + } + + const meta = extractHistoryMeta(envelope.historyRaw, fallbackModel) + const model = meta.model + const project = meta.workspace ? workspaceToProject(meta.workspace) : undefined + const projectPath = meta.workspace ?? undefined + + let userMessage = '' + for (const msg of uiMessages) { + if (msg.type === 'say' && (msg.say === 'user_feedback' || msg.say === 'text')) { + userMessage = (msg.text ?? '').slice(0, 500) + break + } + } + + const apiReqEntries = uiMessages.filter(m => m.type === 'say' && m.say === 'api_req_started') + + for (const [entryIndex, entry] of apiReqEntries.entries()) { + const dedupKey = `${providerName}:${envelope.taskId}:${entryIndex}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + let tokensIn = 0 + let tokensOut = 0 + let cacheReads = 0 + let cacheWrites = 0 + let cost: number | undefined + + if (entry.text) { + try { + const parsed = JSON.parse(entry.text) as { + tokensIn?: number + tokensOut?: number + cacheReads?: number + cacheWrites?: number + cost?: number + } + tokensIn = parsed.tokensIn ?? 0 + tokensOut = parsed.tokensOut ?? 0 + cacheReads = parsed.cacheReads ?? 0 + cacheWrites = parsed.cacheWrites ?? 0 + cost = parsed.cost + } catch {} + } + + if (tokensIn === 0 && tokensOut === 0) continue + + const timestamp = entry.ts ? new Date(entry.ts).toISOString() : '' + + calls.push({ + provider: providerName, + model, + inputTokens: tokensIn, + outputTokens: tokensOut, + cacheCreationInputTokens: cacheWrites, + cacheReadInputTokens: cacheReads, + cachedInputTokens: cacheReads, + reasoningTokens: 0, + webSearchRequests: 0, + ...(cost != null ? { measuredCostUSD: cost } : {}), + tools: [], + rawBashCommands: [], + timestamp, + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: entryIndex === 0 ? userMessage : '', + sessionId: envelope.taskId, + project, + projectPath, + }) + } + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/vscode-cline/index.ts b/packages/core/src/providers/vscode-cline/index.ts new file mode 100644 index 00000000..5f181a0e --- /dev/null +++ b/packages/core/src/providers/vscode-cline/index.ts @@ -0,0 +1,26 @@ +// @codeburn/core vscode-cline provider. +// +// Two layers: +// - Rich pure decode (`decodeVscodeCline`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied task envelopes. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeVscodeCline, + type VscodeClineDecodeInput, + type VscodeClineDecodeResult, +} from './decode.js' + +export { + toObservations, + type RichVscodeClineSessionDecode, + type VscodeClineToObservationsContext, +} from './observations.js' + +export type { + ClineRecordEnvelope, + ClineUiMessage, + ClineHistoryMessage, + VscodeClineDecodedCall, +} from './types.js' diff --git a/packages/core/src/providers/vscode-cline/observations.ts b/packages/core/src/providers/vscode-cline/observations.ts new file mode 100644 index 00000000..66667e2b --- /dev/null +++ b/packages/core/src/providers/vscode-cline/observations.ts @@ -0,0 +1,84 @@ +// Minimizing transform: rich vscode-cline decode -> the strict observation +// envelope. Only opaque ids, fingerprints, enums, numbers, timestamps, and +// canonical tool names cross into the output. The user's message, workspace +// path, and any raw command text stay in the rich decode and never reach here. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { VscodeClineDecodedCall } from './types.js' + +/** One vscode-cline session's rich decode, as the host holds it before minimization. */ +export interface RichVscodeClineSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + /** Rich calls in decode order. */ + calls: VscodeClineDecodedCall[] +} + +export interface VscodeClineToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: VscodeClineDecodedCall, turnIndex: number): CallObservation { + const measured = call.measuredCostUSD !== undefined + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: measured ? 'measured' : 'estimated', + ...(measured ? { measuredCostUSD: call.measuredCostUSD } : {}), + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation(decode: RichVscodeClineSessionDecode, ctx: VscodeClineToObservationsContext): SessionObservation { + const provider = ctx.provider ?? 'cline' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + const session: SessionObservation = { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } + return session +} + +/** + * Map a rich vscode-cline decode (one or many sessions) into the minimized + * observation layer. Returns the `sessions` array plus any per-record + * `diagnostics`. + */ +export function toObservations( + decode: RichVscodeClineSessionDecode | RichVscodeClineSessionDecode[], + ctx: VscodeClineToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/vscode-cline/types.ts b/packages/core/src/providers/vscode-cline/types.ts new file mode 100644 index 00000000..bb73b945 --- /dev/null +++ b/packages/core/src/providers/vscode-cline/types.ts @@ -0,0 +1,52 @@ +// Raw record + rich-decode types for the vscode-cline family (Cline, Roo Code, +// Kilo Code, IBM Bob). The host reads the task directory and hands core a single +// envelope per task; no fs/env/clock here. + +/** One ui_messages.json entry. */ +export type ClineUiMessage = { + type?: string + say?: string + text?: string + ts?: number +} + +/** One api_conversation_history.json message. */ +export type ClineHistoryMessage = { + role?: string + content?: Array<{ text?: string }> +} + +/** The single record the host hands to the core decoder. */ +export type ClineRecordEnvelope = { + kind: 'cline-task' + /** basename(taskDir) — the host resolves it; core never touches paths. */ + taskId: string + /** Raw contents of ui_messages.json. Parsed in core. */ + uiRaw: string + /** Raw contents of api_conversation_history.json, or null when unreadable. */ + historyRaw: string | null +} + +/** The rich decode of one vscode-cline api_req_started entry, pre-pricing. */ +export interface VscodeClineDecodedCall { + provider: string + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + /** Provider-reported dollars from the api_req_started payload; host maps to costBasis 'measured'. */ + measuredCostUSD?: number + tools: string[] + rawBashCommands: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + userMessage: string + sessionId: string + project?: string + projectPath?: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index fa0bd6d2..d74564a2 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -169,6 +169,8 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/devin/types.ts', 'src/providers/copilot/decode.ts', 'src/providers/copilot/types.ts', + 'src/providers/vscode-cline/decode.ts', + 'src/providers/vscode-cline/types.ts', ]) describe('architecture gate: no classification or free text in @codeburn/core source', () => { diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index edc73212..af0a3911 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -35,6 +35,7 @@ import { decodeCursorAgent, toObservations as toCursorAgentObservations } from ' import { decodeQuickdesk, toObservations as toQuickdeskObservations } from '../src/providers/quickdesk/index.js' import { decodeDevin, toObservations as toDevinObservations } from '../src/providers/devin/index.js' import { decodeCopilot, toObservations as toCopilotObservations } from '../src/providers/copilot/index.js' +import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' @@ -373,6 +374,67 @@ describe('content-smuggling guardrail: real qwen decode -> toObservations is sec }) }) +describe('content-smuggling guardrail: real vscode-cline decode -> toObservations is secret-free', () => { + // A hostile vscode-cline task planting every secret in the free-text fields the + // decode captures: the user message, the workspace path, and raw history text. + // The observation envelope MUST surface none of them. + const vscodeClineContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'cline', sourceRef: 'ref' } + + function decodeAndMinimize() { + const uiRaw = JSON.stringify([ + { + type: 'say', + say: 'text', + text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, + }, + { + type: 'say', + say: 'api_req_started', + text: JSON.stringify({ tokensIn: 500, tokensOut: 200 }), + ts: 1_700_000_000_000, + }, + ]) + const historyRaw = JSON.stringify([ + { + role: 'user', + content: [ + { + type: 'text', + text: `\nanthropic/claude-sonnet-4-6\nCurrent Workspace Directory (${SECRETS.absPath})\n${SECRETS.commandLine}\n`, + }, + ], + }, + ]) + const records = [{ + kind: 'cline-task' as const, + taskId: 'sess-hostile', + uiRaw, + historyRaw, + }] + const { calls } = decodeVscodeCline({ records, context: vscodeClineContext }) + const { sessions } = toVscodeClineObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'cline' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile task', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) +}) + describe('content-smuggling guardrail: real grok decode -> toObservations is secret-free', () => { // A hostile Grok session planting every secret in the free-text fields the // decode captures: the project path, the user message (session summary/title), diff --git a/packages/core/tests/providers/vscode-cline-decode.test.ts b/packages/core/tests/providers/vscode-cline-decode.test.ts new file mode 100644 index 00000000..5654f1ef --- /dev/null +++ b/packages/core/tests/providers/vscode-cline-decode.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, it } from 'vitest' + +import { decodeVscodeCline, toObservations } from '../../src/providers/vscode-cline/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { ClineRecordEnvelope } from '../../src/providers/vscode-cline/index.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'cline', sourceRef: 'ref' } + +function envelope(overrides: Partial = {}): ClineRecordEnvelope { + return { + kind: 'cline-task', + taskId: 'task-a', + uiRaw: JSON.stringify([]), + historyRaw: null, + ...overrides, + } +} + +function uiMessage(overrides: { type?: string; say?: string; text?: string; ts?: number } = {}): unknown { + return { type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 10, tokensOut: 5 }), ts: 1_700_000_000_000, ...overrides } +} + +function historyWithModel(model: string, workspace?: string): string { + const workspaceLine = workspace ? `Current Workspace Directory (${workspace})` : '' + return JSON.stringify([ + { + role: 'user', + content: [{ type: 'text', text: `\n${model}\n${workspaceLine}\n` }], + }, + ]) +} + +describe('vscode-cline rich decode (moved to @codeburn/core)', () => { + it('extracts and slash-strips the model from history', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ historyRaw: historyWithModel('anthropic/claude-sonnet-4-6', '/home/user/acme'), uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.model).toBe('claude-sonnet-4-6') + }) + + it('uses the fallback model when no tag is present', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ historyRaw: JSON.stringify([{ role: 'user', content: [{ type: 'text', text: 'hello' }] }]), uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + expect(calls[0]!.model).toBe('cline-auto') + }) + + it('honors the fallbackModel knob', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage()]) })], + context: { ...context, providerId: 'ibm-bob' }, + fallbackModel: 'ibm-bob-auto', + }) + expect(calls[0]!.model).toBe('ibm-bob-auto') + }) + + it('derives project / projectPath from the workspace marker', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ historyRaw: historyWithModel('claude-3', '/home/user/projects/acme-corp'), uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + expect(calls[0]!.project).toBe('acme-corp') + expect(calls[0]!.projectPath).toBe('/home/user/projects/acme-corp') + }) + + it('leaves project / projectPath undefined when there is no workspace marker', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ historyRaw: historyWithModel('claude-3'), uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + expect(calls[0]!.project).toBeUndefined() + expect(calls[0]!.projectPath).toBeUndefined() + }) + + it('falls back to the default model when history is missing', () => { + const { calls } = decodeVscodeCline({ records: [envelope({ historyRaw: null, uiRaw: JSON.stringify([uiMessage()]) })], context }) + expect(calls[0]!.model).toBe('cline-auto') + expect(calls[0]!.project).toBeUndefined() + }) + + it('falls back to the default model when history is malformed JSON', () => { + const { calls } = decodeVscodeCline({ records: [envelope({ historyRaw: 'not json', uiRaw: JSON.stringify([uiMessage()]) })], context }) + expect(calls[0]!.model).toBe('cline-auto') + }) + + it('falls back to the default model when history parses to a non-array', () => { + const { calls } = decodeVscodeCline({ records: [envelope({ historyRaw: JSON.stringify({ not: 'array' }), uiRaw: JSON.stringify([uiMessage()]) })], context }) + expect(calls[0]!.model).toBe('cline-auto') + }) + + it('emits measuredCostUSD when cost is present', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage({ text: JSON.stringify({ tokensIn: 10, tokensOut: 5, cost: 0.042 }) })]) })], + context, + }) + expect(calls[0]).toHaveProperty('measuredCostUSD', 0.042) + }) + + it('omits measuredCostUSD when cost is absent', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + expect(calls[0]).not.toHaveProperty('measuredCostUSD') + }) + + it('omits measuredCostUSD when cost is null', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage({ text: JSON.stringify({ tokensIn: 10, tokensOut: 5, cost: null }) })]) })], + context, + }) + expect(calls[0]).not.toHaveProperty('measuredCostUSD') + }) + + it('skips zero-token entries even when cache buckets are non-zero', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage({ text: JSON.stringify({ tokensIn: 0, tokensOut: 0, cacheReads: 7, cacheWrites: 3 }) })]) })], + context, + }) + expect(calls).toEqual([]) + }) + + it('burns the dedup key for a skipped zero-token entry (G17)', () => { + const seenKeys = new Set() + decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage({ text: JSON.stringify({ tokensIn: 0, tokensOut: 0 }) })]) })], + context, + seenKeys, + }) + expect(seenKeys.has('cline:task-a:0')).toBe(true) + }) + + it('uses the apiReqEntries index, not the uiMessages index, for dedup keys', () => { + const seenKeys = new Set() + const { calls } = decodeVscodeCline({ + records: [envelope({ + uiRaw: JSON.stringify([ + { type: 'say', say: 'text', text: 'interleaved' }, + uiMessage({ text: JSON.stringify({ tokensIn: 10, tokensOut: 5 }) }), + { type: 'say', say: 'text', text: 'interleaved again' }, + uiMessage({ text: JSON.stringify({ tokensIn: 20, tokensOut: 10 }) }), + ]), + })], + context, + seenKeys, + }) + expect(calls).toHaveLength(2) + expect(calls[0]!.deduplicationKey).toBe('cline:task-a:0') + expect(calls[1]!.deduplicationKey).toBe('cline:task-a:1') + }) + + it('carries userMessage only on the first api_req entry', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ + uiRaw: JSON.stringify([ + { type: 'say', say: 'text', text: 'first user message' }, + uiMessage({ text: JSON.stringify({ tokensIn: 10, tokensOut: 5 }) }), + uiMessage({ text: JSON.stringify({ tokensIn: 20, tokensOut: 10 }) }), + ]), + })], + context, + }) + expect(calls[0]!.userMessage).toBe('first user message') + expect(calls[1]!.userMessage).toBe('') + }) + + it('slices userMessage to 500 characters', () => { + const longMessage = 'x'.repeat(600) + const { calls } = decodeVscodeCline({ + records: [envelope({ + uiRaw: JSON.stringify([ + { type: 'say', say: 'text', text: longMessage }, + uiMessage(), + ]), + })], + context, + }) + expect(calls[0]!.userMessage).toHaveLength(500) + }) + + it('uses an empty timestamp when ts is absent', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage({ ts: undefined })]) })], + context, + }) + expect(calls[0]!.timestamp).toBe('') + }) + + it('returns no calls and a malformed-json diagnostic when uiRaw is invalid JSON', () => { + const { calls, diagnostics } = decodeVscodeCline({ records: [envelope({ uiRaw: 'not json' })], context }) + expect(calls).toEqual([]) + expect(diagnostics).toEqual([{ index: 0, code: 'malformed-json' }]) + }) + + it('returns no calls and an unknown-shape diagnostic when uiRaw parses to a non-array', () => { + const { calls, diagnostics } = decodeVscodeCline({ records: [envelope({ uiRaw: JSON.stringify({ not: 'array' }) })], context }) + expect(calls).toEqual([]) + expect(diagnostics).toEqual([{ index: 0, code: 'unknown-shape' }]) + }) + + it('returns no calls and an unknown-shape diagnostic when a record is not a cline-task envelope', () => { + const { calls, diagnostics } = decodeVscodeCline({ records: [{ kind: 'other' }], context }) + expect(calls).toEqual([]) + expect(diagnostics).toEqual([{ index: 0, code: 'unknown-shape' }]) + }) + + it('derives providerName from context.providerId', () => { + const rooContext: DecodeContext = { ...context, providerId: 'roo-code' } + const seenKeys = new Set() + const { calls } = decodeVscodeCline({ + records: [envelope({ uiRaw: JSON.stringify([uiMessage()]) })], + context: rooContext, + seenKeys, + }) + expect(calls[0]!.provider).toBe('roo-code') + expect(seenKeys.has('roo-code:task-a:0')).toBe(true) + expect(seenKeys.has('cline:task-a:0')).toBe(false) + }) + + it('toObservations produces a schema-valid envelope', () => { + const { calls } = decodeVscodeCline({ + records: [envelope({ historyRaw: historyWithModel('claude-3', '/home/user/acme'), uiRaw: JSON.stringify([uiMessage()]) })], + context, + }) + const { sessions } = toObservations( + { sessionId: 'task-a', projectPath: '/home/user/acme', calls }, + { privacyKey: 'test-privacy-key', provider: 'cline' }, + ) + const env = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(env).success).toBe(true) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 4ae19a69..2dc05d13 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -38,6 +38,7 @@ export default defineConfig({ 'src/providers/quickdesk/index.ts', 'src/providers/devin/index.ts', 'src/providers/copilot/index.ts', + 'src/providers/vscode-cline/index.ts', ], format: ['esm'], target: 'node20',