mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-19 13:34:41 +00:00
Add LingTai TUI provider support
This commit is contained in:
parent
270ee81b45
commit
a485087b5b
12 changed files with 786 additions and 5 deletions
|
|
@ -18,11 +18,11 @@
|
|||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-F97316?logo=github" alt="Sponsor" /></a>
|
||||
</p>
|
||||
|
||||
**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 31 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.**
|
||||
**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 32 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.**
|
||||
|
||||
You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot.
|
||||
|
||||
CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **31 AI tools**.
|
||||
CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **32 AI tools**.
|
||||
|
||||
Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily.
|
||||
|
||||
|
|
@ -328,6 +328,7 @@ CodeBurn auto-detects which AI tools you use. Each logo links to its provider do
|
|||
<a href="docs/providers/kilo-code.md" title="KiloCode"><img src="assets/providers/kilo-code.png" alt="KiloCode" height="34" /></a>
|
||||
<a href="docs/providers/qwen.md" title="Qwen"><img src="assets/providers/qwen.png" alt="Qwen" height="34" /></a>
|
||||
<a href="docs/providers/kimi.md" title="Kimi Code CLI"><img src="assets/providers/kimi.svg" alt="Kimi Code CLI" height="34" /></a>
|
||||
<a href="docs/providers/lingtai-tui.md" title="LingTai TUI">LingTai TUI</a>
|
||||
<a href="docs/providers/goose.md" title="Goose"><img src="assets/providers/goose.png" alt="Goose" height="34" /></a>
|
||||
<a href="docs/providers/antigravity.md" title="Antigravity"><img src="assets/providers/antigravity.png" alt="Antigravity" height="34" /></a>
|
||||
<a href="docs/providers/crush.md" title="Crush"><img src="assets/providers/crush.png" alt="Crush" height="34" /></a>
|
||||
|
|
@ -606,6 +607,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|
|||
| **Cline / Roo Code / KiloCode** | VS Code `globalStorage`: Cline at `saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode across VS Code, VS Code Insiders, and VSCodium | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. |
|
||||
| **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks/<task-id>/` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. |
|
||||
| **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. |
|
||||
| **LingTai TUI** | `~/.lingtai/<agent>/logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`<project>/.lingtai/<agent>/logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. |
|
||||
| **Vercel AI Gateway** | [Vercel AI Gateway reporting API](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting) (cloud, not local logs) | Set `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` (from `vercel env pull` / `vercel dev`); requires a Vercel plan with Custom Reporting. Without credentials it's skipped silently in the combined dashboard. |
|
||||
|
||||
CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff, by session+message ID for Kimi), filters by date range per entry, and classifies each turn.
|
||||
|
|
@ -626,6 +628,9 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
|
|||
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
|
||||
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
|
||||
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
|
||||
| `LINGTAI_HOME` | Override LingTai data directory (default: `~/.lingtai`) |
|
||||
| `LINGTAI_TUI_HOME` | Alternate override for LingTai data directory; `LINGTAI_HOME` takes precedence |
|
||||
| `LINGTAI_TUI_GLOBAL_DIR` | Override LingTai TUI global directory used for project registry discovery (default: `~/.lingtai-tui`) |
|
||||
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
|
||||
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
|
||||
| `WARP_DB_PATH` | Override Warp database path (default: Warp Stable, then Warp Preview) |
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
|
||||
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
|
||||
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` |
|
||||
| [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` |
|
||||
| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
|
||||
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
|
||||
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
|
||||
|
|
|
|||
88
docs/providers/lingtai-tui.md
Normal file
88
docs/providers/lingtai-tui.md
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# LingTai TUI
|
||||
|
||||
LingTai TUI per-agent token ledger integration.
|
||||
|
||||
- **Source:** `src/providers/lingtai-tui.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/lingtai-tui.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Explicit LingTai homes | `$LINGTAI_HOME` or `$LINGTAI_TUI_HOME` if set; path-list values are supported |
|
||||
| Default LingTai home | `~/.lingtai` |
|
||||
| Project LingTai homes | `<project>/.lingtai` for projects registered in `~/.lingtai-tui/registry.jsonl` and `~/.lingtai-tui/brief/projects/*/meta.json` |
|
||||
| Current worktree home | `.lingtai` in the current directory or any parent directory |
|
||||
| Agent ledgers | `<lingtai-home>/<agent>/logs/token_ledger.jsonl` |
|
||||
|
||||
Daemon ledgers nested under `<agent>/daemons/...` are deliberately not discovered during normal scanning. LingTai mirrors daemon usage into the parent agent ledger with `source`, `em_id`, and `run_id` tags, so reading nested ledgers too would double count spend.
|
||||
|
||||
## Storage format
|
||||
|
||||
Append-only JSONL. Each valid ledger line may include:
|
||||
|
||||
- `source`
|
||||
- `em_id`
|
||||
- `run_id`
|
||||
- `ts`
|
||||
- `input`
|
||||
- `output`
|
||||
- `thinking`
|
||||
- `cached`
|
||||
- `model`
|
||||
- `endpoint`
|
||||
|
||||
Malformed lines and zero-token entries are skipped. Missing `model` falls back to the agent `.agent.json` `llm.model`, then `unknown`.
|
||||
|
||||
## Parser
|
||||
|
||||
CodeBurn emits one parsed call per ledger entry. LingTai records provider-normalized total input plus a separate `cached` counter, so the provider maps:
|
||||
|
||||
- `input - cached` -> fresh input tokens
|
||||
- `cached` -> cache-read tokens
|
||||
- `output` -> output tokens
|
||||
- `thinking` -> reasoning tokens
|
||||
|
||||
Costs are calculated from CodeBurn's normal model pricing table.
|
||||
|
||||
## Activity mapping
|
||||
|
||||
LingTai's token ledger is an accounting source, not full chat history, so it does not include the original user prompt or per-tool transcript. CodeBurn maps the ledger `source` field conservatively:
|
||||
|
||||
| LingTai `source` | CodeBurn activity |
|
||||
|---|---|
|
||||
| `main` and unknown sources | Conversation |
|
||||
| `tc_wake` and other task-coordinator wake sources | Delegation |
|
||||
| `daemon` | Delegation |
|
||||
| `summarize_apriori` | Planning |
|
||||
|
||||
This keeps the menubar and dashboard **By Activity** view from collapsing all LingTai usage into Conversation while avoiding invented feature/debug/refactor semantics that the ledger cannot prove.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Discovery reads `<agent>/.agent.json` and groups by `nickname`, `agent_name`, `address`, then the directory name. Project-local homes are prefixed with the project directory name, for example `sample-project-Project Agent`, so same-named agents from different LingTai projects do not collapse together. The parsed call also carries the agent directory as `projectPath`.
|
||||
|
||||
## Caching
|
||||
|
||||
The shared session cache fingerprints each `token_ledger.jsonl`. `LINGTAI_HOME`, `LINGTAI_TUI_HOME`, and `LINGTAI_TUI_GLOBAL_DIR` are part of the provider environment fingerprint so changing homes invalidates stale cached results.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Dedup keys include provider name, ledger path, line number, timestamp, model, endpoint, LingTai source tags, and token counts:
|
||||
|
||||
`lingtai-tui:<ledger-path>:<line>:<timestamp>:<model>:...`
|
||||
|
||||
The ledger is append-only, so line number is stable for normal operation.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Tool calls are not reconstructed from chat history. The token ledger is the stable accounting source and does not include tool metadata.
|
||||
- Older ledger entries may not include `source`; those are labeled `main`.
|
||||
- `cached` is treated as cache read. LingTai does not expose a separate cache creation counter in the ledger.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`.
|
||||
2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths.
|
||||
3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`.
|
||||
|
|
@ -1228,6 +1228,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case ibmBob = "IBM Bob"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case lingtaiTui = "LingTai TUI"
|
||||
case kiloCode = "KiloCode"
|
||||
case openclaw = "OpenClaw"
|
||||
case opencode = "OpenCode"
|
||||
|
|
@ -1257,6 +1258,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .goose: ["goose"]
|
||||
case .grok: ["grok", "grok build"]
|
||||
case .hermes: ["hermes", "hermes agent"]
|
||||
case .lingtaiTui: ["lingtai-tui", "lingtai tui"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
|
@ -1277,6 +1279,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
case .lingtaiTui: "lingtai-tui"
|
||||
case .openclaw: "openclaw"
|
||||
case .opencode: "opencode"
|
||||
case .pi: "pi"
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ extension ProviderFilter {
|
|||
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
|
||||
case .lingtaiTui: return Color(red: 0x22/255.0, green: 0xA7/255.0, blue: 0xA0/255.0)
|
||||
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ struct DataClientProcessTests {
|
|||
#expect(value(after: "--days", in: args) == "2026-06-01,2026-06-02")
|
||||
}
|
||||
|
||||
@Test("status argv supports LingTai TUI provider")
|
||||
func statusSubcommandSupportsLingTaiTUI() {
|
||||
let args = DataClient.statusSubcommand(
|
||||
period: .month,
|
||||
provider: .lingtaiTui,
|
||||
includeOptimize: false,
|
||||
scope: .local
|
||||
)
|
||||
|
||||
#expect(value(after: "--provider", in: args) == "lingtai-tui")
|
||||
#expect(value(after: "--period", in: args) == "month")
|
||||
}
|
||||
|
||||
/// Concurrency + timeout smoke test: launch more hung subprocesses than
|
||||
/// there are cooperative threads, all at once, with a short timeout, and
|
||||
/// assert every call returns once the timeout kills its sleep.
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { ibmBob } from './ibm-bob.js'
|
|||
import { kiloCode } from './kilo-code.js'
|
||||
import { kiro } from './kiro.js'
|
||||
import { kimi } from './kimi.js'
|
||||
import { lingtaiTui } from './lingtai-tui.js'
|
||||
import { mistralVibe } from './mistral-vibe.js'
|
||||
import { mux } from './mux.js'
|
||||
import { openclaw } from './openclaw.js'
|
||||
|
|
@ -186,7 +187,7 @@ async function loadZed(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, rooCode, zerostack, grok]
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, 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.
|
||||
|
|
|
|||
414
src/providers/lingtai-tui.ts
Normal file
414
src/providers/lingtai-tui.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, delimiter, dirname, join, resolve } from 'path'
|
||||
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'
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
|
||||
type LingTaiAgentManifest = {
|
||||
agent_id?: string
|
||||
agent_name?: string
|
||||
address?: string
|
||||
nickname?: string | null
|
||||
llm?: {
|
||||
model?: string
|
||||
base_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
type LingTaiLedgerEntry = {
|
||||
source?: string
|
||||
em_id?: string
|
||||
run_id?: string
|
||||
ts?: string | number
|
||||
input?: number | string
|
||||
output?: number | string
|
||||
thinking?: number | string
|
||||
cached?: number | string
|
||||
model?: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
type LingTaiProviderOptions = {
|
||||
lingtaiHomeOverride?: string
|
||||
defaultHomeOverride?: string
|
||||
globalDirOverride?: string
|
||||
cwdOverride?: string
|
||||
}
|
||||
|
||||
type LingTaiHome = {
|
||||
path: string
|
||||
projectPrefix?: string
|
||||
}
|
||||
|
||||
function normalizeOptions(options?: string | LingTaiProviderOptions): LingTaiProviderOptions {
|
||||
return typeof options === 'string'
|
||||
? { lingtaiHomeOverride: options }
|
||||
: options ?? {}
|
||||
}
|
||||
|
||||
function expandHome(raw: string): string {
|
||||
if (raw === '~') return homedir()
|
||||
if (raw.startsWith('~/') || raw.startsWith('~\\')) return join(homedir(), raw.slice(2))
|
||||
return raw
|
||||
}
|
||||
|
||||
function splitPathList(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(delimiter)
|
||||
.map(p => p.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function existingDir(path: string): Promise<string | null> {
|
||||
const resolved = resolve(expandHome(path))
|
||||
const s = await stat(resolved).catch(() => null)
|
||||
return s?.isDirectory() ? resolved : null
|
||||
}
|
||||
|
||||
function getDefaultLingTaiHome(options: LingTaiProviderOptions): string {
|
||||
return options.defaultHomeOverride ?? join(homedir(), '.lingtai')
|
||||
}
|
||||
|
||||
function getLingTaiGlobalDir(options: LingTaiProviderOptions): string {
|
||||
return options.globalDirOverride
|
||||
?? process.env['LINGTAI_TUI_GLOBAL_DIR']
|
||||
?? join(homedir(), '.lingtai-tui')
|
||||
}
|
||||
|
||||
function projectPrefixFromHome(lingtaiHome: string, defaultLingTaiHome: string): string | undefined {
|
||||
const defaultHome = resolve(expandHome(defaultLingTaiHome))
|
||||
const resolved = resolve(lingtaiHome)
|
||||
if (resolved === defaultHome) return undefined
|
||||
|
||||
const projectName = basename(dirname(resolved))
|
||||
return projectName && projectName !== '.' ? sanitizeProject(projectName) : undefined
|
||||
}
|
||||
|
||||
async function readRegisteredProjectPaths(globalDir: string): Promise<string[]> {
|
||||
const projects: string[] = []
|
||||
|
||||
const registryRaw = await readFile(join(globalDir, 'registry.jsonl'), 'utf-8').catch(() => '')
|
||||
for (const line of registryRaw.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue
|
||||
try {
|
||||
const obj = asObject(JSON.parse(line))
|
||||
const path = stringField(obj, 'path')
|
||||
if (path) projects.push(path)
|
||||
} catch {
|
||||
// Ignore corrupt registry rows; LingTai treats this as append-only state.
|
||||
}
|
||||
}
|
||||
|
||||
const briefDir = join(globalDir, 'brief', 'projects')
|
||||
const entries = await readdir(briefDir, { withFileTypes: true }).catch(() => [])
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const meta = await readJson<JsonObject>(join(briefDir, entry.name, 'meta.json'))
|
||||
const path = stringField(meta, 'project_path')
|
||||
if (path) projects.push(path)
|
||||
}
|
||||
|
||||
return projects
|
||||
}
|
||||
|
||||
function cwdLingTaiHomes(cwd: string): string[] {
|
||||
const homes: string[] = []
|
||||
let current = resolve(cwd)
|
||||
for (;;) {
|
||||
homes.push(join(current, '.lingtai'))
|
||||
const parent = dirname(current)
|
||||
if (parent === current) break
|
||||
current = parent
|
||||
}
|
||||
return homes
|
||||
}
|
||||
|
||||
async function getLingTaiHomes(options: LingTaiProviderOptions): Promise<LingTaiHome[]> {
|
||||
const explicit = splitPathList(options.lingtaiHomeOverride ?? process.env['LINGTAI_HOME'] ?? process.env['LINGTAI_TUI_HOME'])
|
||||
const defaultHome = getDefaultLingTaiHome(options)
|
||||
const candidates = explicit.length
|
||||
? explicit
|
||||
: [
|
||||
defaultHome,
|
||||
...(await readRegisteredProjectPaths(getLingTaiGlobalDir(options))).map(project => join(project, '.lingtai')),
|
||||
...cwdLingTaiHomes(options.cwdOverride ?? process.cwd()),
|
||||
]
|
||||
|
||||
const seen = new Set<string>()
|
||||
const homes: LingTaiHome[] = []
|
||||
for (const candidate of candidates) {
|
||||
const path = await existingDir(candidate)
|
||||
if (!path || seen.has(path)) continue
|
||||
seen.add(path)
|
||||
homes.push({ path, projectPrefix: explicit.length ? undefined : projectPrefixFromHome(path, defaultHome) })
|
||||
}
|
||||
|
||||
return homes
|
||||
}
|
||||
|
||||
function sanitizeProject(raw: string): string {
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) return 'lingtai'
|
||||
return trimmed.replace(/^[/\\]+/, '').replace(/[:/\\]/g, '-')
|
||||
}
|
||||
|
||||
function asObject(value: unknown): JsonObject | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : null
|
||||
}
|
||||
|
||||
function stringField(obj: JsonObject | null, key: string): string | undefined {
|
||||
const value = obj?.[key]
|
||||
return typeof value === 'string' && value.trim() ? value : undefined
|
||||
}
|
||||
|
||||
function numericField(obj: JsonObject, key: keyof LingTaiLedgerEntry): number {
|
||||
const raw = obj[key]
|
||||
const n = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : NaN
|
||||
if (!Number.isFinite(n) || n <= 0) return 0
|
||||
return Math.trunc(n)
|
||||
}
|
||||
|
||||
async function readJson<T>(path: string): Promise<T | null> {
|
||||
const raw = await readFile(path, 'utf-8').catch(() => null)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as T
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function readAgentManifest(agentDir: string): Promise<LingTaiAgentManifest | null> {
|
||||
return readJson<LingTaiAgentManifest>(join(agentDir, '.agent.json'))
|
||||
}
|
||||
|
||||
function agentDirFromLedgerPath(ledgerPath: string): string {
|
||||
return dirname(dirname(ledgerPath))
|
||||
}
|
||||
|
||||
function projectFromManifest(manifest: LingTaiAgentManifest | null, fallback: string, prefix?: string): string {
|
||||
const name = sanitizeProject(
|
||||
manifest?.nickname
|
||||
?? manifest?.agent_name
|
||||
?? manifest?.address
|
||||
?? fallback,
|
||||
)
|
||||
return prefix ? `${prefix}-${name}` : name
|
||||
}
|
||||
|
||||
function parseTimestamp(raw: unknown): string {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
||||
const ms = raw < 1e12 ? raw * 1000 : raw
|
||||
return new Date(ms).toISOString()
|
||||
}
|
||||
if (typeof raw !== 'string' || !raw.trim()) return ''
|
||||
const d = new Date(raw)
|
||||
return Number.isNaN(d.getTime()) ? '' : d.toISOString()
|
||||
}
|
||||
|
||||
function parseLedgerLine(line: string | Buffer): LingTaiLedgerEntry | null {
|
||||
const text = Buffer.isBuffer(line) ? line.toString('utf-8') : line
|
||||
if (!text.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(text) as unknown
|
||||
const obj = asObject(parsed)
|
||||
return obj ? obj as LingTaiLedgerEntry : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function activityForSource(sourceLabel: string): { userMessage: string; tools: string[]; subagentTypes: string[] } {
|
||||
const normalized = sourceLabel.trim().toLowerCase()
|
||||
|
||||
if (normalized === 'tc_wake' || normalized.startsWith('tc_') || normalized.includes('wake')) {
|
||||
return {
|
||||
userMessage: 'LingTai task coordinator wake',
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-task-coordinator'],
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized === 'daemon') {
|
||||
return {
|
||||
userMessage: 'LingTai daemon task',
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-daemon'],
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized === 'summarize_apriori' || normalized.includes('summar')) {
|
||||
return {
|
||||
userMessage: 'LingTai planning summary',
|
||||
tools: ['EnterPlanMode'],
|
||||
subagentTypes: [],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
userMessage: normalized === 'main'
|
||||
? 'LingTai main conversation'
|
||||
: `LingTai ${sourceLabel || 'main'} conversation`,
|
||||
tools: [],
|
||||
subagentTypes: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverLedgersInHome(home: LingTaiHome): Promise<SessionSource[]> {
|
||||
const entries = await readdir(home.path, { withFileTypes: true }).catch(() => [])
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue
|
||||
|
||||
const agentDir = join(home.path, entry.name)
|
||||
const ledgerPath = join(agentDir, 'logs', 'token_ledger.jsonl')
|
||||
const s = await stat(ledgerPath).catch(() => null)
|
||||
if (!s?.isFile()) continue
|
||||
|
||||
const manifest = await readAgentManifest(agentDir)
|
||||
sources.push({
|
||||
path: ledgerPath,
|
||||
project: projectFromManifest(manifest, entry.name, home.projectPrefix),
|
||||
provider: 'lingtai-tui',
|
||||
})
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
async function discoverLedgers(homes: LingTaiHome[]): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
for (const home of homes) {
|
||||
for (const source of await discoverLedgersInHome(home)) {
|
||||
if (seen.has(source.path)) continue
|
||||
seen.add(source.path)
|
||||
sources.push(source)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const agentDir = agentDirFromLedgerPath(source.path)
|
||||
const manifest = await readAgentManifest(agentDir)
|
||||
const agentId = manifest?.agent_id ?? basename(agentDir)
|
||||
const fallbackModel = manifest?.llm?.model ?? 'unknown'
|
||||
const fallbackEndpoint = manifest?.llm?.base_url ?? ''
|
||||
const project = source.project || projectFromManifest(manifest, basename(agentDir))
|
||||
const projectPath = agentDir
|
||||
|
||||
let lineNo = 0
|
||||
for await (const line of readSessionLines(source.path)) {
|
||||
lineNo += 1
|
||||
const entry = parseLedgerLine(line)
|
||||
if (!entry) continue
|
||||
|
||||
const obj = entry as JsonObject
|
||||
const inputTotal = numericField(obj, 'input')
|
||||
const outputTokens = numericField(obj, 'output')
|
||||
const reasoningTokens = numericField(obj, 'thinking')
|
||||
const cachedInputTokens = numericField(obj, 'cached')
|
||||
const totalTokens = inputTotal + outputTokens + reasoningTokens + cachedInputTokens
|
||||
if (totalTokens === 0) continue
|
||||
|
||||
// LingTai records provider-normalized input totals plus a separate
|
||||
// cached count. Match CodeBurn's normal shape by billing cached tokens
|
||||
// in cacheReadInputTokens, not again as fresh input.
|
||||
const inputTokens = Math.max(0, inputTotal - cachedInputTokens)
|
||||
const model = stringField(obj, 'model') ?? fallbackModel
|
||||
const endpoint = stringField(obj, 'endpoint') ?? fallbackEndpoint
|
||||
const timestamp = parseTimestamp(entry.ts)
|
||||
const sourceLabel = stringField(obj, 'source') ?? 'main'
|
||||
const emId = stringField(obj, 'em_id') ?? ''
|
||||
const runId = stringField(obj, 'run_id') ?? ''
|
||||
const sessionId = runId || `${agentId}:${sourceLabel}`
|
||||
const activity = activityForSource(sourceLabel)
|
||||
const dedupKey = [
|
||||
'lingtai-tui',
|
||||
source.path,
|
||||
lineNo,
|
||||
timestamp,
|
||||
model,
|
||||
endpoint,
|
||||
sourceLabel,
|
||||
emId,
|
||||
runId,
|
||||
inputTotal,
|
||||
outputTokens,
|
||||
reasoningTokens,
|
||||
cachedInputTokens,
|
||||
].join(':')
|
||||
|
||||
const costUSD = calculateCost(
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens + reasoningTokens,
|
||||
0,
|
||||
cachedInputTokens,
|
||||
0,
|
||||
)
|
||||
|
||||
yield {
|
||||
provider: 'lingtai-tui',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: cachedInputTokens,
|
||||
cachedInputTokens,
|
||||
reasoningTokens,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: activity.tools,
|
||||
bashCommands: [],
|
||||
subagentTypes: activity.subagentTypes,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
turnId: `${sessionId}:line:${lineNo}`,
|
||||
userMessage: activity.userMessage,
|
||||
sessionId,
|
||||
project,
|
||||
projectPath,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createLingTaiTuiProvider(options?: string | LingTaiProviderOptions): Provider {
|
||||
const providerOptions = normalizeOptions(options)
|
||||
|
||||
return {
|
||||
name: 'lingtai-tui',
|
||||
displayName: 'LingTai TUI',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return getShortModelName(model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverLedgers(await getLingTaiHomes(providerOptions))
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource): SessionParser {
|
||||
return createParser(source)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const lingtaiTui = createLingTaiTuiProvider()
|
||||
|
|
@ -91,6 +91,7 @@ const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
|||
claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'],
|
||||
codex: ['CODEX_HOME'],
|
||||
hermes: ['HERMES_HOME'],
|
||||
'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'],
|
||||
droid: ['FACTORY_DIR'],
|
||||
cursor: ['XDG_DATA_HOME'],
|
||||
'cursor-agent': ['XDG_DATA_HOME'],
|
||||
|
|
@ -114,6 +115,7 @@ const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
'cursor-agent': 'workspaceless-transcript-v1',
|
||||
copilot: 'otel-durable-v1',
|
||||
hermes: 'reasoning-output-accounting-v1',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
kiro: 'ide-parsing-v1',
|
||||
'kilo-code': 'worktree-project-grouping-v1',
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ import { spawnSync } from 'node:child_process'
|
|||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function runCli(args: string[], home: string) {
|
||||
function runCli(args: string[], home: string, extraEnv: Record<string, string> = {}) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
||||
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
|
||||
HOME: home,
|
||||
TZ: 'UTC',
|
||||
...extraEnv,
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
|
|
@ -155,6 +157,70 @@ describe('codeburn status --format menubar-json', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('includes LingTai TUI usage and activity categories in menubar payloads', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-lingtai-'))
|
||||
|
||||
try {
|
||||
const lingtaiHome = join(home, '.lingtai')
|
||||
const agentDir = join(lingtaiHome, 'agent')
|
||||
await mkdir(join(agentDir, 'logs'), { recursive: true })
|
||||
await writeFile(join(agentDir, '.agent.json'), JSON.stringify({
|
||||
agent_id: 'agent-1',
|
||||
agent_name: 'LingTai Agent',
|
||||
llm: { model: 'gpt-4o' },
|
||||
}))
|
||||
|
||||
const now = new Date()
|
||||
const h = now.getUTCHours()
|
||||
const base = h >= 2 ? new Date(now.getTime() - 2 * 3600_000) : new Date(now.getTime() - h * 3600_000 - 300_000)
|
||||
const ts1 = base.toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts2 = new Date(base.getTime() + 60_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
const ts3 = new Date(base.getTime() + 120_000).toISOString().replace(/\.\d+Z$/, 'Z')
|
||||
|
||||
await writeFile(
|
||||
join(agentDir, 'logs', 'token_ledger.jsonl'),
|
||||
[
|
||||
JSON.stringify({ source: 'main', ts: ts1, input: 1000, cached: 100, output: 100, model: 'gpt-4o' }),
|
||||
JSON.stringify({ source: 'tc_wake', ts: ts2, input: 2000, cached: 500, output: 200, model: 'gpt-4o' }),
|
||||
JSON.stringify({ source: 'summarize_apriori', ts: ts3, input: 1500, cached: 300, output: 150, model: 'gpt-4o' }),
|
||||
].join('\n') + '\n',
|
||||
)
|
||||
|
||||
const result = runCli([
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--period', 'today',
|
||||
'--provider', 'lingtai-tui',
|
||||
'--no-optimize',
|
||||
], home, {
|
||||
LINGTAI_HOME: lingtaiHome,
|
||||
LINGTAI_TUI_GLOBAL_DIR: join(home, '.lingtai-tui'),
|
||||
})
|
||||
|
||||
expect(result.status, `stderr: ${result.stderr}`).toBe(0)
|
||||
|
||||
const payload = JSON.parse(result.stdout) as {
|
||||
current: {
|
||||
cost: number
|
||||
calls: number
|
||||
providers: Record<string, number>
|
||||
topActivities: Array<{ name: string; turns: number }>
|
||||
}
|
||||
}
|
||||
|
||||
expect(payload.current.cost).toBeGreaterThan(0)
|
||||
expect(payload.current.calls).toBe(3)
|
||||
expect(payload.current.providers['lingtai tui']).toBeGreaterThan(0)
|
||||
expect(payload.current.topActivities.map(a => a.name).sort()).toEqual([
|
||||
'Conversation',
|
||||
'Delegation',
|
||||
'Planning',
|
||||
])
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches combined local-only usage for --scope combined and omits it for local scope', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-menubar-combined-'))
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index.
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'roo-code', 'zerostack', 'grok'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', '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'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
@ -102,6 +102,13 @@ describe('provider registry', () => {
|
|||
expect(kimi.toolDisplayName('WriteFile')).toBe('Write')
|
||||
})
|
||||
|
||||
it('lingtai-tui model display names are normalized', () => {
|
||||
const lingtai = providers.find(p => p.name === 'lingtai-tui')!
|
||||
expect(lingtai.displayName).toBe('LingTai TUI')
|
||||
expect(lingtai.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
expect(lingtai.toolDisplayName('custom_tool')).toBe('custom_tool')
|
||||
})
|
||||
|
||||
it('cursor model display names handle auto mode', async () => {
|
||||
const all = await getAllProviders()
|
||||
const cursor = all.find(p => p.name === 'cursor')!
|
||||
|
|
|
|||
180
tests/providers/lingtai-tui.test.ts
Normal file
180
tests/providers/lingtai-tui.test.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'lingtai-tui-provider-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeAgent(name: string, opts: {
|
||||
home?: string
|
||||
manifest?: Record<string, unknown>
|
||||
ledgerLines?: Array<Record<string, unknown> | string>
|
||||
} = {}): Promise<string> {
|
||||
const home = opts.home ?? tmpDir
|
||||
const agentDir = join(home, name)
|
||||
await mkdir(join(agentDir, 'logs'), { recursive: true })
|
||||
if (opts.manifest) {
|
||||
await writeFile(join(agentDir, '.agent.json'), JSON.stringify(opts.manifest, null, 2))
|
||||
}
|
||||
const ledgerPath = join(agentDir, 'logs', 'token_ledger.jsonl')
|
||||
const lines = opts.ledgerLines ?? []
|
||||
await writeFile(
|
||||
ledgerPath,
|
||||
lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + (lines.length ? '\n' : ''),
|
||||
)
|
||||
return ledgerPath
|
||||
}
|
||||
|
||||
async function collectCalls(provider: ReturnType<typeof createLingTaiTuiProvider>, sourcePath: string, project = 'agent'): Promise<ParsedProviderCall[]> {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
const parser = provider.createSessionParser({ path: sourcePath, project, provider: 'lingtai-tui' }, new Set())
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('lingtai-tui provider', () => {
|
||||
it('discovers top-level LingTai token ledgers', async () => {
|
||||
const ledgerPath = await writeAgent('agent-a', {
|
||||
manifest: {
|
||||
agent_id: 'agent-001',
|
||||
agent_name: 'Operator Agent',
|
||||
llm: { model: 'gpt-5.5', base_url: 'example-endpoint' },
|
||||
},
|
||||
ledgerLines: [
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5' },
|
||||
],
|
||||
})
|
||||
await mkdir(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs'), { recursive: true })
|
||||
await writeFile(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs', 'token_ledger.jsonl'), '{}\n')
|
||||
|
||||
const provider = createLingTaiTuiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toEqual([{ path: ledgerPath, project: 'Operator Agent', provider: 'lingtai-tui' }])
|
||||
})
|
||||
|
||||
it('discovers project-local LingTai homes from the TUI registry', async () => {
|
||||
const defaultHome = join(tmpDir, 'home', '.lingtai')
|
||||
const globalDir = join(tmpDir, 'home', '.lingtai-tui')
|
||||
const projectRoot = join(tmpDir, 'projects', 'sample-project')
|
||||
const projectHome = join(projectRoot, '.lingtai')
|
||||
|
||||
const defaultLedger = await writeAgent('personal', {
|
||||
home: defaultHome,
|
||||
manifest: { agent_name: 'Personal Agent' },
|
||||
ledgerLines: [{ ts: '2026-07-06T01:00:00Z', input: 1, output: 1, model: 'gpt-5.5' }],
|
||||
})
|
||||
const projectLedger = await writeAgent('lingtai', {
|
||||
home: projectHome,
|
||||
manifest: { agent_name: 'Project Agent' },
|
||||
ledgerLines: [{ ts: '2026-07-06T02:00:00Z', input: 2, output: 2, model: 'gpt-5.5' }],
|
||||
})
|
||||
await mkdir(globalDir, { recursive: true })
|
||||
await writeFile(join(globalDir, 'registry.jsonl'), JSON.stringify({ path: projectRoot }) + '\n')
|
||||
|
||||
const provider = createLingTaiTuiProvider({
|
||||
defaultHomeOverride: defaultHome,
|
||||
globalDirOverride: globalDir,
|
||||
cwdOverride: join(tmpDir, 'elsewhere'),
|
||||
})
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ path: defaultLedger, project: 'Personal Agent', provider: 'lingtai-tui' },
|
||||
{ path: projectLedger, project: 'sample-project-Project Agent', provider: 'lingtai-tui' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses ledger entries and separates cached input from fresh input', async () => {
|
||||
const ledgerPath = await writeAgent('agent-b', {
|
||||
manifest: {
|
||||
agent_id: 'agent-002',
|
||||
address: 'agent-b',
|
||||
llm: { model: 'fallback-model', base_url: 'fallback-endpoint' },
|
||||
},
|
||||
ledgerLines: [
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5', endpoint: 'example-endpoint' },
|
||||
{ source: 'tc_wake', ts: '2026-06-04T01:28:24Z', input: 25, output: 5, thinking: 0, cached: 10 },
|
||||
{ source: 'summarize_apriori', ts: '2026-06-04T01:29:24Z', input: 40, output: 10, thinking: 0, cached: 20 },
|
||||
{ source: 'daemon', em_id: 'em-1', run_id: 'run-1', ts: '2026-06-04T01:30:24Z', input: 50, output: 10, thinking: 0, cached: 50 },
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath, 'agent-b')
|
||||
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'lingtai-tui',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 90,
|
||||
outputTokens: 20,
|
||||
reasoningTokens: 5,
|
||||
cacheReadInputTokens: 10,
|
||||
cachedInputTokens: 10,
|
||||
timestamp: '2026-06-04T01:25:09.000Z',
|
||||
userMessage: 'LingTai main conversation',
|
||||
sessionId: 'agent-002:main',
|
||||
project: 'agent-b',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-task-coordinator'],
|
||||
userMessage: 'LingTai task coordinator wake',
|
||||
sessionId: 'agent-002:tc_wake',
|
||||
})
|
||||
expect(calls[2]).toMatchObject({
|
||||
tools: ['EnterPlanMode'],
|
||||
subagentTypes: [],
|
||||
userMessage: 'LingTai planning summary',
|
||||
sessionId: 'agent-002:summarize_apriori',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
model: 'fallback-model',
|
||||
})
|
||||
expect(calls[3]).toMatchObject({
|
||||
model: 'fallback-model',
|
||||
inputTokens: 0,
|
||||
cacheReadInputTokens: 50,
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-daemon'],
|
||||
sessionId: 'run-1',
|
||||
userMessage: 'LingTai daemon task',
|
||||
})
|
||||
expect(calls[0]!.deduplicationKey).not.toBe(calls[3]!.deduplicationKey)
|
||||
})
|
||||
|
||||
it('skips corrupt and zero-token lines', async () => {
|
||||
const ledgerPath = await writeAgent('agent-c', {
|
||||
ledgerLines: [
|
||||
'not json',
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 0, output: 0, thinking: 0, cached: 0, model: 'gpt-5.5' },
|
||||
{ source: 'main', ts: '2026-06-04T01:26:09Z', input: 1, output: 2, thinking: 3, cached: 0, model: 'gpt-5.5' },
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(1)
|
||||
expect(calls[0]!.outputTokens).toBe(2)
|
||||
expect(calls[0]!.reasoningTokens).toBe(3)
|
||||
})
|
||||
|
||||
it('uses shared model display names', () => {
|
||||
const provider = createLingTaiTuiProvider(tmpDir)
|
||||
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue