mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-14 11:04:44 +00:00
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:
commit
3817289238
14 changed files with 1288 additions and 152 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
559
packages/cli/tests/providers/vscode-cline-shared-bridge.test.ts
Normal file
559
packages/cli/tests/providers/vscode-cline-shared-bridge.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
167
packages/core/src/providers/vscode-cline/decode.ts
Normal file
167
packages/core/src/providers/vscode-cline/decode.ts
Normal 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 }
|
||||
}
|
||||
26
packages/core/src/providers/vscode-cline/index.ts
Normal file
26
packages/core/src/providers/vscode-cline/index.ts
Normal 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'
|
||||
84
packages/core/src/providers/vscode-cline/observations.ts
Normal file
84
packages/core/src/providers/vscode-cline/observations.ts
Normal 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: [] }
|
||||
}
|
||||
52
packages/core/src/providers/vscode-cline/types.ts
Normal file
52
packages/core/src/providers/vscode-cline/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
241
packages/core/tests/providers/vscode-cline-decode.test.ts
Normal file
241
packages/core/tests/providers/vscode-cline-decode.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue