Merge pull request #836 from getagentseal/phase8/shared-modules

refactor(core): vscode-cline shared decode into core — cline, ibm-bob, roo-code (phase 8, shared batch S1)
This commit is contained in:
Resham Joshi 2026-07-26 22:07:39 -07:00 committed by GitHub
commit 3817289238
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 1288 additions and 152 deletions

View file

@ -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<SessionSourc
export function createClineProvider(overrideDirs?: string | string[]): Provider {
const configuredDirs = normalizeOverrideDirs(overrideDirs)
return {
return createBridgedProvider<VscodeClineDecodedCall>({
name: 'cline',
displayName: 'Cline',
@ -64,10 +68,10 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider
return dedupeTaskSources(sources.flat())
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createClineParser(source, seenKeys, 'cline')
},
}
readRecords: readClineRecords,
decode: input => decodeVscodeCline(input),
toProviderCall: toClineProviderCall,
})
}
export const cline = createClineProvider()

View file

@ -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<VscodeClineDecodedCall>({
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<string>): SessionParser {
return createClineParser(source, seenKeys, PROVIDER_NAME, FALLBACK_MODEL)
},
}
decode: input => decodeVscodeCline({ ...input, fallbackModel: FALLBACK_MODEL }),
readRecords: readClineRecords,
toProviderCall: toClineProviderCall,
})
}
export const ibmBob = createIBMBobProvider()

View file

@ -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<VscodeClineDecodedCall>({
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<string>): SessionParser {
return createClineParser(source, seenKeys, 'roo-code')
},
}
readRecords: readClineRecords,
decode: input => decodeVscodeCline(input),
toProviderCall: toClineProviderCall,
})
}
export const rooCode = createRooCodeProvider()

View file

@ -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>([^<]+)<\/model>/
const WORKSPACE_DIR_RE = /Current Workspace Directory \(([^)]+)\)/
type HistoryMeta = { model: string; workspace: string | null }
function extractHistoryMeta(taskDir: string, fallbackModel: string): Promise<HistoryMeta> {
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<unknown[] | null> {
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<string>, 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<string>,
providerName: string,
fallbackModel = 'cline-auto',
): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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)
},
}
}

View file

@ -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<string> {
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<SessionSource[]> {
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<environment_details>\n<model>anthropic/claude-sonnet-4-6</model>\nCurrent Workspace Directory (/home/user/projects/acme-corp)\n</environment_details>',
},
],
},
],
})
// 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: '<environment_details>\n<model>anthropic/claude-opus-5</model>\n</environment_details>' }],
},
],
})
// 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<string>): Promise<ParsedProviderCall[]> {
const calls: ParsedProviderCall[] = []
let parser: ReturnType<typeof cline.createSessionParser>
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<ParsedProviderCall[]> {
const seenKeys = new Set<string>()
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<string>()
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<string>()
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)
}
})
})

View file

@ -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": [

View file

@ -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>([^<]+)<\/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<string>
/** 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 `<providerId>:<taskId>:<apiReqIndex>` 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<string>()
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 }
}

View file

@ -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'

View file

@ -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: [] }
}

View file

@ -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
}

View file

@ -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', () => {

View file

@ -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: `<environment_details>\n<model>anthropic/claude-sonnet-4-6</model>\nCurrent Workspace Directory (${SECRETS.absPath})\n${SECRETS.commandLine}\n</environment_details>`,
},
],
},
])
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),

View file

@ -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> = {}): 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: `<environment_details>\n<model>${model}</model>\n${workspaceLine}\n</environment_details>` }],
},
])
}
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 <model> 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<string>()
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<string>()
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<string>()
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)
})
})

View file

@ -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',