feat(doctor): probeRoots for six fixed-location providers (#899 Tier 2, batch 1) (#938)

Add doctor probeRoots coverage for the remaining fixed-location providers while keeping discovery and diagnostics on the same shared root-resolution logic.
This commit is contained in:
Rick Culpepper 2026-08-08 19:38:35 -05:00 committed by GitHub
parent 45d98a373f
commit 08e6c99d3b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 170 additions and 19 deletions

View file

@ -2,8 +2,8 @@ import { stat } from 'fs/promises'
import { homedir } from 'os'
import { basename, join } from 'path'
import { discoverClineTasks, createClineParser, getVSCodeGlobalStoragePaths } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
const EXTENSION_ID = 'saoudrizwan.claude-dev'
@ -38,6 +38,14 @@ async function dedupeTaskSources(sources: SessionSource[]): Promise<SessionSourc
export function createClineProvider(overrideDirs?: string | string[]): Provider {
const configuredDirs = normalizeOverrideDirs(overrideDirs)
// Cline may be installed in any VS Code variant (stable, Insiders, VSCodium),
// so every globalStorage root is scanned - same as the Roo Code and KiloCode
// siblings - plus Cline's own home-data root. Shared by discovery and
// probeRoots so doctor can never report a root discovery does not read.
const taskRoots = (): string[] => configuredDirs ?? [
...clineTaskRoots(EXTENSION_ID),
getClineDataPath(),
]
return {
name: 'cline',
@ -51,14 +59,12 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider
return rawTool
},
async probeRoots(): Promise<ProbeRoot[]> {
return taskRoots().map(path => ({ path, label: 'tasks' }))
},
async discoverSessions(): Promise<SessionSource[]> {
// Cline may be installed in any VS Code variant (stable, Insiders,
// VSCodium), so every globalStorage root is scanned - same as the Roo Code
// and KiloCode siblings - plus Cline's own home-data root.
const baseDirs = configuredDirs ?? [
...getVSCodeGlobalStoragePaths(EXTENSION_ID),
getClineDataPath(),
]
const baseDirs = taskRoots()
return dedupeTaskSources(await discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', baseDirs))
},

View file

@ -5,7 +5,7 @@ import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
// Grok Build (xAI's coding CLI) stores one session per directory at
// <grok-home>/sessions/<url-encoded-cwd>/<uuid>/, where grok-home is $GROK_HOME
@ -257,6 +257,10 @@ export function createGrokProvider(sessionsDir?: string): Provider {
name: 'grok',
displayName: 'Grok Build',
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},
modelDisplayName(model: string): string {
if (model.startsWith('grok-build')) return 'Grok Build'
return getShortModelName(model)

View file

@ -1,9 +1,9 @@
import { join } from 'path'
import { homedir } from 'os'
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
const EXTENSION_ID = 'kilocode.kilo-code'
const PROVIDER_NAME = 'kilo-code'
@ -33,6 +33,14 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
return rawTool
},
async probeRoots(): Promise<ProbeRoot[]> {
// Both halves of discovery: the legacy task tree and the SQLite store.
return [
...clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' })),
{ path: sqliteConfig.dbDir, label: 'sqlite' },
]
},
async discoverSessions(): Promise<SessionSource[]> {
const [oldSessions, dbSessions] = await Promise.all([
discoverClineTasks(EXTENSION_ID, PROVIDER_NAME, 'KiloCode', overrideDir),

View file

@ -6,7 +6,7 @@ import { homedir } from 'os'
import { extractBashCommands } from '../bash-utils.js'
import { readSessionLines } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
type JsonObject = Record<string, unknown>
@ -346,6 +346,10 @@ export function createKimiProvider(overrideDir?: string): Provider {
name: 'kimi',
displayName: 'Kimi',
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: join(shareDir, 'sessions'), label: 'sessions' }]
},
modelDisplayName(model: string): string {
return getShortModelName(model)
},

View file

@ -6,7 +6,7 @@ import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import { normalizeContentBlocks } from '../content-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const modelDisplayNames: Record<string, string> = {
'gpt-5.4': 'GPT-5.4',
@ -272,6 +272,10 @@ export function createPiProvider(sessionsDir?: string): Provider {
return {
name: 'pi',
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},
displayName: 'Pi',
modelDisplayName(model: string): string {
@ -302,6 +306,10 @@ export function createOmpProvider(sessionsDir?: string): Provider {
return {
name: 'omp',
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: dir, label: 'sessions' }]
},
displayName: 'OMP',
modelDisplayName(model: string): string {

View file

@ -1,5 +1,5 @@
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
import { discoverClineTasks, createClineParser, clineTaskRoots } from './vscode-cline-parser.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js'
const EXTENSION_ID = 'rooveterinaryinc.roo-cline'
@ -16,6 +16,10 @@ export function createRooCodeProvider(overrideDir?: string | string[]): Provider
return rawTool
},
async probeRoots(): Promise<ProbeRoot[]> {
return clineTaskRoots(EXTENSION_ID, overrideDir).map(path => ({ path, label: 'tasks' }))
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir)
},

View file

@ -42,11 +42,18 @@ export function getVSCodeGlobalStoragePath(extensionId: string): string {
return getVSCodeGlobalStoragePaths(extensionId)[0]!
}
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
const baseDirs = overrideDir
// The roots discoverClineTasks scans: an explicit override wins, otherwise
// every VS Code variant's globalStorage. Exported so a provider's probeRoots()
// can report exactly what discovery reads by calling the same function, rather
// than mirroring this logic and drifting from it.
export function clineTaskRoots(extensionId: string, overrideDir?: string | string[]): string[] {
return overrideDir
? (Array.isArray(overrideDir) ? overrideDir : [overrideDir])
: getVSCodeGlobalStoragePaths(extensionId)
return discoverClineTasksInBaseDirs(baseDirs, providerName, displayName)
}
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise<SessionSource[]> {
return discoverClineTasksInBaseDirs(clineTaskRoots(extensionId, overrideDir), providerName, displayName)
}
export async function discoverClineTasksInBaseDirs(baseDirs: string[], providerName: string, displayName: string): Promise<SessionSource[]> {

View file

@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest'
import { isAbsolute, join } from 'path'
import { homedir } from 'os'
import { createClineProvider, getClineDataPath } from '../src/providers/cline.js'
import { createRooCodeProvider } from '../src/providers/roo-code.js'
import { createKiloCodeProvider } from '../src/providers/kilo-code.js'
import { createGrokProvider } from '../src/providers/grok.js'
import { createPiProvider, createOmpProvider } from '../src/providers/pi.js'
import { createKimiProvider } from '../src/providers/kimi.js'
import {
clineTaskRoots,
discoverClineTasks,
getVSCodeGlobalStoragePaths,
} from '../src/providers/vscode-cline-parser.js'
// #899 Tier 2, batch 1. probeRoots() must report the roots discovery actually
// reads: a probe pointing somewhere discovery never looks is worse than none,
// because it looks authoritative. Assertions pin exact root sets rather than
// substrings, so a wrong-but-similar path cannot pass.
//
// This file is separate from the Tier 1 suite only because #903 introduces
// that one and is still open; fold the two together once it lands.
const CLINE_EXTENSION = 'saoudrizwan.claude-dev'
const ROO_EXTENSION = 'rooveterinaryinc.roo-cline'
describe('probeRoots mirrors discovery resolution (Tier 2, batch 1)', () => {
it('cline reports exactly the roots discovery scans', async () => {
// The provider whose silence motivated #874: four places to look, and until
// now no way to see which of them CodeBurn actually read.
const roots = await createClineProvider().probeRoots!()
expect(roots).toEqual([
...clineTaskRoots(CLINE_EXTENSION).map(path => ({ path, label: 'tasks' })),
{ path: getClineDataPath(), label: 'tasks' },
])
expect(roots).toHaveLength(4)
for (const root of roots) expect(isAbsolute(root.path)).toBe(true)
})
it('cline reports the configured dirs verbatim when overridden', async () => {
expect(await createClineProvider(['/tmp/cline-a', '/tmp/cline-b']).probeRoots!()).toEqual([
{ path: '/tmp/cline-a', label: 'tasks' },
{ path: '/tmp/cline-b', label: 'tasks' },
])
})
it('roo-code reports the override, or exactly the VS Code variant roots', async () => {
expect(await createRooCodeProvider('/tmp/roo-a').probeRoots!()).toEqual([
{ path: '/tmp/roo-a', label: 'tasks' },
])
expect(await createRooCodeProvider().probeRoots!()).toEqual(
getVSCodeGlobalStoragePaths(ROO_EXTENSION).map(path => ({ path, label: 'tasks' })),
)
})
// Regression: an earlier draft mirrored the resolution in a local helper that
// detected "no override" with `=== undefined`, while discoverClineTasks uses
// truthiness. An empty-string override made doctor report [""] while
// discovery scanned the three default roots. Both now call one resolver.
it('an empty-string override resolves the same for probeRoots and discovery', async () => {
const probed = (await createRooCodeProvider('').probeRoots!()).map(r => r.path)
expect(probed).toEqual(clineTaskRoots(ROO_EXTENSION, ''))
expect(probed).toEqual(getVSCodeGlobalStoragePaths(ROO_EXTENSION))
// discoverClineTasks resolves through the same function, so an empty
// override cannot send discovery somewhere probeRoots did not report.
expect(await discoverClineTasks(ROO_EXTENSION, 'roo-code', 'Roo Code', '')).toEqual([])
})
it('kilo-code reports both halves of its discovery: tasks and the sqlite store', async () => {
const roots = await createKiloCodeProvider('/tmp/kilo-a').probeRoots!()
expect(roots[0]).toEqual({ path: '/tmp/kilo-a', label: 'tasks' })
const sqlite = roots.filter(r => r.label === 'sqlite')
expect(sqlite).toHaveLength(1)
// The same dbDir discoverSqliteSessions reads, not a lookalike.
expect(sqlite[0]!.path).toBe(
join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'kilo'),
)
})
it('grok reports exactly its resolved sessions dir', async () => {
expect(await createGrokProvider('/tmp/grok-a').probeRoots!()).toEqual([
{ path: '/tmp/grok-a', label: 'sessions' },
])
expect(await createGrokProvider().probeRoots!()).toEqual([
{ path: join(homedir(), '.grok', 'sessions'), label: 'sessions' },
])
})
it('pi and omp each report their own sessions dir', async () => {
expect(await createPiProvider('/tmp/pi-a').probeRoots!()).toEqual([
{ path: '/tmp/pi-a', label: 'sessions' },
])
expect(await createOmpProvider('/tmp/omp-a').probeRoots!()).toEqual([
{ path: '/tmp/omp-a', label: 'sessions' },
])
// Same module, two providers: the roots must not collide.
const [piRoot] = await createPiProvider().probeRoots!()
const [ompRoot] = await createOmpProvider().probeRoots!()
expect(piRoot!.path).not.toBe(ompRoot!.path)
})
it('kimi reports the sessions dir under its share root, not the share root itself', async () => {
// Discovery walks <shareDir>/sessions; reporting shareDir would point doctor
// at a directory that exists even when no sessions do.
expect(await createKimiProvider('/tmp/kimi-a').probeRoots!()).toEqual([
{ path: join('/tmp/kimi-a', 'sessions'), label: 'sessions' },
])
})
})