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 a9d72bad..31449567 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 51354090..b48574fe 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 5661d823..0223dfdc 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 924956a1..b57265a7 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 250e3279..290f615a 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 4ec8cf6c..5da2239e 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 71097097..8cdec502 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 309b29e9..980703b4 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 5aec0f65..a9c0d12d 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 bec5d931..ee2c25d9 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 427b5fdf..55dbf4d1 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 999fc3c0..2bb89938 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 00000000..4de8a87d --- /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) + }) +})