diff --git a/CHANGELOG.md b/CHANGELOG.md index 21e9c3a69..8f0876aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ All notable changes to the **OpenCode Go BYOK Provider** extension are documente - **`[Models]` Gemini requests no longer send `temperature` to models that declare it unsupported.** The Google body builder sent `temperature` unconditionally even when models.dev marks the model `temperature: false` (`gemini-4-argon-contributor`) — the OpenAI and Anthropic builders already guarded this. Guarded the same way, and the builder now receives the model metadata it needs for both this and the thinking payload. +- **`[Usage]` CLI history reader now reads the opencode CLI 2.x schema — Today shows 0 tokens fixed (issue #262).** The Go usage dashboard read per-message usage only from the CLI 1.x `message` table, which CLI ≥ 2.0 no longer populates, so "Today" showed 0 despite active CLI usage. The reader now probes `sqlite_master` and reads the CLI 2.x `session_message` table (role in the `type` column, provider/model under `$.model.*`, cwd joined from the parent `session_v2.directory`) alongside the legacy table, combined with `UNION` so rows the 2.x migration copied into both schemas are counted once. The issue's suggested `UNION ALL` against `session_v2` was rejected on real data: `session_v2` holds one AGGREGATED row per session (its Go total equals the per-message total), so that patch doubles every dollar/token; and its `providerID='opencode'` filter imports Zen free-model usage into the Go tracker (`opencode` is the Zen provider id — Go is `opencode-go`). A fresh CLI 2.x install without the legacy `message` table is covered too. Regression tests build real SQLite DBs (`src/test/cli2HistoryReader.test.ts`) and prove the dedup: 3 shared + 1 CLI2-only row → 4 rows at the exact total, never 2×. + +- **`[Provider]` Test Connection uses live models again (issue #261).** Both `testModelId`s pointed at models that no longer exist on the gateways — Zen's `deepseek-v4-flash-free` is dead upstream (issue #204, already in `KNOWN_UNAVAILABLE_MODEL_IDS`) so every Zen Test Connection failed with "Model is unavailable", and Go's `deepseek-v4-flash` was replaced by `v4.1` in the live catalog. Both now use `deepseek-v4.1-flash` (live on both gateways, cheapest paid tier — `max_tokens: 10` costs a fraction of a cent). A paid model is deliberate for the Zen test: the gateway answers `403 FreeTierError` to free-model requests from non-OpenCode clients regardless of headers or key validity, so a free test model can never distinguish a valid key from an invalid one. Note for #261 reporters: "OpenCode Zen missing from + Add Models" could not be reproduced from the contribution/registration side (vendor entries and `when` clauses verified correct, live Zen catalog returns 85 models incl. 10 free) — if Zen is still absent after this fix, run `OpenCode: Model Picker Diagnostics` and include the output. + ## [0.7.9] — 2026-10-13 ### Fixed diff --git a/docs/issues/100-20261020-issue262-cli2-history-issue261-zen-test-model.md b/docs/issues/100-20261020-issue262-cli2-history-issue261-zen-test-model.md new file mode 100644 index 000000000..4559871d5 --- /dev/null +++ b/docs/issues/100-20261020-issue262-cli2-history-issue261-zen-test-model.md @@ -0,0 +1,68 @@ +# Issue #262 (+ #261) — CLI 2.x usage history unreadable; Zen/Go Test Connection dead models + +**Status:** ✅ Solved (branch `fix/issue262-cli2-history-and-issue261-zen-test-model`) +**Topic:** usage / provider / models +**Updated:** 2026-10-20 +**Tags:** #usage #sqlite #cli2 #zen #test-connection #byok +**GitHub Issues:** [#262](https://github.com/ltmoerdani/opencode-copilot-chat/issues/262), [#261](https://github.com/ltmoerdani/opencode-copilot-chat/issues/261) +**Related:** [88-20260903-issue204-block-unavailable-zen-free-models.md](88-20260903-issue204-block-unavailable-zen-free-models.md) · repo memory `opencode-go-usage-accuracy.md` + +--- + +## Problem + +### #262 — usage dashboard shows 0 tokens for today (opencode CLI ≥ 2.0) + +`src/usage/history.ts` read Go per-message usage only from the CLI 1.x `message` table. The CLI 2.x schema moved that data to `session_message` (and stopped writing `message`), so "Today"/"Yesterday"/the charts showed 0 despite active CLI usage. + +### #261 — "OpenCode Zen does not show in add models" + +Reporter gave no environment/repro detail. Investigation found two **verified broken** Zen surfaces adjacent to the report (both fixed here), but could NOT reproduce the missing "+ Add Models" entry itself. + +## Root Cause (evidence-based) + +### #262 — two schemas, and the reporter's patch was wrong twice + +Verified against a live `~/.local/share/opencode/opencode.db` that went through the 1.x → 2.x migration: + +| fact | evidence | +| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `message` and `session_message` hold the same per-message rows after migration | 927/929 `message` ids exist in `session_message` with identical cost/tokens/modelId/`time.created` (the CLI's `v1-migration.bun.ts` copies legacy rows preserving ids) | +| `session_v2` is a per-session **aggregate** | its Go total (57 rows, $2.671938) exactly equals the per-message total (191 rows, $2.671938) | +| reporter's `UNION ALL message + session_v2` **double-counts** | measured: $5.343876 = exactly 2× the true total | +| reporter's `providerID='opencode'` filter imports **Zen** usage | `opencode` = Zen free models (243 rows, 17.1M tokens); Go is `opencode-go` — confirmed by model-id sets and the CLI's own models.dev snapshot keys | +| CLI 2.x column moves | role → `type` column; provider/model → `$.model.providerID`/`$.model.id`; `$.path.cwd` gone → joined from `session_v2.directory` (matches legacy cwd for all 191 Go rows) | +| fresh CLI 2.x DB may lack `message` | caught by the unit tests — the reader probes `sqlite_master` and falls back to a v2-only query | + +### #261 — verified broken surfaces + +1. **Zen `testModelId` was `deepseek-v4-flash-free`** — dead upstream (issue #204, listed in our own `KNOWN_UNAVAILABLE_MODEL_IDS`), so **every** Zen "Test Connection" failed with "Model is unavailable" before validating anything. +2. **Go `testModelId` was `deepseek-v4-flash`** — no longer in the live Go catalog (replaced by `deepseek-v4.1-flash`). +3. **Zen free tier is client-gated**: live probes show `403 FreeTierError "OpenCode's free tier can only be used from within OpenCode"` for free models from any non-OpenCode client, regardless of `User-Agent` / `x-opencode-client` / key presence. So a free test model can never validate a credential. +4. **Not reproducible**: the `opencodezen` vendor contribution, `when` clause (`config.opencodezen.enabled`, registered, default `true`), activation event, and registration in `extension.ts` are all symmetric with Go and correct; the live Zen `/models` returns 85 models (10 free). Most plausible remaining causes are user-side: "+ Add Models" clicked in the **Agents window** (agent vendors hidden by default via `showAgentModelsInManagePanel=false` + BYOK bridge settings) or an outdated VS Code. + +## Fix + +| file | change | +| ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/usage/history.ts` | Added `HISTORY_ROWS_SQL_V2` (CLI 2.x projection incl. `session_v2.directory` cwd join) and `HISTORY_ROWS_SQL_COMBINED` (`UNION` → dedups rows shared by both schemas). A `sqlite_master` probe (`parseHistorySchemasProbeRow` / `selectHistoryRowsSql`) picks legacy-only / combined / v2-only per database — used by BOTH readers (node:sqlite and the `sqlite3` binary fallback) | +| `src/provider/definitions.ts` | `testModelId` → `deepseek-v4.1-flash` for Go and Zen (live on both gateways, cheapest paid tier; `max_tokens: 10` ≈ negligible cost) | +| `src/test/cli2HistoryReader.test.ts` | new — builds real SQLite DBs via `node:sqlite` and proves: provider filtering, dedup (3 shared + 1 v2-only → 4 rows, exact total, never 2×), cwd join, and the fresh-2.x-no-legacy-table case | + +## Verification + +- `npm run lint` — 7/7 (editorconfig, eslint, markdown, prettier, shell, typecheck, tests incl. the new suite). +- SQL variants validated against the real local `opencode.db` before implementation (combined UNION returns 191 rows / $2.671938 — identical to the per-message truth). +- Live gateway probes: `deepseek-v4.1-flash` present on both `/zen/go/v1/models` and `/zen/v1/models`; old ids absent. + +## Lessons Learned + +1. **Read the actual schema before patching a usage query** — the reporter's locally-"verified" patch double-counted every dollar because `session_v2` is an aggregate table, not a per-message one. +2. **Provider-id assumptions are dangerous**: `opencode` (Zen) vs `opencode-go` (Go) vs legacy `opencodego` alias — always verify against the model-id sets actually present per provider. +3. **A missing table is a prepare-time error** — schema-optional SQL needs a probe statement, not a guard inside the SELECT. The unit tests caught the fresh-CLI-2.x-without-`message` case that the first implementation missed. +4. **Connection-test models must be (a) live and (b) paid on Zen** — free-tier models 403 for any non-OpenCode client regardless of key validity, so they cannot validate credentials. + +## Open follow-up (not in this PR) + +- #261 needs reporter confirmation (VS Code version + `OpenCode: Model Picker Diagnostics` output) if Zen is still absent from "+ Add Models" after this fix. +- Zen free-tier `403 FreeTierError` on real chat requests (not just the test) deserves its own investigation — it may require OpenCode-side allowance for this client. diff --git a/src/provider/definitions.ts b/src/provider/definitions.ts index 71eccb43b..8fdd64462 100644 --- a/src/provider/definitions.ts +++ b/src/provider/definitions.ts @@ -141,7 +141,10 @@ export const PROVIDERS: Record messagesUrl: goRoutes.messagesUrl, responsesUrl: goRoutes.responsesUrl, usageUrl: goRoutes.usageUrl, - testModelId: "deepseek-v4-flash", + // Issue #261/#262 batch: `deepseek-v4-flash` is gone from the live Go + // catalog (replaced by v4.1). Test Connection must use a live model or it + // fails with "Model is unavailable" before ever validating the key. + testModelId: "deepseek-v4.1-flash", fallbackModels: [ // Curated active set from models.dev (2026-09-08) — deprecated/legacy // models are excluded, mirroring the live catalog's own filtering. @@ -182,7 +185,13 @@ export const PROVIDERS: Record chatCompletionsUrl: zenRoutes.chatCompletionsUrl, messagesUrl: zenRoutes.messagesUrl, responsesUrl: zenRoutes.responsesUrl, - testModelId: "deepseek-v4-flash-free", + // Issue #261: `deepseek-v4-flash-free` is dead upstream (issue #204, in + // KNOWN_UNAVAILABLE_MODEL_IDS) — every Test Connection failed with + // "Model is unavailable". A live PAID model is used instead: the free + // tier also answers 403 FreeTierError to non-OpenCode clients, which + // cannot distinguish a valid key from an invalid one. v4.1-flash is the + // cheapest live model (~$0.30/1M in; max_tokens 10 ≈ negligible). + testModelId: "deepseek-v4.1-flash", fallbackModels: [ "claude-opus-4-7", "claude-opus-4-6", diff --git a/src/test/cli2HistoryReader.test.ts b/src/test/cli2HistoryReader.test.ts new file mode 100644 index 000000000..d5c81cdf4 --- /dev/null +++ b/src/test/cli2HistoryReader.test.ts @@ -0,0 +1,217 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +// node:sqlite only exists on Node >= 22.5 (CI also runs a Node 20 matrix) — +// the DB-backed tests skip there instead of failing; the probe-parsing test +// still runs everywhere. +const nodeSqliteSkip: string | undefined = (() => { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const sqlite = require("node:sqlite") as { DatabaseSync?: unknown }; + return typeof sqlite.DatabaseSync === "function" ? undefined : "node:sqlite unavailable"; + } catch { + return "node:sqlite unavailable on this Node version"; + } +})(); + +// Issue #262 regression tests: the CLI history reader must read BOTH schemas +// (CLI 1.x `message` + CLI 2.x `session_message`) WITHOUT double-counting the +// rows the CLI 2.x migration copied into both tables. The tests build real +// SQLite databases with node:sqlite and run the exported SQL, so they verify +// the actual query behaviour (dedup, cwd join, provider filter) end-to-end +// rather than just the SQL string contents. + +interface SqliteDb { + prepare(sql: string): { all(): Record[]; run(...args: unknown[]): unknown }; + close(): void; +} + +function openDb(path: string): SqliteDb | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { DatabaseSync } = require("node:sqlite") as { + DatabaseSync?: new (p: string) => SqliteDb; + }; + return typeof DatabaseSync === "function" ? new DatabaseSync(path) : undefined; + } catch { + return undefined; + } +} + +const MESSAGE_SCHEMA = ` + CREATE TABLE message ( + id text PRIMARY KEY, + session_id text NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + )`; + +const SESSION_V2_SCHEMA = ` + CREATE TABLE session_v2 ( + id text PRIMARY KEY, + directory text NOT NULL, + model text, + cost real DEFAULT 0 NOT NULL, + time_created integer NOT NULL + )`; + +const SESSION_MESSAGE_SCHEMA = ` + CREATE TABLE session_message ( + id text PRIMARY KEY, + session_id text NOT NULL, + type text NOT NULL, + seq integer NOT NULL, + time_created integer NOT NULL, + time_updated integer NOT NULL, + data text NOT NULL + )`; + +/** A CLI 1.x assistant message row (flat JSON). */ +const legacyRow = (id: string, cost: number, input: number, cwd: string, modelId: string, providerID = "opencode-go") => + JSON.stringify({ + role: "assistant", + providerID, + modelID: modelId, + cost, + tokens: { input, output: 10, reasoning: 0, "cache.read": 5 }, + path: { cwd }, + time: { created: 1789000000000 }, + }); + +/** The same message as the CLI 2.x `session_message` projection. */ +const cli2Row = (id: string, cost: number, input: number, modelId: string) => + JSON.stringify({ + model: { providerID: "opencode-go", id: modelId, variant: "default" }, + cost, + tokens: { input, output: 10, reasoning: 0, "cache.read": 5 }, + time: { created: 1789000000000 }, + }); + +describe("CLI history reader — dual schema (issue #262)", () => { + it("probe parses which schemas exist", async () => { + const { parseHistorySchemasProbeRow } = await import("../usage/history.js"); + assert.deepEqual(parseHistorySchemasProbeRow({ legacyPresent: 1, v2Present: 0 }), { legacy: true, cli2: false }); + assert.deepEqual(parseHistorySchemasProbeRow({ legacyPresent: 1, v2Present: 2 }), { legacy: true, cli2: true }); + assert.deepEqual(parseHistorySchemasProbeRow({ legacyPresent: 0, v2Present: 2 }), { legacy: false, cli2: true }); + }); + + it("legacy-only database reads the message table and excludes non-Go providers", { skip: nodeSqliteSkip }, async () => { + const { dbPath, dir } = withTempDb((db) => { + db.prepare(MESSAGE_SCHEMA).run(); + insertLegacy(db, "m1", 0.5, 100, "/repo", "mimo-v2.5"); + // Zen (providerID 'opencode') and github-copilot rows must NOT count. + insertLegacy(db, "m2", 9, 999, "/repo", "big-pickle", "opencode"); + insertLegacy(db, "m3", 9, 999, "/repo", "claude", "github-copilot"); + }); + await assertRows(dbPath, { rows: 1, cost: 0.5, cwd: "/repo", modelId: "mimo-v2.5" }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("CLI 2.x database deduplicates rows present in both schemas (no double count)", { skip: nodeSqliteSkip }, async () => { + const { dbPath, dir } = withTempDb((db) => { + db.prepare(MESSAGE_SCHEMA).run(); + db.prepare(SESSION_V2_SCHEMA).run(); + db.prepare(SESSION_MESSAGE_SCHEMA).run(); + db.prepare(`INSERT INTO session_v2 (id, directory, model, cost, time_created) VALUES ('s1', '/repo', '{}', 0, 1789000000000)`).run(); + // Same 3 Go rows in BOTH tables — exactly what the CLI 2.x migration + // produces (it copies legacy message rows into session_message). + for (const id of ["m1", "m2", "m3"]) { + const cost = id === "m1" ? 0.5 : id === "m2" ? 0.25 : 1.25; + insertLegacy(db, id, cost, 100, "/repo", "mimo-v2.5"); + insertCli2(db, id, "s1", cost, 100, "mimo-v2.5"); + } + // Plus one CLI 2.x-ONLY row (new message written after the migration). + insertCli2(db, "m4", "s1", 2, 400, "gpt-5.6-luna"); + // session_message also carries non-assistant rows and Zen usage — both excluded. + db.prepare(`INSERT INTO session_message VALUES ('u1','s1','user',1,1789000000000,1789000000000,?)`).run( + JSON.stringify({ text: "hi" }), + ); + db.prepare(`INSERT INTO session_message VALUES ('z1','s1','assistant',2,1789000000000,1789000000000,?)`).run( + JSON.stringify({ + model: { providerID: "opencode", id: "big-pickle" }, + cost: 9, + tokens: { input: 999, output: 1 }, + time: { created: 1789000000000 }, + }), + ); + }); + // 3 shared (counted once) + 1 CLI2-only = 4 rows; total 4.0 — NOT 7.75 (2× double count). + await assertRows(dbPath, { rows: 4, cost: 4 }); + rmSync(dir, { recursive: true, force: true }); + }); + + it("fresh CLI 2.x database without the legacy message table still reads history", { skip: nodeSqliteSkip }, async () => { + const { dbPath, dir } = withTempDb((db) => { + db.prepare(SESSION_V2_SCHEMA).run(); + db.prepare(SESSION_MESSAGE_SCHEMA).run(); + db.prepare( + `INSERT INTO session_v2 (id, directory, model, cost, time_created) VALUES ('s9', '/workspace/project', '{}', 0, 1789000000000)`, + ).run(); + insertCli2(db, "n1", "s9", 0.75, 50, "kimi-k3"); + }); + await assertRows(dbPath, { rows: 1, cost: 0.75, cwd: "/workspace/project", modelId: "kimi-k3" }); + rmSync(dir, { recursive: true, force: true }); + }); +}); + +function insertLegacy(db: SqliteDb, id: string, cost: number, input: number, cwd: string, modelId: string, providerID?: string): void { + db.prepare(`INSERT INTO message VALUES (?, 's1', 1789000000000, 1789000000000, ?)`).run( + id, + legacyRow(id, cost, input, cwd, modelId, providerID), + ); +} + +function insertCli2(db: SqliteDb, id: string, sessionId: string, cost: number, input: number, modelId: string): void { + db.prepare(`INSERT INTO session_message VALUES (?, ?, 'assistant', 1, 1789000000000, 1789000000000, ?)`).run( + id, + sessionId, + cli2Row(id, cost, input, modelId), + ); +} + +/** Build a temp DB, hand it to `build`, and return its path + dir (caller deletes). */ +function withTempDb(build: (db: SqliteDb) => void): { dbPath: string; dir: string } { + const dir = mkdtempSync(join(tmpdir(), "oc-history-test-")); + const dbPath = join(dir, "test.db"); + const db = openDb(dbPath); + assert.ok(db, "node:sqlite is unavailable — cannot run this test"); + try { + build(db); + } finally { + db.close(); + } + return { dbPath, dir }; +} + +/** Run the reader's exported SQL against the temp DB and assert the aggregate. */ +async function assertRows(dbPath: string, expected: { rows: number; cost: number; cwd?: string; modelId?: string }): Promise { + const db = openDb(dbPath); + assert.ok(db); + try { + const { selectHistoryRowsSql, parseHistorySchemasProbeRow } = await import("../usage/history.js"); + const probe = db + .prepare( + `SELECT + (SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'message') AS legacyPresent, + (SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session_message', 'session_v2')) AS v2Present`, + ) + .all()[0]; + const sql = selectHistoryRowsSql(parseHistorySchemasProbeRow(probe)); + const rows = db.prepare(sql).all(); + assert.equal(rows.length, expected.rows, `expected ${expected.rows} rows, got ${String(rows.length)}\nsql=${sql}`); + const totalCost = rows.reduce((sum, row) => sum + (typeof row.cost === "number" ? row.cost : 0), 0); + assert.ok(Math.abs(totalCost - expected.cost) < 1e-9, `expected cost ${String(expected.cost)}, got ${String(totalCost)}`); + if (expected.cwd !== undefined) { + assert.equal(rows[0].cwd, expected.cwd); + } + if (expected.modelId !== undefined) { + assert.equal(rows[0].modelId, expected.modelId); + } + } finally { + db.close(); + } +} diff --git a/src/usage/history.ts b/src/usage/history.ts index ae6410df7..738ab4c48 100644 --- a/src/usage/history.ts +++ b/src/usage/history.ts @@ -12,6 +12,30 @@ import type { UsageLogEntry } from "./tracker"; const OPENCODE_DB_PATH = path.join(os.homedir(), ".local", "share", "opencode", "opencode.db"); +/** + * Per-message usage rows for the OpenCode Go provider. + * + * Two on-disk schemas carry the same data and both must be read: + * + * - CLI 1.x (`message`): flat JSON — `$.providerID`, `$.role`, `$.modelID`, + * `$.path.cwd`. + * - CLI 2.x (`session_message`): role moved to the `type` COLUMN, provider and + * model moved under `$.model` (`$.model.providerID`, `$.model.id`), and + * `$.path.cwd` no longer exists — the working directory lives on the parent + * session (`session_v2.directory`) and is reached via `session_id`. + * + * The two tables overlap: a CLI 2.x database keeps the migrated legacy rows in + * `message` as well, so the same message id can exist in both. Reading them + * with `UNION` (not `UNION ALL`) over an identical projection deduplicates on + * the full row; reading `session_v2` instead would be WRONG because that + * table holds one aggregated row per SESSION, so it would double-count every + * message that belongs to a session that also has a legacy row. + * + * `session_message` and `session_v2` may not exist on a CLI 1.x install, so + * the query probes for them and falls back to the legacy table alone. The + * probe is a separate statement because a missing table is a hard error at + * prepare time — it cannot be guarded inside the SELECT. + */ const HISTORY_ROWS_SQL = ` SELECT CAST(COALESCE(json_extract(data, '$.time.created'), time_created) AS INTEGER) AS createdMs, @@ -29,6 +53,75 @@ const HISTORY_ROWS_SQL = ` AND json_type(data, '$.cost') IN ('integer', 'real') `; +/** + * CLI 2.x per-message rows. Mirrors {@link HISTORY_ROWS_SQL} field for field so + * the two can be combined with `UNION` (which deduplicates on the full row). + * The `id` is projected so overlapping rows from the two schemas collapse. + */ +const HISTORY_ROWS_SQL_V2 = ` + SELECT + CAST(COALESCE(json_extract(sm.data, '$.time.created'), sm.time_created) AS INTEGER) AS createdMs, + CAST(json_extract(sm.data, '$.cost') AS REAL) AS cost, + CAST(json_extract(sm.data, '$.tokens.input') AS INTEGER) AS tokensInput, + CAST(json_extract(sm.data, '$.tokens.output') AS INTEGER) AS tokensOutput, + CAST(json_extract(sm.data, '$.tokens.reasoning') AS INTEGER) AS tokensReasoning, + CAST(json_extract(sm.data, '$.tokens.cache.read') AS INTEGER) AS tokensCacheRead, + s.directory AS cwd, + json_extract(sm.data, '$.model.id') AS modelId + FROM session_message sm + JOIN session_v2 s ON s.id = sm.session_id + WHERE json_valid(sm.data) + AND sm.type = 'assistant' + AND json_extract(sm.data, '$.model.providerID') = 'opencode-go' + AND json_type(sm.data, '$.cost') IN ('integer', 'real') +`; + +/** + * The combined query: legacy rows plus CLI 2.x rows, deduplicated. + * + * `UNION` (not `UNION ALL`) removes the rows the two schemas share. A message + * that exists in both tables has the same usage numbers in both, so the + * duplicate collapses to a single row — no double counting. + */ +const HISTORY_ROWS_SQL_COMBINED = ` + ${HISTORY_ROWS_SQL} + UNION + ${HISTORY_ROWS_SQL_V2} +`; + +/** Probe: which history schemas exist in this database? (one row: legacy + v2 counts) */ +const CLI2_TABLES_PROBE_SQL = ` + SELECT + (SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'message') AS legacyPresent, + (SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session_message', 'session_v2')) AS v2Present +`; + +/** Interpret the probe's result row as which schemas are available (issue #262). */ +export interface HistorySchemas { + legacy: boolean; + cli2: boolean; +} + +export function parseHistorySchemasProbeRow(row: unknown): HistorySchemas { + const counts = row as { legacyPresent?: unknown; v2Present?: unknown } | null | undefined; + return { + legacy: typeof counts?.legacyPresent === "number" && counts.legacyPresent >= 1, + cli2: typeof counts?.v2Present === "number" && counts.v2Present >= 2, + }; +} + +/** + * Pick the history query for a database from its available schemas: the + * combined (CLI 1.x + 2.x UNION, deduplicated) query when both exist, the + * single-schema query when only one does. A missing table is a hard error at + * prepare time, so the choice must be made before running the history query + * — a fresh CLI 2.x install may not carry the legacy `message` table at all. + */ +export function selectHistoryRowsSql(schemas: HistorySchemas): string { + if (schemas.legacy && schemas.cli2) return HISTORY_ROWS_SQL_COMBINED; + return schemas.cli2 ? HISTORY_ROWS_SQL_V2 : HISTORY_ROWS_SQL; +} + export interface HistoryRow { createdMs: number; cost: number; @@ -304,7 +397,11 @@ function readHistoryViaNodeSqlite(): HistoryRow[] | null | undefined { try { const db = new DatabaseSync(OPENCODE_DB_PATH, { readOnly: true }); try { - const rows = db.prepare(HISTORY_ROWS_SQL).all(); + // Issue #262: CLI >= 2.0 moved per-message usage into session_message + // (with the cwd on the parent session_v2 row). Probe which schema + // exists, then run the matching query. + const probe = db.prepare(CLI2_TABLES_PROBE_SQL).all()[0]; + const rows = db.prepare(selectHistoryRowsSql(parseHistorySchemasProbeRow(probe))).all(); return rows.length > 0 ? normalizeHistoryRows(rows) : null; } finally { db.close(); @@ -347,14 +444,27 @@ function readHistoryViaSqliteCli(): HistoryRow[] | null { for (const binary of sqliteCliCandidates()) { for (let attempt = 0; attempt < 2; attempt++) { try { - const result = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, HISTORY_ROWS_SQL], { + // Issue #262: probe the schema first (see readHistoryViaNodeSqlite). + const probeResult = execFileSync(binary, ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, CLI2_TABLES_PROBE_SQL], { timeout: 10_000, - // 256MB: a power user's full CLI history JSON can exceed 64MB; a too- - // small cap silently makes the usage panel show no history at all. - maxBuffer: 256 * 1024 * 1024, + maxBuffer: 1024 * 1024, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], }); + const probeRows: unknown = JSON.parse(probeResult); + const schemas = Array.isArray(probeRows) ? parseHistorySchemasProbeRow(probeRows[0]) : { legacy: true, cli2: false }; + const result = execFileSync( + binary, + ["-readonly", "-cmd", ".timeout 5000", "-json", OPENCODE_DB_PATH, selectHistoryRowsSql(schemas)], + { + timeout: 10_000, + // 256MB: a power user's full CLI history JSON can exceed 64MB; a too- + // small cap silently makes the usage panel show no history at all. + maxBuffer: 256 * 1024 * 1024, + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + }, + ); const rows: unknown = JSON.parse(result); return Array.isArray(rows) ? normalizeHistoryRows(rows) : null; } catch (error) {