mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-29 19:05:30 +00:00
Merge pull request #735 from ozymandiashh/feat/quickdesk-provider
feat(quickdesk): Amazon Quick Desktop provider (~/.quickwork)
This commit is contained in:
commit
906cb2d550
9 changed files with 1056 additions and 3 deletions
|
|
@ -29,6 +29,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
|
||||
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
|
||||
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |
|
||||
| [Quick Desktop](quickdesk.md) | EMF JSONL + SQLite | `src/providers/quickdesk.ts` | `tests/providers/quickdesk.test.ts` |
|
||||
| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` |
|
||||
| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` |
|
||||
| [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` |
|
||||
|
|
|
|||
49
docs/providers/quickdesk.md
Normal file
49
docs/providers/quickdesk.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Quick Desktop
|
||||
|
||||
Amazon Quick Desktop local usage and session history.
|
||||
|
||||
- **Source:** `src/providers/quickdesk.ts`
|
||||
- **Loading:** eager
|
||||
- **Test:** `tests/providers/quickdesk.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
The provider reads `~/.quickwork` by default and honors `QUICKWORK_HOME`.
|
||||
|
||||
When `profiles.json` contains `entries[{ id, data_path }]`, every `data_path` is scanned and the entry `id` is used as the project. Relative paths resolve from the store root; absolute paths are used as written. `last_active` is not used for filtering. If the store root also contains `sessions/sessions.db`, that migrated legacy history is scanned as the `default` profile alongside the manifest entries. If the profile manifest is missing, unreadable, malformed, or has no usable entries, the provider scans the store root as the legacy `default` profile.
|
||||
|
||||
For each profile base it reads:
|
||||
|
||||
- `metrics/metrics-YYYY-MM-DD.jsonl`
|
||||
- `sessions/sessions.db`
|
||||
|
||||
`probeRoots()` reports the same resolved profile bases for `codeburn doctor`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Metrics files contain one AWS Embedded Metric Format JSON object per line. A usage row must have `Model`, `InputTokens`, and `OutputTokens`. `_aws.Timestamp` is milliseconds since the Unix epoch; when it is absent, the date in the metrics filename is used at midnight UTC. A numeric `CostUSD` is preserved as measured cost. Rows without `CostUSD` are priced through codeburn's existing model pricing and marked estimated. Tool-only rows in the file are linked to usage by `session_id`; `thread_id` may be present but is not required for the link. Malformed lines are skipped independently.
|
||||
|
||||
The SQLite database is opened read-only through `src/sqlite.ts`. The provider introspects `sqlite_master` and table columns before querying `sessions` or `session_messages`. It uses non-deleted sessions for the first user message and tools. A non-deleted session absent from every metrics file gets one estimated `quickdesk-auto` call: user and non-assistant content is input, assistant content is output, and characters are converted at four characters per token. Missing tables or columns disable only the affected enrichment or fallback; metrics remain usable.
|
||||
|
||||
The on-disk schema is reverse-engineered. AWS officially documents the `.quickwork` root, but does not publish a contract for `profiles.json`, metrics JSONL, or `sessions.db`.
|
||||
|
||||
## Caching
|
||||
|
||||
Quick Desktop is an eager provider using the shared session cache. Each metrics file and profile database is a separate cache source. The `QUICKWORK_HOME` value and the Quick Desktop parser version participate in the provider cache fingerprint. Sources are durable because Quick Desktop may prune its managed store; cached records are retained when a previously discovered source disappears.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Metrics calls with a session use `quickdesk:<session_id>:<timestamp>:<model>:<input>:<output>`. Session-less calls use `quickdesk:<profile>:<file>:<timestamp>:<model>:<input>:<output>`. Estimated database-only sessions use `quickdesk-est:<session_id>`. All keys are stable across runs.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Quick Desktop can delegate to Kiro or Claude Code. If Quick Desktop metrics meter the same delegated calls that those native stores persist, enabling all providers may double-count that traffic. No sanitized real store has yet resolved this caveat.
|
||||
- The metrics directory location is reverse-engineered as `<profile_base>/metrics`; it is not an official AWS schema guarantee.
|
||||
- Deleted sessions are excluded when `sessions.deleted_at` is available. On an older schema without that column, metrics are retained because deletion state cannot be determined.
|
||||
- `sessions.db` uses WAL journaling, so the main file fingerprint can lag until checkpoint. A session estimated in one run can briefly coexist with its first real metrics rows in the next; this transient estimated-plus-real overlap self-heals when the database file changes.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Test both a multi-profile manifest and the legacy root layout.
|
||||
2. Keep metrics parsing independent from optional SQLite enrichment.
|
||||
3. Add malformed-line and missing-table coverage for every newly observed schema variant.
|
||||
|
|
@ -266,6 +266,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
'openai-codex:gpt-5.5': 'gpt-5.5',
|
||||
'ibm-bob-auto': 'claude-sonnet-4-5',
|
||||
'kiro-auto': 'claude-sonnet-4-5',
|
||||
'quickdesk-auto': 'claude-sonnet-4-5',
|
||||
'cline-auto': 'claude-sonnet-4-5',
|
||||
'openclaw-auto': 'claude-sonnet-4-5',
|
||||
'warp-auto-efficient': 'gpt-5.3-codex',
|
||||
|
|
@ -816,6 +817,7 @@ const autoModelNames: Record<string, string> = {
|
|||
'copilot-anthropic-auto': 'Copilot (Anthropic)',
|
||||
'ibm-bob-auto': 'IBM Bob (auto)',
|
||||
'kiro-auto': 'Kiro (auto)',
|
||||
'quickdesk-auto': 'Quick Desktop (auto)',
|
||||
'cline-auto': 'Cline (auto)',
|
||||
'openclaw-auto': 'OpenClaw (auto)',
|
||||
'qwen-auto': 'Qwen (auto)',
|
||||
|
|
|
|||
|
|
@ -1826,7 +1826,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
|
|||
webSearchRequests: call.webSearchRequests,
|
||||
cacheCreationOneHourTokens: 0,
|
||||
},
|
||||
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale') ? call.costUSD : undefined,
|
||||
costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk') ? call.costUSD : undefined,
|
||||
isEstimated: call.costIsEstimated || undefined,
|
||||
speed: call.speed,
|
||||
timestamp: call.timestamp,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { openclaw } from './openclaw.js'
|
|||
import { openDesign } from './open-design.js'
|
||||
import { pi, omp } from './pi.js'
|
||||
import { qwen } from './qwen.js'
|
||||
import { quickdesk } from './quickdesk.js'
|
||||
import { rooCode } from './roo-code.js'
|
||||
import { zerostack } from './zerostack.js'
|
||||
import { grok } from './grok.js'
|
||||
|
|
@ -188,7 +189,7 @@ async function loadZed(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok]
|
||||
const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
|
||||
|
||||
// Lazily loaded providers, listed by name so --provider validation works even
|
||||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
|
|
|
|||
553
src/providers/quickdesk.ts
Normal file
553
src/providers/quickdesk.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
import { readdir, readFile, stat } from 'node:fs/promises'
|
||||
import { homedir } from 'node:os'
|
||||
import { basename, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import { estimateTokensFromChars } from '../token-estimate.js'
|
||||
import { blobToText, isSqliteAvailable, openDatabase } from '../sqlite.js'
|
||||
import type { SqliteDatabase } from '../sqlite.js'
|
||||
import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
const METRICS_FILE_RE = /^metrics-(\d{4})-(\d{2})-(\d{2})\.jsonl$/
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'claude-sonnet-4-5': 'Sonnet 4.5',
|
||||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
readFile: 'Read',
|
||||
read_file: 'Read',
|
||||
writeFile: 'Edit',
|
||||
write_file: 'Edit',
|
||||
editFile: 'Edit',
|
||||
edit_file: 'Edit',
|
||||
runCommand: 'Bash',
|
||||
run_command: 'Bash',
|
||||
executeBash: 'Bash',
|
||||
shell: 'Bash',
|
||||
grep: 'Grep',
|
||||
searchFiles: 'Grep',
|
||||
search_files: 'Grep',
|
||||
}
|
||||
|
||||
type ProfileBase = {
|
||||
path: string
|
||||
profile: string
|
||||
}
|
||||
|
||||
type MetricsRecord = {
|
||||
record: Record<string, unknown>
|
||||
}
|
||||
|
||||
type SessionMetadata = {
|
||||
id: string
|
||||
title: string
|
||||
agentMode: string
|
||||
createdAt?: number
|
||||
deleted: boolean
|
||||
firstUserMessage: string
|
||||
inputChars: number
|
||||
outputChars: number
|
||||
tools: string[]
|
||||
}
|
||||
|
||||
type DatabaseSnapshot = {
|
||||
sessions: Map<string, SessionMetadata>
|
||||
canEstimate: boolean
|
||||
}
|
||||
|
||||
type SqliteMasterRow = {
|
||||
name?: unknown
|
||||
}
|
||||
|
||||
type TableInfoRow = {
|
||||
name?: unknown
|
||||
}
|
||||
|
||||
type SessionRow = Record<string, unknown> & {
|
||||
id?: unknown
|
||||
title?: unknown
|
||||
agent_mode?: unknown
|
||||
created_at?: unknown
|
||||
deleted_at?: unknown
|
||||
}
|
||||
|
||||
type MessageRow = Record<string, unknown> & {
|
||||
session_id?: unknown
|
||||
role?: unknown
|
||||
content?: unknown
|
||||
tool_names?: unknown
|
||||
}
|
||||
|
||||
function quickworkHome(): string {
|
||||
return resolve(process.env['QUICKWORK_HOME'] || join(homedir(), '.quickwork'))
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
||||
}
|
||||
|
||||
function nonNegativeNumber(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number >= 0 ? number : undefined
|
||||
}
|
||||
|
||||
async function resolveProfileBases(): Promise<ProfileBase[]> {
|
||||
const root = quickworkHome()
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(await readFile(join(root, 'profiles.json'), 'utf8')))
|
||||
const entries = parsed?.['entries']
|
||||
if (Array.isArray(entries)) {
|
||||
const bases: ProfileBase[] = []
|
||||
const seenPaths = new Set<string>()
|
||||
for (const rawEntry of entries) {
|
||||
const entry = asRecord(rawEntry)
|
||||
const profile = stringValue(entry?.['id'])
|
||||
const dataPath = stringValue(entry?.['data_path'])
|
||||
if (!profile || !dataPath) continue
|
||||
const basePath = isAbsolute(dataPath) ? resolve(dataPath) : resolve(root, dataPath)
|
||||
if (seenPaths.has(basePath)) continue
|
||||
seenPaths.add(basePath)
|
||||
bases.push({ path: basePath, profile })
|
||||
}
|
||||
if (bases.length > 0) {
|
||||
const legacyDbPath = join(root, 'sessions', 'sessions.db')
|
||||
if (!seenPaths.has(root) && await isFile(legacyDbPath)) {
|
||||
bases.push({ path: root, profile: 'default' })
|
||||
}
|
||||
return bases
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Missing, unreadable, or malformed profiles.json is the legacy root layout.
|
||||
}
|
||||
return [{ path: root, profile: 'default' }]
|
||||
}
|
||||
|
||||
async function isFile(path: string): Promise<boolean> {
|
||||
try {
|
||||
return (await stat(path)).isFile()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function metricsFiles(basePath: string): Promise<string[]> {
|
||||
const metricsDir = join(basePath, 'metrics')
|
||||
try {
|
||||
const entries = await readdir(metricsDir, { withFileTypes: true })
|
||||
return entries
|
||||
.filter(entry => entry.isFile() && METRICS_FILE_RE.test(entry.name))
|
||||
.map(entry => join(metricsDir, entry.name))
|
||||
.sort()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSources(): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
for (const base of await resolveProfileBases()) {
|
||||
for (const metricsPath of await metricsFiles(base.path)) {
|
||||
sources.push({
|
||||
path: metricsPath,
|
||||
project: base.profile,
|
||||
provider: 'quickdesk',
|
||||
sourceId: 'metrics',
|
||||
sourcePath: base.path,
|
||||
})
|
||||
}
|
||||
const dbPath = join(base.path, 'sessions', 'sessions.db')
|
||||
if (await isFile(dbPath)) {
|
||||
sources.push({
|
||||
path: dbPath,
|
||||
project: base.profile,
|
||||
provider: 'quickdesk',
|
||||
sourceId: 'sessions-db',
|
||||
sourcePath: base.path,
|
||||
})
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
function tableNames(db: SqliteDatabase): Set<string> {
|
||||
const rows = db.query<SqliteMasterRow>(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('sessions', 'session_messages')",
|
||||
)
|
||||
return new Set(rows.map(row => stringValue(row.name)).filter(Boolean))
|
||||
}
|
||||
|
||||
function tableColumns(db: SqliteDatabase, table: 'sessions' | 'session_messages'): Set<string> {
|
||||
const rows = db.query<TableInfoRow>(`PRAGMA table_info(${table})`)
|
||||
return new Set(rows.map(row => stringValue(row.name)).filter(Boolean))
|
||||
}
|
||||
|
||||
function selectColumn(columns: Set<string>, name: string, fallback = 'NULL'): string {
|
||||
return columns.has(name) ? name : `${fallback} AS ${name}`
|
||||
}
|
||||
|
||||
function toolNames(value: unknown): string[] {
|
||||
const text = value instanceof Uint8Array ? blobToText(value) : stringValue(value)
|
||||
if (!text) return []
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.flatMap(entry => {
|
||||
if (typeof entry === 'string') return entry.trim() ? [entry.trim()] : []
|
||||
const record = asRecord(entry)
|
||||
const name = stringValue(record?.['name']) || stringValue(record?.['tool_name']) || stringValue(record?.['toolName'])
|
||||
return name ? [name] : []
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Older stores may use a comma-separated tool_names value instead of JSON.
|
||||
}
|
||||
return text.split(',').map(name => name.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
function uniqueMappedTools(values: string[]): string[] {
|
||||
return [...new Set(values.map(value => toolNameMap[value] ?? value).filter(Boolean))]
|
||||
}
|
||||
|
||||
function timestampSeconds(value: unknown): number | undefined {
|
||||
const number = finiteNumber(value)
|
||||
return number !== undefined && number >= 0 ? number : undefined
|
||||
}
|
||||
|
||||
function unixSecondsIso(value: number): string | null {
|
||||
const date = new Date(value * 1000)
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString()
|
||||
}
|
||||
|
||||
function loadDatabaseSnapshot(basePath: string): DatabaseSnapshot {
|
||||
const empty: DatabaseSnapshot = { sessions: new Map(), canEstimate: false }
|
||||
if (!isSqliteAvailable()) return empty
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(join(basePath, 'sessions', 'sessions.db'))
|
||||
} catch {
|
||||
return empty
|
||||
}
|
||||
|
||||
try {
|
||||
const tables = tableNames(db)
|
||||
if (!tables.has('sessions')) return empty
|
||||
|
||||
const sessionColumns = tableColumns(db, 'sessions')
|
||||
if (!sessionColumns.has('id')) return empty
|
||||
const deletionKnown = sessionColumns.has('deleted_at')
|
||||
const sessionRows = db.query<SessionRow>(
|
||||
`SELECT id,
|
||||
${selectColumn(sessionColumns, 'title')},
|
||||
${selectColumn(sessionColumns, 'agent_mode')},
|
||||
${selectColumn(sessionColumns, 'created_at')},
|
||||
${selectColumn(sessionColumns, 'deleted_at')}
|
||||
FROM sessions`,
|
||||
)
|
||||
|
||||
const sessions = new Map<string, SessionMetadata>()
|
||||
for (const row of sessionRows) {
|
||||
const id = stringValue(row.id)
|
||||
if (!id) continue
|
||||
sessions.set(id, {
|
||||
id,
|
||||
title: stringValue(row.title),
|
||||
agentMode: stringValue(row.agent_mode),
|
||||
createdAt: timestampSeconds(row.created_at),
|
||||
deleted: deletionKnown && row.deleted_at !== null && row.deleted_at !== undefined,
|
||||
firstUserMessage: '',
|
||||
inputChars: 0,
|
||||
outputChars: 0,
|
||||
tools: [],
|
||||
})
|
||||
}
|
||||
|
||||
if (!tables.has('session_messages')) {
|
||||
return { sessions, canEstimate: false }
|
||||
}
|
||||
|
||||
const messageColumns = tableColumns(db, 'session_messages')
|
||||
if (!messageColumns.has('session_id') || !messageColumns.has('role') || !messageColumns.has('content')) {
|
||||
return { sessions, canEstimate: false }
|
||||
}
|
||||
|
||||
try {
|
||||
const orderBy = messageColumns.has('timestamp') ? 'ORDER BY timestamp ASC' : ''
|
||||
const rows = db.query<MessageRow>(
|
||||
`SELECT session_id,
|
||||
role,
|
||||
CAST(content AS BLOB) AS content,
|
||||
${messageColumns.has('tool_names') ? 'CAST(tool_names AS BLOB) AS tool_names' : 'NULL AS tool_names'}
|
||||
FROM session_messages
|
||||
${orderBy}`,
|
||||
)
|
||||
for (const row of rows) {
|
||||
const sessionId = stringValue(row.session_id)
|
||||
const session = sessions.get(sessionId)
|
||||
if (!session) continue
|
||||
const role = stringValue(row.role).toLowerCase()
|
||||
const content = row.content instanceof Uint8Array ? blobToText(row.content) : stringValue(row.content)
|
||||
if (role === 'assistant') session.outputChars += content.length
|
||||
else session.inputChars += content.length
|
||||
if (role === 'user' && !session.firstUserMessage && content.trim()) {
|
||||
session.firstUserMessage = content.trim()
|
||||
}
|
||||
session.tools.push(...toolNames(row.tool_names))
|
||||
}
|
||||
} catch {
|
||||
return { sessions, canEstimate: false }
|
||||
}
|
||||
for (const session of sessions.values()) session.tools = uniqueMappedTools(session.tools)
|
||||
|
||||
return { sessions, canEstimate: true }
|
||||
} catch {
|
||||
return empty
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function readMetricsRecords(path: string): Promise<MetricsRecord[]> {
|
||||
let contents: string
|
||||
try {
|
||||
contents = await readFile(path, 'utf8')
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const records: MetricsRecord[] = []
|
||||
const lines = contents.split(/\r?\n/)
|
||||
for (let index = 0; index < lines.length; index++) {
|
||||
const line = lines[index]!.trim()
|
||||
if (!line) continue
|
||||
try {
|
||||
const record = asRecord(JSON.parse(line))
|
||||
if (record) records.push({ record })
|
||||
} catch {
|
||||
// A partially-written or malformed JSONL line must not hide later records.
|
||||
}
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
function usageRecord(record: Record<string, unknown>): boolean {
|
||||
return Boolean(stringValue(record['Model']))
|
||||
&& nonNegativeNumber(record['InputTokens']) !== undefined
|
||||
&& nonNegativeNumber(record['OutputTokens']) !== undefined
|
||||
}
|
||||
|
||||
function sessionId(record: Record<string, unknown>): string {
|
||||
return stringValue(record['session_id'])
|
||||
}
|
||||
|
||||
function toolsKey(record: Record<string, unknown>): string {
|
||||
return sessionId(record)
|
||||
}
|
||||
|
||||
function collectMetricTools(records: MetricsRecord[]): Map<string, string[]> {
|
||||
const tools = new Map<string, string[]>()
|
||||
for (const { record } of records) {
|
||||
const key = toolsKey(record)
|
||||
const tool = stringValue(record['ToolName'])
|
||||
if (!key || !tool) continue
|
||||
const current = tools.get(key) ?? []
|
||||
current.push(toolNameMap[tool] ?? tool)
|
||||
tools.set(key, current)
|
||||
}
|
||||
for (const [key, values] of tools) tools.set(key, [...new Set(values)])
|
||||
return tools
|
||||
}
|
||||
|
||||
function fallbackTimestamp(path: string): string | null {
|
||||
const match = METRICS_FILE_RE.exec(basename(path))
|
||||
if (!match) return null
|
||||
const year = Number(match[1])
|
||||
const month = Number(match[2])
|
||||
const day = Number(match[3])
|
||||
const date = new Date(Date.UTC(year, month - 1, day))
|
||||
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return null
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function metricsTimestamp(record: Record<string, unknown>, path: string): string | null {
|
||||
const aws = asRecord(record['_aws'])
|
||||
const timestampMs = finiteNumber(aws?.['Timestamp'])
|
||||
if (timestampMs !== undefined) {
|
||||
const date = new Date(timestampMs)
|
||||
if (!Number.isNaN(date.getTime())) return date.toISOString()
|
||||
}
|
||||
return fallbackTimestamp(path)
|
||||
}
|
||||
|
||||
async function metricSessionIds(basePath: string): Promise<Set<string>> {
|
||||
const ids = new Set<string>()
|
||||
for (const path of await metricsFiles(basePath)) {
|
||||
for (const { record } of await readMetricsRecords(path)) {
|
||||
if (!usageRecord(record)) continue
|
||||
const id = sessionId(record)
|
||||
if (id) ids.add(id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function allMetricSessionIds(): Promise<Set<string>> {
|
||||
const ids = new Set<string>()
|
||||
for (const base of await resolveProfileBases()) {
|
||||
for (const id of await metricSessionIds(base.path)) ids.add(id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
function basePathFor(source: SessionSource): string {
|
||||
if (source.sourcePath) return source.sourcePath
|
||||
return resolve(source.path, '..', '..')
|
||||
}
|
||||
|
||||
function commonCallFields(source: SessionSource, basePath: string) {
|
||||
return {
|
||||
provider: 'quickdesk',
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
bashCommands: [] as string[],
|
||||
speed: 'standard' as const,
|
||||
project: source.project,
|
||||
projectPath: basePath,
|
||||
}
|
||||
}
|
||||
|
||||
function createMetricsParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const records = await readMetricsRecords(source.path)
|
||||
const basePath = basePathFor(source)
|
||||
const linkedTools = collectMetricTools(records)
|
||||
const snapshot = loadDatabaseSnapshot(basePath)
|
||||
const fileId = basename(source.path)
|
||||
|
||||
for (const { record } of records) {
|
||||
if (!usageRecord(record)) continue
|
||||
const model = stringValue(record['Model'])
|
||||
const inputTokens = nonNegativeNumber(record['InputTokens'])!
|
||||
const outputTokens = nonNegativeNumber(record['OutputTokens'])!
|
||||
const timestamp = metricsTimestamp(record, source.path)
|
||||
if (!timestamp) continue
|
||||
|
||||
const linkedSessionId = sessionId(record)
|
||||
const metadata = linkedSessionId ? snapshot.sessions.get(linkedSessionId) : undefined
|
||||
if (metadata?.deleted) continue
|
||||
|
||||
const fallbackId = `${source.project}:${fileId}`
|
||||
const deduplicationKey = `quickdesk:${linkedSessionId || fallbackId}:${timestamp}:${model}:${inputTokens}:${outputTokens}`
|
||||
if (seenKeys.has(deduplicationKey)) continue
|
||||
seenKeys.add(deduplicationKey)
|
||||
|
||||
const recordedCost = nonNegativeNumber(record['CostUSD'])
|
||||
const costIsEstimated = recordedCost === undefined
|
||||
const metricTools = linkedTools.get(toolsKey(record)) ?? []
|
||||
const tools = uniqueMappedTools([...metricTools, ...(metadata?.tools ?? [])])
|
||||
|
||||
yield {
|
||||
...commonCallFields(source, basePath),
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costUSD: recordedCost ?? calculateCost(model, inputTokens, outputTokens, 0, 0, 0),
|
||||
costIsEstimated,
|
||||
tools,
|
||||
timestamp,
|
||||
deduplicationKey,
|
||||
userMessage: metadata?.firstUserMessage ?? '',
|
||||
sessionId: linkedSessionId || fileId,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createDatabaseParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const basePath = basePathFor(source)
|
||||
const snapshot = loadDatabaseSnapshot(basePath)
|
||||
if (!snapshot.canEstimate) return
|
||||
const meteredSessions = await allMetricSessionIds()
|
||||
|
||||
for (const metadata of snapshot.sessions.values()) {
|
||||
if (metadata.deleted || meteredSessions.has(metadata.id) || metadata.createdAt === undefined) continue
|
||||
const createdAtSeconds = metadata.createdAt > 1_000_000_000_000
|
||||
? metadata.createdAt / 1000
|
||||
: metadata.createdAt
|
||||
const timestamp = unixSecondsIso(createdAtSeconds)
|
||||
if (!timestamp) continue
|
||||
const inputTokens = estimateTokensFromChars(metadata.inputChars)
|
||||
const outputTokens = estimateTokensFromChars(metadata.outputChars)
|
||||
if (inputTokens + outputTokens === 0) continue
|
||||
|
||||
const deduplicationKey = `quickdesk-est:${metadata.id}`
|
||||
if (seenKeys.has(deduplicationKey)) continue
|
||||
seenKeys.add(deduplicationKey)
|
||||
const model = 'quickdesk-auto'
|
||||
|
||||
yield {
|
||||
...commonCallFields(source, basePath),
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
costUSD: calculateCost(model, inputTokens, outputTokens, 0, 0, 0),
|
||||
costIsEstimated: true,
|
||||
tools: metadata.tools,
|
||||
timestamp,
|
||||
deduplicationKey,
|
||||
userMessage: metadata.firstUserMessage,
|
||||
sessionId: metadata.id,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const quickdesk: Provider = {
|
||||
name: 'quickdesk',
|
||||
displayName: 'Quick Desktop',
|
||||
durableSources: true,
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
if (model === 'quickdesk-auto') return 'Quick Desktop (auto)'
|
||||
return modelDisplayNames[model] ?? model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return (await resolveProfileBases()).map(base => ({ path: base.path, label: base.profile }))
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSources()
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return source.sourceId === 'sessions-db' || basename(source.path) === 'sessions.db'
|
||||
? createDatabaseParser(source, seenKeys)
|
||||
: createMetricsParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
|
|
@ -128,6 +128,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
|||
antigravity: ['CODEBURN_CACHE_DIR'],
|
||||
qwen: ['QWEN_DATA_DIR'],
|
||||
'ibm-bob': ['XDG_CONFIG_HOME'],
|
||||
quickdesk: ['QUICKWORK_HOME'],
|
||||
}
|
||||
|
||||
// Names of providers whose cache entries are never evicted when source files
|
||||
|
|
@ -157,6 +158,7 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
kiro: 'ide-parsing-v1-est-cost',
|
||||
quickdesk: 'emf-sqlite-v2-est-cost',
|
||||
'kilo-code': 'worktree-project-grouping-v1',
|
||||
'roo-code': 'worktree-project-grouping-v1',
|
||||
warp: 'worktree-project-grouping-v1-est-cost',
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
445
tests/providers/quickdesk.test.ts
Normal file
445
tests/providers/quickdesk.test.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { calculateCost, getShortModelName } from '../../src/models.js'
|
||||
import { providers } from '../../src/providers/index.js'
|
||||
import { quickdesk } from '../../src/providers/quickdesk.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let quickworkHome: string
|
||||
let originalQuickworkHome: string | undefined
|
||||
let externalDirs: string[]
|
||||
|
||||
beforeEach(async () => {
|
||||
quickworkHome = await mkdtemp(join(tmpdir(), 'quickdesk-provider-test-'))
|
||||
originalQuickworkHome = process.env['QUICKWORK_HOME']
|
||||
process.env['QUICKWORK_HOME'] = quickworkHome
|
||||
externalDirs = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalQuickworkHome === undefined) delete process.env['QUICKWORK_HOME']
|
||||
else process.env['QUICKWORK_HOME'] = originalQuickworkHome
|
||||
await rm(quickworkHome, { recursive: true, force: true })
|
||||
for (const path of externalDirs) await rm(path, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeMetrics(basePath: string, date: string, lines: Array<Record<string, unknown> | string>): Promise<string> {
|
||||
const metricsDir = join(basePath, 'metrics')
|
||||
await mkdir(metricsDir, { recursive: true })
|
||||
const path = join(metricsDir, `metrics-${date}.jsonl`)
|
||||
await writeFile(path, lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + '\n')
|
||||
return path
|
||||
}
|
||||
|
||||
async function writeProfiles(entries: Array<{ id: string, data_path: string }>): Promise<void> {
|
||||
await writeFile(join(quickworkHome, 'profiles.json'), JSON.stringify({ last_active: entries[0]?.id, entries }))
|
||||
}
|
||||
|
||||
async function createSessionsDb(basePath: string, withMessages = true): Promise<string> {
|
||||
const sessionsDir = join(basePath, 'sessions')
|
||||
await mkdir(sessionsDir, { recursive: true })
|
||||
const dbPath = join(sessionsDir, 'sessions.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite') as {
|
||||
DatabaseSync: new (path: string) => TestDb
|
||||
}
|
||||
const db = new Database(dbPath)
|
||||
db.exec('PRAGMA journal_mode = WAL')
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT,
|
||||
created_at REAL,
|
||||
updated_at REAL,
|
||||
message_count INTEGER,
|
||||
agent_mode TEXT,
|
||||
deleted_at REAL
|
||||
)
|
||||
`)
|
||||
if (withMessages) {
|
||||
db.exec(`
|
||||
CREATE TABLE session_messages (
|
||||
session_id TEXT,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
timestamp REAL,
|
||||
tool_names TEXT
|
||||
)
|
||||
`)
|
||||
}
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function withDb(dbPath: string, fn: (db: TestDb) => void): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite') as {
|
||||
DatabaseSync: new (path: string) => TestDb
|
||||
}
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
fn(db)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function collectCalls(sources: SessionSource[]): Promise<ParsedProviderCall[]> {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
const seenKeys = new Set<string>()
|
||||
for (const source of sources) {
|
||||
for await (const call of quickdesk.createSessionParser(source, seenKeys).parse()) calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('quickdesk pure metrics', () => {
|
||||
it('parses metrics without requiring a SQLite fixture', async () => {
|
||||
await writeMetrics(quickworkHome, '2026-07-17', [
|
||||
{ Model: 'claude-sonnet-4-5', InputTokens: 12, OutputTokens: 6, CostUSD: 0.002 },
|
||||
])
|
||||
|
||||
const calls = await collectCalls(await quickdesk.discoverSessions())
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'quickdesk',
|
||||
inputTokens: 12,
|
||||
outputTokens: 6,
|
||||
costUSD: 0.002,
|
||||
costIsEstimated: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers an absolute data_path outside the store root', async () => {
|
||||
const absoluteBase = await mkdtemp(join(tmpdir(), 'quickdesk-absolute-profile-'))
|
||||
externalDirs.push(absoluteBase)
|
||||
await writeProfiles([{ id: 'absolute-profile', data_path: absoluteBase }])
|
||||
const metricsPath = await writeMetrics(absoluteBase, '2026-07-18', [
|
||||
{ Model: 'claude-sonnet-4-5', InputTokens: 9, OutputTokens: 3, CostUSD: 0.001 },
|
||||
])
|
||||
|
||||
expect(await quickdesk.discoverSessions()).toEqual([{
|
||||
path: metricsPath,
|
||||
project: 'absolute-profile',
|
||||
provider: 'quickdesk',
|
||||
sourceId: 'metrics',
|
||||
sourcePath: absoluteBase,
|
||||
}])
|
||||
expect(await quickdesk.probeRoots?.()).toEqual([{ path: absoluteBase, label: 'absolute-profile' }])
|
||||
})
|
||||
|
||||
it('namespaces session-less metric keys by profile', async () => {
|
||||
const firstBase = join(quickworkHome, 'profiles', 'first')
|
||||
const secondBase = join(quickworkHome, 'profiles', 'second')
|
||||
await writeProfiles([
|
||||
{ id: 'first-profile', data_path: 'profiles/first' },
|
||||
{ id: 'second-profile', data_path: 'profiles/second' },
|
||||
])
|
||||
const row = { Model: 'claude-sonnet-4-5', InputTokens: 20, OutputTokens: 5, CostUSD: 0.003 }
|
||||
await writeMetrics(firstBase, '2026-07-19', [row])
|
||||
await writeMetrics(secondBase, '2026-07-19', [row])
|
||||
|
||||
const calls = await collectCalls(await quickdesk.discoverSessions())
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(call => call.deduplicationKey)).toEqual([
|
||||
'quickdesk:first-profile:metrics-2026-07-19.jsonl:2026-07-19T00:00:00.000Z:claude-sonnet-4-5:20:5',
|
||||
'quickdesk:second-profile:metrics-2026-07-19.jsonl:2026-07-19T00:00:00.000Z:claude-sonnet-4-5:20:5',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('quickdesk provider', () => {
|
||||
it('discovers every profile data_path and parses EMF usage, linked tools, real cost, and malformed lines', async () => {
|
||||
const firstBase = join(quickworkHome, 'stores', 'alpha-data')
|
||||
const secondBase = join(quickworkHome, 'custom-location', 'beta-data')
|
||||
await writeProfiles([
|
||||
{ id: 'profile-alpha', data_path: 'stores/alpha-data' },
|
||||
{ id: 'profile-beta', data_path: 'custom-location/beta-data' },
|
||||
])
|
||||
await writeMetrics(firstBase, '2026-07-14', [
|
||||
'{malformed json',
|
||||
{ _aws: { Timestamp: 1783987200000 } },
|
||||
{ session_id: 'session-alpha', ToolName: 'read_file', ToolCallCount: 1 },
|
||||
{
|
||||
_aws: { Timestamp: 1783987200123 },
|
||||
session_id: 'session-alpha',
|
||||
thread_id: 'thread-1',
|
||||
Model: 'claude-sonnet-4-5',
|
||||
InputTokens: 120,
|
||||
OutputTokens: 30,
|
||||
CostUSD: 0.0042,
|
||||
},
|
||||
])
|
||||
await writeMetrics(secondBase, '2026-07-15', [
|
||||
{ session_id: 'session-beta', Model: 'claude-sonnet-4-5', InputTokens: 40, OutputTokens: 10 },
|
||||
])
|
||||
|
||||
const sources = await quickdesk.discoverSessions()
|
||||
expect(sources.map(source => [source.project, source.sourcePath])).toEqual([
|
||||
['profile-alpha', firstBase],
|
||||
['profile-beta', secondBase],
|
||||
])
|
||||
expect(await quickdesk.probeRoots?.()).toEqual([
|
||||
{ path: firstBase, label: 'profile-alpha' },
|
||||
{ path: secondBase, label: 'profile-beta' },
|
||||
])
|
||||
|
||||
const calls = await collectCalls(sources)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'quickdesk',
|
||||
model: 'claude-sonnet-4-5',
|
||||
inputTokens: 120,
|
||||
outputTokens: 30,
|
||||
costUSD: 0.0042,
|
||||
costIsEstimated: false,
|
||||
tools: ['Read'],
|
||||
timestamp: '2026-07-14T00:00:00.123Z',
|
||||
sessionId: 'session-alpha',
|
||||
project: 'profile-alpha',
|
||||
})
|
||||
expect(calls[0]!.deduplicationKey).toBe(
|
||||
'quickdesk:session-alpha:2026-07-14T00:00:00.123Z:claude-sonnet-4-5:120:30',
|
||||
)
|
||||
expect(calls[1]).toMatchObject({
|
||||
inputTokens: 40,
|
||||
outputTokens: 10,
|
||||
costIsEstimated: true,
|
||||
timestamp: '2026-07-15T00:00:00.000Z',
|
||||
project: 'profile-beta',
|
||||
})
|
||||
expect(calls[1]!.costUSD).toBe(calculateCost('claude-sonnet-4-5', 40, 10, 0, 0, 0))
|
||||
})
|
||||
|
||||
it('falls back to the legacy store root when profiles.json is absent', async () => {
|
||||
const metricsPath = await writeMetrics(quickworkHome, '2026-07-16', [
|
||||
{ Model: 'claude-sonnet-4-5', InputTokens: 8, OutputTokens: 4, CostUSD: 0.001 },
|
||||
])
|
||||
|
||||
const sources = await quickdesk.discoverSessions()
|
||||
expect(sources).toEqual([{
|
||||
path: metricsPath,
|
||||
project: 'default',
|
||||
provider: 'quickdesk',
|
||||
sourceId: 'metrics',
|
||||
sourcePath: quickworkHome,
|
||||
}])
|
||||
expect(await quickdesk.probeRoots?.()).toEqual([{ path: quickworkHome, label: 'default' }])
|
||||
|
||||
const calls = await collectCalls(sources)
|
||||
expect(calls[0]).toMatchObject({
|
||||
sessionId: 'metrics-2026-07-16.jsonl',
|
||||
project: 'default',
|
||||
userMessage: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('emits sessions from active profiles and a coexisting migrated legacy root database', async () => {
|
||||
const profileBase = join(quickworkHome, 'profiles', 'active-data')
|
||||
await writeProfiles([{ id: 'active-profile', data_path: 'profiles/active-data' }])
|
||||
const profileDbPath = await createSessionsDb(profileBase)
|
||||
const legacyDbPath = await createSessionsDb(quickworkHome)
|
||||
|
||||
withDb(profileDbPath, db => {
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run('profile-session', 'Profile session', 1783987200, 1783987300, 2, 'agent', null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('profile-session', 'user', 'profile prompt', 1783987201, null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('profile-session', 'assistant', 'profile answer', 1783987202, null)
|
||||
})
|
||||
withDb(legacyDbPath, db => {
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run('legacy-session', 'Legacy session', 1777507200, 1777507300, 2, 'agent', null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('legacy-session', 'user', 'legacy prompt', 1777507201, null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('legacy-session', 'assistant', 'legacy answer', 1777507202, null)
|
||||
})
|
||||
|
||||
const sources = await quickdesk.discoverSessions()
|
||||
expect(sources.map(source => [source.project, source.path])).toEqual([
|
||||
['active-profile', profileDbPath],
|
||||
['default', legacyDbPath],
|
||||
])
|
||||
const calls = await collectCalls(sources)
|
||||
expect(calls.map(call => [call.project, call.sessionId])).toEqual([
|
||||
['active-profile', 'profile-session'],
|
||||
['default', 'legacy-session'],
|
||||
])
|
||||
})
|
||||
|
||||
it('suppresses database estimates globally when profile metrics contain the migrated session id', async () => {
|
||||
const profileBase = join(quickworkHome, 'profiles', 'active-data')
|
||||
await writeProfiles([{ id: 'active-profile', data_path: 'profiles/active-data' }])
|
||||
const profileDbPath = await createSessionsDb(profileBase)
|
||||
const legacyDbPath = await createSessionsDb(quickworkHome)
|
||||
|
||||
for (const dbPath of [profileDbPath, legacyDbPath]) {
|
||||
withDb(dbPath, db => {
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run('shared-session', 'Shared session', 1783987200, 1783987300, 2, 'agent', null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('shared-session', 'user', 'shared prompt', 1783987201, null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('shared-session', 'assistant', 'shared answer', 1783987202, null)
|
||||
})
|
||||
}
|
||||
await writeMetrics(profileBase, '2026-07-14', [{
|
||||
session_id: 'shared-session',
|
||||
Model: 'claude-sonnet-4-5',
|
||||
InputTokens: 40,
|
||||
OutputTokens: 10,
|
||||
CostUSD: 0.004,
|
||||
}])
|
||||
|
||||
const calls = await collectCalls(await quickdesk.discoverSessions())
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
sessionId: 'shared-session',
|
||||
costUSD: 0.004,
|
||||
costIsEstimated: false,
|
||||
})
|
||||
expect(calls.filter(call => call.costIsEstimated)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enriches metrics and estimates only non-deleted sessions absent from all metrics', async () => {
|
||||
const dbPath = await createSessionsDb(quickworkHome)
|
||||
withDb(dbPath, db => {
|
||||
const insertSession = db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
)
|
||||
insertSession.run('metered', 'Metered session', 1783900800, 1783900900, 2, 'agent', null)
|
||||
insertSession.run('fallback', 'Fallback session', 1783987200, 1783987300, 3, 'agent', null)
|
||||
insertSession.run('deleted', 'Deleted session', 1784073600, 1784073700, 2, 'agent', 1784073800)
|
||||
|
||||
const insertMessage = db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
)
|
||||
insertMessage.run('metered', 'user', 'metered prompt', 1783900801, null)
|
||||
insertMessage.run('metered', 'assistant', 'metered answer', 1783900802, '["write_file"]')
|
||||
insertMessage.run('fallback', 'user', '12345678', 1783987201, null)
|
||||
insertMessage.run('fallback', 'tool', '1234', 1783987202, '["run_command"]')
|
||||
insertMessage.run('fallback', 'assistant', '123456789', 1783987203, null)
|
||||
insertMessage.run('deleted', 'user', 'must not appear', 1784073601, null)
|
||||
insertMessage.run('deleted', 'assistant', 'deleted answer', 1784073602, null)
|
||||
})
|
||||
await writeMetrics(quickworkHome, '2026-07-13', [
|
||||
{ session_id: 'metered', thread_id: 'thread-a', ToolName: 'read_file' },
|
||||
{
|
||||
session_id: 'metered',
|
||||
thread_id: 'thread-a',
|
||||
Model: 'claude-sonnet-4-5',
|
||||
InputTokens: 50,
|
||||
OutputTokens: 20,
|
||||
CostUSD: 0.003,
|
||||
},
|
||||
{
|
||||
session_id: 'deleted',
|
||||
Model: 'claude-sonnet-4-5',
|
||||
InputTokens: 99,
|
||||
OutputTokens: 99,
|
||||
CostUSD: 0.9,
|
||||
},
|
||||
])
|
||||
|
||||
const calls = await collectCalls(await quickdesk.discoverSessions())
|
||||
expect(calls.map(call => call.sessionId)).toEqual(['metered', 'fallback'])
|
||||
expect(calls[0]).toMatchObject({
|
||||
userMessage: 'metered prompt',
|
||||
tools: ['Read', 'Edit'],
|
||||
costIsEstimated: false,
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
model: 'quickdesk-auto',
|
||||
inputTokens: 3,
|
||||
outputTokens: 3,
|
||||
costIsEstimated: true,
|
||||
tools: ['Bash'],
|
||||
timestamp: '2026-07-14T00:00:00.000Z',
|
||||
deduplicationKey: 'quickdesk-est:fallback',
|
||||
userMessage: '12345678',
|
||||
})
|
||||
expect(calls[1]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('treats millisecond sessions.created_at values as milliseconds', async () => {
|
||||
const dbPath = await createSessionsDb(quickworkHome)
|
||||
withDb(dbPath, db => {
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run('milliseconds-session', 'Milliseconds', 1783987200000, 1783987300000, 2, 'agent', null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('milliseconds-session', 'user', 'millisecond prompt', 1783987201, null)
|
||||
db.prepare(
|
||||
'INSERT INTO session_messages (session_id, role, content, timestamp, tool_names) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('milliseconds-session', 'assistant', 'millisecond answer', 1783987202, null)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(await quickdesk.discoverSessions())
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
sessionId: 'milliseconds-session',
|
||||
timestamp: '2026-07-14T00:00:00.000Z',
|
||||
costIsEstimated: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps metrics when sessions.db has no session_messages table', async () => {
|
||||
const dbPath = await createSessionsDb(quickworkHome, false)
|
||||
withDb(dbPath, db => {
|
||||
db.prepare(
|
||||
'INSERT INTO sessions (id, title, created_at, updated_at, message_count, agent_mode, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run('metrics-only', 'Metrics only', 1783987200, 1783987300, 0, 'agent', null)
|
||||
})
|
||||
await writeMetrics(quickworkHome, '2026-07-14', [
|
||||
{
|
||||
session_id: 'metrics-only',
|
||||
Model: 'claude-sonnet-4-5',
|
||||
InputTokens: 16,
|
||||
OutputTokens: 4,
|
||||
CostUSD: 0.002,
|
||||
},
|
||||
])
|
||||
|
||||
await expect(collectCalls(await quickdesk.discoverSessions())).resolves.toMatchObject([{
|
||||
sessionId: 'metrics-only',
|
||||
inputTokens: 16,
|
||||
outputTokens: 4,
|
||||
userMessage: '',
|
||||
}])
|
||||
})
|
||||
|
||||
it('uses raw display values outside the small obvious maps', () => {
|
||||
expect(providers).toContain(quickdesk)
|
||||
expect(quickdesk.modelDisplayName('quickdesk-auto')).toBe('Quick Desktop (auto)')
|
||||
expect(getShortModelName('quickdesk-auto')).toBe('Quick Desktop (auto)')
|
||||
expect(quickdesk.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5')
|
||||
expect(quickdesk.modelDisplayName('future-model')).toBe('future-model')
|
||||
expect(quickdesk.toolDisplayName('read_file')).toBe('Read')
|
||||
expect(quickdesk.toolDisplayName('future_tool')).toBe('future_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue