From 800f650dbdb9f30bc994e6f13a2803a17f1b3e95 Mon Sep 17 00:00:00 2001 From: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Date: Tue, 4 Aug 2026 03:28:59 +0300 Subject: [PATCH] feat(doctor): probeRoots for 12 more providers (#899 Tier 1) Each implementation calls the exact resolution helpers its provider's discovery uses (same env fallbacks, same OS branches, same closure overrides), so doctor reports precisely where discovery looks and a stale override or moved data dir stops being a silent $0.00. lingtai-tui deliberately reports its candidates pre-existence-filter: getLingTaiHomes drops non-existent dirs (right for discovery, wrong for doctor, whose job is to show where it looked). tests/provider-probe-roots.test.ts locks the mirroring: where a factory takes an override, the same override must come back through probeRoots. --- src/providers/antigravity.ts | 9 ++- src/providers/codewhale.ts | 6 +- src/providers/crush.ts | 6 +- src/providers/cursor-agent.ts | 8 ++ src/providers/cursor.ts | 6 +- src/providers/droid.ts | 5 ++ src/providers/goose.ts | 6 +- src/providers/hermes.ts | 6 +- src/providers/ibm-bob.ts | 6 +- src/providers/lingtai-tui.ts | 20 ++++- src/providers/qwen.ts | 6 +- src/providers/warp.ts | 6 +- tests/provider-probe-roots.test.ts | 118 +++++++++++++++++++++++++++++ 13 files changed, 198 insertions(+), 10 deletions(-) create mode 100644 tests/provider-probe-roots.test.ts diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index a9d72ba..3144956 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -8,7 +8,7 @@ import https from 'https' import { calculateCost } from '../models.js' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' type AntigravityConversationRoot = { dir: string @@ -1428,6 +1428,13 @@ export function createAntigravityProvider(): Provider { return rawTool }, + async probeRoots(): Promise { + return [ + ...conversationRoots().map(root => ({ path: root.dir, label: 'conversations' })), + { path: getAntigravityStatusLineEventsPath(), label: 'statusline' }, + ] + }, + async discoverSessions(): Promise { return discoverAntigravitySessionSources() }, diff --git a/src/providers/codewhale.ts b/src/providers/codewhale.ts index 5135409..b48574f 100644 --- a/src/providers/codewhale.ts +++ b/src/providers/codewhale.ts @@ -6,7 +6,7 @@ import { extractBashCommands } from '../bash-utils.js' import { readSessionFile } from '../fs-utils.js' import { calculateCost, getShortModelName } from '../models.js' import type { ToolCall } from '../types.js' -import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' +import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' const METADATA_PREFIX_BYTES = 64 * 1024 @@ -457,6 +457,10 @@ export function createCodeWhaleProvider(overrideDirs?: string | string[]): Provi return mapToolName(rawTool) }, + async probeRoots(): Promise { + return (configuredDirs ?? defaultSessionDirs()).map(path => ({ path, label: 'sessions' })) + }, + async discoverSessions(): Promise { const seenSessionIds = new Set() const sources: SessionSource[] = [] diff --git a/src/providers/crush.ts b/src/providers/crush.ts index 5661d82..0223dfd 100644 --- a/src/providers/crush.ts +++ b/src/providers/crush.ts @@ -4,7 +4,7 @@ import { homedir, platform } from 'os' import { calculateCost } from '../models.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' /// Crush stores per-project SQLite databases discovered through a JSON registry. /// We only read both. Schema source: charmbracelet/crush @@ -236,6 +236,10 @@ export function createCrushProvider(): Provider { return rawTool }, + async probeRoots(): Promise { + return [{ path: getRegistryPath(), label: 'registry' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const registry = await loadRegistry(getRegistryPath()) diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 924956a..b57265a 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -13,6 +13,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall, + ProbeRoot, } from './types.js' type ConversationSummary = { @@ -513,6 +514,13 @@ export function createCursorAgentProvider(baseDirOverride?: string): Provider { return rawTool }, + async probeRoots(): Promise { + return [ + { path: projectsDir, label: 'projects' }, + { path: dbPath, label: 'db' }, + ] + }, + async discoverSessions(): Promise { if (!existsSync(projectsDir)) return [] diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 250e327..290f615 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -8,7 +8,7 @@ import { readCachedResults, writeCachedResults } from '../cursor-cache.js' import { isSqliteAvailable, isSqliteBusyError, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { DateRange } from '../types.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' /** Matches cli-date.ts "all" period cap (6 months). */ const CURSOR_MAX_LOOKBACK_MONTHS = 6 @@ -1045,6 +1045,10 @@ export function createCursorProvider(dbPathOverride?: string): Provider { return rawTool }, + async probeRoots(): Promise { + return [{ path: dbPathOverride ?? getCursorDbPath(), label: 'db' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] diff --git a/src/providers/droid.ts b/src/providers/droid.ts index 4ec8cf6..5da2239 100644 --- a/src/providers/droid.ts +++ b/src/providers/droid.ts @@ -11,6 +11,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall, + ProbeRoot, } from './types.js' const toolNameMap: Record = { @@ -391,6 +392,10 @@ export function createDroidProvider(factoryDir?: string): Provider { return toolNameMap[rawTool] ?? rawTool }, + async probeRoots(): Promise { + return [{ path: sessionsDir, label: 'sessions' }] + }, + async discoverSessions(): Promise { return discoverSessionsInDir(sessionsDir, base) }, diff --git a/src/providers/goose.ts b/src/providers/goose.ts index 7109709..8cdec50 100644 --- a/src/providers/goose.ts +++ b/src/providers/goose.ts @@ -5,7 +5,7 @@ import { calculateCost, getShortModelName } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js' import type { ToolCall } from '../types.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' type SessionRow = { id: string @@ -275,6 +275,10 @@ export function createGooseProvider(): Provider { return toolNameMap[rawTool] ?? rawTool }, + async probeRoots(): Promise { + return [{ path: getDbPath(), label: 'db' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const dbPath = getDbPath() diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 309b29e..980703b 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' import { calculateCost, getShortModelName } from '../models.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' -import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' import type { ToolCall } from '../types.js' type HermesSessionRow = { @@ -462,6 +462,10 @@ export function createHermesProvider(hermesHomeOverride?: string): Provider { return mapToolName(rawTool) }, + async probeRoots(): Promise { + return [{ path: hermesHome, label: 'home' }] + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] const dbs = await findStateDbs(hermesHome) diff --git a/src/providers/ibm-bob.ts b/src/providers/ibm-bob.ts index 5aec0f6..a9c0d12 100644 --- a/src/providers/ibm-bob.ts +++ b/src/providers/ibm-bob.ts @@ -3,7 +3,7 @@ import { homedir } from 'os' import { getShortModelName } from '../models.js' import { discoverClineTasksInBaseDirs, createClineParser } from './vscode-cline-parser.js' -import type { Provider, SessionSource, SessionParser } from './types.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser } from './types.js' const PROVIDER_NAME = 'ibm-bob' const DISPLAY_NAME = 'IBM Bob' @@ -45,6 +45,10 @@ export function createIBMBobProvider(overrideDir?: string): Provider { return rawTool }, + async probeRoots(): Promise { + return (overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs()).map(path => ({ path, label: 'storage' })) + }, + async discoverSessions(): Promise { const dirs = overrideDir ? [overrideDir] : getIBMBobGlobalStorageDirs() return discoverClineTasksInBaseDirs(dirs, PROVIDER_NAME, DISPLAY_NAME) diff --git a/src/providers/lingtai-tui.ts b/src/providers/lingtai-tui.ts index bec5d93..ee2c25d 100644 --- a/src/providers/lingtai-tui.ts +++ b/src/providers/lingtai-tui.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' 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 @@ -418,6 +418,24 @@ export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptio return rawTool }, + async probeRoots(): Promise { + // Deliberately pre-existence-filter: doctor's job is to show where + // discovery looks, so a missing default home reads as "not installed + // here" instead of vanishing (getLingTaiHomes drops non-existent + // candidates, which is right for discovery and wrong for doctor). + const explicit = splitPathList(providerOptions.lingtaiHomeOverride ?? process.env['LINGTAI_HOME'] ?? process.env['LINGTAI_TUI_HOME']) + const roots: ProbeRoot[] = explicit.length + ? explicit.map(path => ({ path, label: 'sessions' })) + : [ + { path: getDefaultLingTaiHome(providerOptions), label: 'sessions' }, + { path: getLingTaiGlobalDir(providerOptions), label: 'registry' }, + ] + for (const home of await getLingTaiHomes(providerOptions)) { + if (!roots.some(root => root.path === home.path)) roots.push({ path: home.path, label: 'sessions' }) + } + return roots + }, + async discoverSessions(): Promise { return discoverLedgers(await getLingTaiHomes(providerOptions)) }, diff --git a/src/providers/qwen.ts b/src/providers/qwen.ts index 427b5fd..55dbf4d 100644 --- a/src/providers/qwen.ts +++ b/src/providers/qwen.ts @@ -5,7 +5,7 @@ import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' import { calculateCost } 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' const toolNameMap: Record = { read_file: 'Read', @@ -162,6 +162,10 @@ export function createQwenProvider(overrideDir?: string): Provider { return toolNameMap[rawTool] ?? rawTool }, + async probeRoots(): Promise { + return [{ path: projectsDir, label: 'projects' }] + }, + async discoverSessions(): Promise { const sources: SessionSource[] = [] diff --git a/src/providers/warp.ts b/src/providers/warp.ts index 999fc3c..2bb8993 100644 --- a/src/providers/warp.ts +++ b/src/providers/warp.ts @@ -5,7 +5,7 @@ import { extractBashCommands } from '../bash-utils.js' import { calculateCost, getShortModelName } from '../models.js' import { blobToText, getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js' import { estimateTokensFromChars } from '../token-estimate.js' -import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' +import type { ProbeRoot, ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' import { safeNumber } from '../parser.js' const WARP_GROUP_CONTAINER = '2BBY89MBSN.dev.warp' @@ -479,6 +479,10 @@ export function createWarpProvider(dbPathOverride?: string): Provider { return rawTool === 'run_command' ? 'Bash' : rawTool }, + async probeRoots(): Promise { + return getDbCandidates(dbPathOverride).map(path => ({ path, label: 'db' })) + }, + async discoverSessions(): Promise { if (!isSqliteAvailable()) return [] diff --git a/tests/provider-probe-roots.test.ts b/tests/provider-probe-roots.test.ts new file mode 100644 index 0000000..4de8a87 --- /dev/null +++ b/tests/provider-probe-roots.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest' +import { isAbsolute, join } from 'path' + +import { createCodeWhaleProvider } from '../src/providers/codewhale.js' +import { createHermesProvider } from '../src/providers/hermes.js' +import { createLingTaiTuiProvider } from '../src/providers/lingtai-tui.js' +import { createDroidProvider } from '../src/providers/droid.js' +import { createCursorProvider } from '../src/providers/cursor.js' +import { createCursorAgentProvider } from '../src/providers/cursor-agent.js' +import { createGooseProvider } from '../src/providers/goose.js' +import { createCrushProvider } from '../src/providers/crush.js' +import { createWarpProvider } from '../src/providers/warp.js' +import { createAntigravityProvider } from '../src/providers/antigravity.js' +import { createQwenProvider } from '../src/providers/qwen.js' +import { createIBMBobProvider } from '../src/providers/ibm-bob.js' + +// probeRoots must mirror the exact resolution each provider's discovery uses +// (#899 Tier 1). Where a factory takes an override, the assertion is exact: +// the same override must come back through probeRoots, proving the two paths +// share one resolution. Providers without an override factory get structural +// assertions: non-empty, absolute, correctly labeled. + +describe('probeRoots mirrors discovery resolution', () => { + it('codewhale reports the configured dirs, or both defaults', async () => { + expect(await createCodeWhaleProvider('/tmp/cw-root').probeRoots!()).toEqual([ + { path: '/tmp/cw-root', label: 'sessions' }, + ]) + const defaults = await createCodeWhaleProvider().probeRoots!() + expect(defaults).toHaveLength(2) + for (const root of defaults) expect(isAbsolute(root.path)).toBe(true) + }) + + it('hermes reports its resolved home', async () => { + expect(await createHermesProvider('/tmp/hermes-home').probeRoots!()).toEqual([ + { path: '/tmp/hermes-home', label: 'home' }, + ]) + }) + + it('droid reports the sessions dir under the factory root', async () => { + expect(await createDroidProvider('/tmp/factory').probeRoots!()).toEqual([ + { path: join('/tmp/factory', 'sessions'), label: 'sessions' }, + ]) + }) + + it('cursor reports the state db path', async () => { + expect(await createCursorProvider('/tmp/cursor/state.vscdb').probeRoots!()).toEqual([ + { path: '/tmp/cursor/state.vscdb', label: 'db' }, + ]) + }) + + it('cursor-agent reports the projects dir and the attribution db', async () => { + expect(await createCursorAgentProvider('/tmp/ca').probeRoots!()).toEqual([ + { path: join('/tmp/ca', 'projects'), label: 'projects' }, + { path: join('/tmp/ca', 'ai-tracking', 'ai-code-tracking.db'), label: 'db' }, + ]) + }) + + it('warp reports the override db, or both bundle candidates', async () => { + expect(await createWarpProvider('/tmp/warp.db').probeRoots!()).toEqual([ + { path: '/tmp/warp.db', label: 'db' }, + ]) + const defaults = await createWarpProvider().probeRoots!() + expect(defaults).toHaveLength(2) + for (const root of defaults) { + expect(isAbsolute(root.path)).toBe(true) + expect(root.label).toBe('db') + } + }) + + it('qwen reports the projects dir', async () => { + expect(await createQwenProvider('/tmp/qwen-projects').probeRoots!()).toEqual([ + { path: '/tmp/qwen-projects', label: 'projects' }, + ]) + }) + + it('ibm-bob reports the storage dirs', async () => { + expect(await createIBMBobProvider('/tmp/bob').probeRoots!()).toEqual([ + { path: '/tmp/bob', label: 'storage' }, + ]) + const defaults = await createIBMBobProvider().probeRoots!() + expect(defaults.length).toBeGreaterThan(0) + for (const root of defaults) expect(root.label).toBe('storage') + }) + + it('lingtai-tui reports its candidates even when none exist yet', async () => { + // getLingTaiHomes drops non-existent candidates (right for discovery); + // probeRoots must keep them visible so doctor can show where it looked. + const roots = await createLingTaiTuiProvider().probeRoots!() + expect(roots.length).toBeGreaterThanOrEqual(2) + for (const root of roots) expect(isAbsolute(root.path)).toBe(true) + const labels = new Set(roots.map(r => r.label)) + expect(labels.has('sessions')).toBe(true) + expect(labels.has('registry')).toBe(true) + }) + + it('goose reports its sessions db', async () => { + const roots = await createGooseProvider().probeRoots!() + expect(roots).toHaveLength(1) + expect(isAbsolute(roots[0]!.path)).toBe(true) + expect(roots[0]!.label).toBe('db') + }) + + it('crush reports its registry file', async () => { + const roots = await createCrushProvider().probeRoots!() + expect(roots).toHaveLength(1) + expect(isAbsolute(roots[0]!.path)).toBe(true) + expect(roots[0]!.label).toBe('registry') + }) + + it('antigravity reports its conversation roots and the statusline file', async () => { + const roots = await createAntigravityProvider().probeRoots!() + expect(roots.length).toBeGreaterThanOrEqual(2) + for (const root of roots) expect(isAbsolute(root.path)).toBe(true) + const labels = new Set(roots.map(r => r.label)) + expect(labels.has('conversations')).toBe(true) + expect(labels.has('statusline')).toBe(true) + }) +})