FN-7449: include chat token usage in dashboard totals

Chat token usage now contributes to Command Center token analytics totals alongside task execution usage.

- Add durable chat token usage rows and persistence hooks for assistant and room messages.
- Aggregate chat usage into token totals, time series, provider/model groups, CSV export, and dashboard counts.
- Cover chat token persistence and analytics with core and dashboard tests.
- Document the expanded dashboard token usage behavior and add a changeset for the published package.

Files changed:
 .changeset/fn-7449-chat-token-totals.md            |   7 +
 docs/dashboard-guide.md                            |   4 +-
 .../core/src/__tests__/token-analytics.test.ts     | 146 ++++++++++++++++++
 packages/core/src/chat-store.ts                    | 105 +++++++++++++
 packages/core/src/chat-types.ts                    |  37 +++++
 packages/core/src/db.ts                            |  52 ++++++-
 packages/core/src/index.ts                         |   3 +
 packages/core/src/token-analytics.ts               | 167 +++++++++++++++++----
 .../__tests__/CommandCenter.test.tsx               |   4 +-
 .../components/command-center/areas/TokensArea.tsx |   4 +
 .../src/__tests__/chat-manager-cli-send.test.ts    |  40 +++++
 .../dashboard/src/__tests__/chat-manager.test.ts   | 100 ++++++++++++
 .../src/__tests__/command-center-csv.test.ts       |   2 +
 packages/dashboard/src/chat.ts                     | 110 +++++++++++++-
 packages/dashboard/src/command-center-csv.ts       |   3 +
 15 files changed, 745 insertions(+), 39 deletions(-)

Fusion-Task-Id: FN-7449
Fusion-Task-Lineage: f2bacbb6-93b9-4874-b431-2049981b66c1
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-02 15:00:40 -07:00
parent 1d6bb08e79
commit 765218f80f
15 changed files with 745 additions and 39 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Include supported chat interactions in Command Center token usage totals.
category: fix
dev: Records chat-session and room-responder token usage separately from task execution tokens and aggregates both sources in token analytics.

View File

@@ -956,7 +956,7 @@ Features:
- **Overview** summarizes token usage/cost, autonomy, active nodes, sessions, agent runs, tasks done, model breadth, and real open signals, and includes the SDLC throughput funnel for the selected range at the bottom of the Overview content in loading, error, empty, and populated states. Its token total and Live activity snapshot token metric refresh on a bounded live cadence and animate number changes while preserving reduced-motion preferences. The sessions card uses the selected-range `ActivityAnalytics.sessions` value already loaded for the overview. The Live activity snapshot also shows the current board-state count for tasks in progress, independent of the selected analytics date range. Overview's active-agent and daily activity values count durable-agent usage events plus ephemeral task-worker execution runs, including graph-owned workflow step sessions that publish `agentRuns` lifecycle rows, with the same agent counted once per day/range if both sources record activity. Overview includes a graph-rich software-factory snapshot with the existing top-model-consumers bar, tool-category bar, top-model token-share pie, and the daily activity multi-series line chart placed before the daily activity sparkline/trend so the richer line graph sits higher in the chart grid. These reuse the already-loaded tokens, tools, activity, and signals analytics; the signals count comes from `/api/command-center/signals` and renders unavailable (`—`) while the incidents-backed response is loading or unavailable. The chart reveal/glow accents are decorative and disabled when reduced-motion preferences are active. The SDLC completion rate is shown as a radial gauge and is calculated as cohort conversion from in-range triage entrants, so the rate is capped at 100% even when older tasks finish during the range.
<!-- FNXC:CommandCenter 2026-06-21-00:00: Command Center cost must read as an estimated, derived value from recorded token counts and the hand-maintained model pricing map; it is never persisted, and the UI must surface prices-as-of, stale low-confidence, and unavailable unknown-model states instead of implying billing truth. -->
<!-- FNXC:CommandCenter 2026-06-22-00:00: FN-6876 requires user-maintained/LiteLLM-fetched pricing overrides to feed Tokens and Team estimates immediately without implying provider billing reconciliation. -->
- **Tokens** breaks down token totals, estimated cost, tasks, and per-model usage. Per-model and per-provider breakdowns use the task's analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, a full token-by-model bar, and a token-share pie backed by every grouped model returned by token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
- **Tokens** breaks down token totals, estimated cost, task count, chat-turn count, and per-model usage. Per-model and per-provider breakdowns include task execution tokens plus supported dashboard chat, task-detail planner chat, and room responder turns when their runtime exposes authoritative session token stats; CLI-backed chat and title generation are excluded until those paths expose reliable stats. Task counts remain task-only while chat turns are counted separately, so task detail token panels stay execution-scoped and planner chat does not double-count the task it discusses. Per-model and per-provider breakdowns use the task/chat analytics-only actually-used model snapshot when available, so usage from settings-resolved runs appears under the real runtime model instead of `(unknown)` without changing future model resolution; estimated cost uses the same snapshot-first, legacy-fallback model identity so those resolved runs price normally when the model is in the pricing table. Estimated cost is derived at read time from recorded token counts multiplied by the effective per-model pricing table: Settings → Global Models pricing overrides win first, then the built-in fallback table is used. It is not persisted, so historical rows stay tied to current maintained prices instead of stale stored billing truth. The Tokens area shows a **prices as of** date/source for the effective table, marks pricing older than the staleness threshold as low-confidence, and shows cost unavailable for models with no pricing entry rather than guessing a price. It includes the existing token-usage-over-time chart, an additive recharts multi-series line graph, a full token-by-model bar, and a token-share pie backed by every grouped model returned by token analytics; use the granularity control to switch the time-series request between hourly, daily, and weekly buckets. The token total and charts poll on a bounded cadence, keep the previous data visible during refresh, animate decorative count/bar transitions, and disable those animations for reduced-motion users.
- **Tools** shows autonomy ratio, tool-call volume, intervention counts, sessions, and tool categories. The area keeps the existing category bar and adds a recharts category-share pie from `ToolAnalytics.byCategory`. There is intentionally no tools line chart yet because `ToolAnalytics` does not expose a per-day tool trend; the dashboard does not fabricate one or call a new endpoint.
- **Activity** tracks sessions, messages, active nodes, active agents, agent heartbeat runs, and stickiness. Active agents include durable-agent `usage_events` and ephemeral task-worker `agentRuns` rows in the selected range; graph-owned/new workflow step sessions publish those `agentRuns` rows as they move from active to completed/failed, duplicate same-day agent ids across both sources count once, and run-only task workers still make the active-agents lines and stat cards non-zero. Agent-run sheets show total, active, completed, and failed runs for the selected range, and the Agent runs/day sparkline trends runs by `agentRuns.startedAt`. The area keeps the existing live animated line charts for messages/day, active agents/day, active nodes/day, and combined throughput/day (`messages + active agents + active nodes`), and adds a recharts multi-series line graph for messages, active agents, and agent runs plus an agent-run outcome pie from the existing `agentRuns` split. These charts reuse the existing activity analytics endpoint, refresh on a bounded 15-second cadence while mounted, keep the previous data visible during refreshes, and disable decorative draw-on motion for reduced-motion users.
- **Productivity** separates outcome counters (commits and pull requests), task-duration stats, and volume proxies such as modified files, lines changed, and files by language. The task-duration block counts done tasks completed in the selected range and shows average, median, p90, and total active execution time from `cumulativeActiveMs`; when no qualifying duration data exists, duration values render the unavailable `—` sentinel rather than `0`. The Lines changed card includes **Preview LOC backfill**, an explicit operator control for historical commit-association diff stats. Preview runs the project-scoped backfill in dry-run mode by default and reports scanned rows, distinct commits, updated rows, skipped unavailable commits, and skipped invalid SHAs without writing; **Apply backfill** appears after a preview and requires danger confirmation before persisting additions/deletions to `task_commit_associations`, then renders the same counts as an applied report. It keeps the files-by-language bar and adds a language-share pie from `ProductivityAnalytics.byLanguage`. There is intentionally no productivity line chart because the current productivity response has no per-day throughput or completion time series; no new endpoint is called.
@@ -969,7 +969,7 @@ Features:
- **Signals** is backed by the project-scoped `/api/command-center/signals` endpoint, which aggregates real rows from the local `incidents` table. Verified external connectors (`POST /api/signals/gitlab`, `/webhook`, `/sentry`, `/datadog`, and `/pagerduty`) create triage tasks and also write/resolve incidents, so Signals shows total/open/resolved counts, MTTR when resolved incidents have enough timestamps, and source/severity/status breakdowns from connector traffic. GitLab supports GitLab.com and self-managed project/group issue and merge-request webhooks through the environment-only `FUSION_SIGNAL_GITLAB_SECRET` and `X-Gitlab-Token` header; no GitLab CLI or server-side link fetch is used. Signals adds an open-vs-resolved status pie from the same response. Signals has no per-day series today, so it intentionally does not render a line chart or fabricate a trend. The companion `/api/command-center/signals/connectors` endpoint returns only per-provider configured booleans, allowing the empty state to distinguish "no connector configured" from "connector configured, awaiting signals" without exposing secrets.
- **System** is the canonical system-telemetry destination. It reads local telemetry from `GET /api/system-stats` and, when multiple registered nodes exist, shows a node selector that can proxy the same system-stats payload through `GET /api/nodes/:id/system-stats` for remote nodes. It renders live radial gauges for app CPU, host memory, and heap usage, keeps a small client-side rolling buffer for CPU/memory/heap trend sparklines, adds a recharts CPU/memory/heap line from that same rolling buffer, and adds a task-by-column pie alongside the existing tasks-by-column and agents-by-state bars. Host memory uses OS-available memory (Node `process.availableMemory()` when available, with a flagged `freemem` fallback) so macOS inactive/cache pages are not reported as used. The Vitest process count, manual kill confirmation, auto-kill toggle, threshold controls, and last-auto-kill timestamp moved here unchanged; the standalone System Stats modal and its desktop Header/mobile More affordances were removed.
- **Mission Control** shows live active sessions/runs/nodes, current sessions and nodes, an animated live activity snapshot, and a live SDLC funnel; when idle it reports that live updates resume when work starts. No additional pie or line chart is rendered because the live SDLC funnel already visualizes the panel's only quantitative distribution (`snapshot.columns`), while sessions/nodes are live control lists rather than categorical analytics. Motion-heavy accents respect reduced-motion preferences.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
- CSV exports are available from the analytics endpoints with `?format=csv`. The Tokens CSV includes `nTasks` and `nChatMessages` columns so mixed task/chat totals can be reconciled without relabeling chat turns as tasks. The Workflows CSV includes one row per workflow plus a summary row; the Activity CSV includes daily `agentRuns` values plus summary rows for `(agentRuns.total)`, `(agentRuns.active)`, `(agentRuns.completed)`, and `(agentRuns.failed)`.
Rendering invariants:
- On mobile (`max-width: 768px`), `.cc-tabpanel` remains the sole vertical scroll owner for every chart-bearing tab. Shared chart primitives (`Bar`, `StackedBar`, `Sparkline`, `LineChart`, `RadialGauge`, `Funnel`, `TokenSeriesChart`, and the Command Center recharts wrappers) must shrink within the tabpanel, keep non-zero usable height, avoid stretch/clipping artifacts, and never introduce a competing vertical overflow container.

View File

@@ -52,6 +52,48 @@ function insertTask(db: Database, t: TaskSeed): void {
);
}
function insertChatTokenUsage(db: Database, t: {
id: string;
sourceKind?: string;
chatSessionId?: string | null;
roomId?: string | null;
messageId?: string | null;
projectId?: string | null;
agentId?: string | null;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
cacheWriteTokens?: number;
totalTokens?: number;
modelProvider?: string | null;
modelId?: string | null;
createdAt: string;
}): void {
db.prepare(
`INSERT INTO chat_token_usage
(id, sourceKind, chatSessionId, roomId, messageId, projectId, agentId,
modelProvider, modelId, inputTokens, outputTokens, cachedTokens,
cacheWriteTokens, totalTokens, createdAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
t.id,
t.sourceKind ?? "chat",
t.chatSessionId ?? null,
t.roomId ?? null,
t.messageId ?? null,
t.projectId ?? null,
t.agentId ?? null,
t.modelProvider ?? null,
t.modelId ?? null,
t.inputTokens ?? 0,
t.outputTokens ?? 0,
t.cachedTokens ?? 0,
t.cacheWriteTokens ?? 0,
t.totalTokens ?? ((t.inputTokens ?? 0) + (t.outputTokens ?? 0) + (t.cachedTokens ?? 0) + (t.cacheWriteTokens ?? 0)),
t.createdAt,
);
}
describe("token-analytics", () => {
let tmpDir: string;
let db: Database;
@@ -401,8 +443,111 @@ describe("token-analytics", () => {
);
});
it("includes chat token usage in mixed task and chat totals exactly once", () => {
insertTask(db, {
id: "task-usage",
inputTokens: 100,
outputTokens: 50,
cachedTokens: 10,
cacheWriteTokens: 5,
totalTokens: 165,
lastUsedAt: "2026-03-01T01:00:00.000Z",
tokenUsageModelProvider: "anthropic",
tokenUsageModelId: "claude-sonnet-4-5",
agentId: "executor-agent",
});
insertChatTokenUsage(db, {
id: "chat-1",
chatSessionId: "chat-a",
messageId: "msg-a",
agentId: "agent-chat",
inputTokens: 20,
outputTokens: 10,
cachedTokens: 3,
cacheWriteTokens: 2,
totalTokens: 35,
modelProvider: "openai",
modelId: "gpt-4o",
createdAt: "2026-03-01T02:00:00.000Z",
});
insertChatTokenUsage(db, {
id: "planner-1",
sourceKind: "task-planner-chat",
chatSessionId: "chat-planner",
messageId: "msg-planner",
agentId: "task-planner:task-usage",
inputTokens: 5,
outputTokens: 7,
totalTokens: 12,
modelProvider: "openai",
modelId: "gpt-4o",
createdAt: "2026-03-01T03:00:00.000Z",
});
const result = aggregateTokenAnalytics(db, {
from: "2026-03-01T00:00:00.000Z",
to: "2026-03-01T23:59:59.999Z",
groupBy: "model",
granularity: "day",
});
expect(result.totals).toMatchObject({
inputTokens: 125,
outputTokens: 67,
cachedTokens: 13,
cacheWriteTokens: 7,
totalTokens: 212,
nTasks: 1,
nChatMessages: 2,
});
expect(result.series).toHaveLength(1);
expect(result.series?.[0]).toMatchObject({ bucket: "2026-03-01", totalTokens: 212, nTasks: 1, nChatMessages: 2 });
const groups = new Map(result.groups.map((group) => [group.key, group]));
expect(groups.get("claude-sonnet-4-5")).toMatchObject({ totalTokens: 165, nTasks: 1, nChatMessages: 0 });
expect(groups.get("gpt-4o")).toMatchObject({ totalTokens: 47, nTasks: 0, nChatMessages: 2 });
expect(result.groups.reduce((sum, group) => sum + group.totalTokens, 0)).toBe(result.totals.totalTokens);
});
it("groups chat token usage by provider and agent without inflating task counts", () => {
insertChatTokenUsage(db, {
id: "room-a",
sourceKind: "room-chat",
roomId: "room-1",
messageId: "room-msg-a",
agentId: "agent-a",
inputTokens: 8,
outputTokens: 4,
totalTokens: 12,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-03-01T00:00:00.000Z",
});
insertChatTokenUsage(db, {
id: "room-b",
sourceKind: "room-chat",
roomId: "room-1",
messageId: "room-msg-b",
agentId: "agent-b",
inputTokens: 10,
outputTokens: 5,
totalTokens: 15,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-03-01T00:01:00.000Z",
});
const byProvider = aggregateTokenAnalytics(db, { groupBy: "provider" });
expect(byProvider.totals).toMatchObject({ totalTokens: 27, nTasks: 0, nChatMessages: 2 });
expect(byProvider.groups).toHaveLength(1);
expect(byProvider.groups[0]).toMatchObject({ key: "anthropic", totalTokens: 27, nTasks: 0, nChatMessages: 2 });
const byAgent = aggregateTokenAnalytics(db, { groupBy: "agent" });
expect(new Map(byAgent.groups.map((group) => [group.key, group.totalTokens]))).toEqual(new Map([["agent-b", 15], ["agent-a", 12]]));
});
it("empty range returns zeroed structures, not nulls", () => {
insertTask(db, { id: "t1", inputTokens: 100, totalTokens: 100, lastUsedAt: "2026-03-01T00:00:00.000Z", modelId: "model-A" });
insertChatTokenUsage(db, { id: "chat-out", inputTokens: 100, totalTokens: 100, createdAt: "2026-03-01T00:00:00.000Z" });
const result = aggregateTokenAnalytics(db, {
from: "2027-01-01T00:00:00.000Z",
@@ -416,6 +561,7 @@ describe("token-analytics", () => {
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
nChatMessages: 0,
});
expect(result.groups).toEqual([]);
});

View File

@@ -34,6 +34,9 @@ import type {
ChatRoomStatus,
ChatRoomUpdateInput,
RoomMemberRole,
ChatTokenUsageCreateInput,
ChatTokenUsageRecord,
ChatTokenUsageSourceKind,
} from "./chat-types.js";
// ── Event Types ─────────────────────────────────────────────────────
@@ -133,6 +136,24 @@ interface ChatRoomMessageRow {
createdAt: string;
}
interface ChatTokenUsageRow {
id: string;
sourceKind: string;
chatSessionId: string | null;
roomId: string | null;
messageId: string | null;
projectId: string | null;
agentId: string | null;
modelProvider: string | null;
modelId: string | null;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
totalTokens: number;
createdAt: string;
}
// ── ChatStore Class ─────────────────────────────────────────────────
export class ChatStore extends EventEmitter<ChatStoreEvents> {
@@ -220,6 +241,26 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
};
}
private rowToTokenUsage(row: ChatTokenUsageRow): ChatTokenUsageRecord {
return {
id: row.id,
sourceKind: row.sourceKind as ChatTokenUsageSourceKind,
chatSessionId: row.chatSessionId ?? null,
roomId: row.roomId ?? null,
messageId: row.messageId ?? null,
projectId: row.projectId ?? null,
agentId: row.agentId ?? null,
modelProvider: row.modelProvider ?? null,
modelId: row.modelId ?? null,
inputTokens: row.inputTokens ?? 0,
outputTokens: row.outputTokens ?? 0,
cachedTokens: row.cachedTokens ?? 0,
cacheWriteTokens: row.cacheWriteTokens ?? 0,
totalTokens: row.totalTokens ?? 0,
createdAt: row.createdAt,
};
}
private normalizeRoomName(name: string): string {
return name.trim().replace(/^#+/, "").trim();
}
@@ -1126,4 +1167,68 @@ export class ChatStore extends EventEmitter<ChatStoreEvents> {
this.emit("chat:room:message:updated", updated);
return updated;
}
recordTokenUsage(input: ChatTokenUsageCreateInput): ChatTokenUsageRecord | undefined {
const inputTokens = Math.max(0, Math.trunc(input.inputTokens));
const outputTokens = Math.max(0, Math.trunc(input.outputTokens));
const cachedTokens = Math.max(0, Math.trunc(input.cachedTokens));
const cacheWriteTokens = Math.max(0, Math.trunc(input.cacheWriteTokens));
const totalTokens = Math.max(0, Math.trunc(input.totalTokens ?? (inputTokens + outputTokens + cachedTokens + cacheWriteTokens)));
if (inputTokens === 0 && outputTokens === 0 && cachedTokens === 0 && cacheWriteTokens === 0 && totalTokens === 0) {
return undefined;
}
const record: ChatTokenUsageRecord = {
id: `chat-tokens-${randomUUID().slice(0, 12)}`,
sourceKind: input.sourceKind,
chatSessionId: input.chatSessionId ?? null,
roomId: input.roomId ?? null,
messageId: input.messageId ?? null,
projectId: input.projectId ?? null,
agentId: input.agentId ?? null,
modelProvider: input.modelProvider ?? null,
modelId: input.modelId ?? null,
inputTokens,
outputTokens,
cachedTokens,
cacheWriteTokens,
totalTokens,
createdAt: input.createdAt ?? new Date().toISOString(),
};
/*
* FNXC:ChatTokenAccounting 2026-07-02-00:00:
* Chat interactions are first-class token consumers for Command Center totals, but they are stored in a separate append-only table instead of task.tokenUsage so task execution panels stay task-scoped and planner chat cannot double-count executor/reviewer/triage/merger sessions.
*/
this.db.prepare(`
INSERT INTO chat_token_usage (
id, sourceKind, chatSessionId, roomId, messageId, projectId, agentId,
modelProvider, modelId, inputTokens, outputTokens, cachedTokens,
cacheWriteTokens, totalTokens, createdAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
record.id,
record.sourceKind,
record.chatSessionId,
record.roomId,
record.messageId,
record.projectId,
record.agentId,
record.modelProvider,
record.modelId,
record.inputTokens,
record.outputTokens,
record.cachedTokens,
record.cacheWriteTokens,
record.totalTokens,
record.createdAt,
);
this.db.bumpLastModified();
return record;
}
listTokenUsage(): ChatTokenUsageRecord[] {
const rows = this.db.prepare("SELECT * FROM chat_token_usage ORDER BY createdAt ASC").all() as ChatTokenUsageRow[];
return rows.map((row) => this.rowToTokenUsage(row));
}
}

View File

@@ -154,6 +154,43 @@ export interface ChatMessageCreateInput {
attachments?: ChatAttachment[];
}
export type ChatTokenUsageSourceKind = "chat" | "task-planner-chat" | "room-chat" | "cli-chat" | "chat-title";
export interface ChatTokenUsageRecord {
id: string;
sourceKind: ChatTokenUsageSourceKind;
chatSessionId: string | null;
roomId: string | null;
messageId: string | null;
projectId: string | null;
agentId: string | null;
modelProvider: string | null;
modelId: string | null;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
totalTokens: number;
createdAt: string;
}
export interface ChatTokenUsageCreateInput {
sourceKind: ChatTokenUsageSourceKind;
chatSessionId?: string | null;
roomId?: string | null;
messageId?: string | null;
projectId?: string | null;
agentId?: string | null;
modelProvider?: string | null;
modelId?: string | null;
inputTokens: number;
outputTokens: number;
cachedTokens: number;
cacheWriteTokens: number;
totalTokens?: number;
createdAt?: string;
}
/**
* Input for creating a chat session.
*/

View File

@@ -183,7 +183,7 @@ export function isFts5CorruptionError(error: unknown): boolean {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 135;
const SCHEMA_VERSION = 136;
const TASKS_FTS_AUTOMERGE = 8;
const TASKS_FTS_CRISISMERGE = 16;
@@ -1549,6 +1549,23 @@ export const MIGRATION_ONLY_TABLE_SCHEMAS: Record<string, Record<string, string>
createdAt: "TEXT NOT NULL",
attachments: "TEXT",
},
chat_token_usage: {
id: "TEXT PRIMARY KEY",
sourceKind: "TEXT NOT NULL",
chatSessionId: "TEXT",
roomId: "TEXT",
messageId: "TEXT",
projectId: "TEXT",
agentId: "TEXT",
modelProvider: "TEXT",
modelId: "TEXT",
inputTokens: "INTEGER NOT NULL DEFAULT 0",
outputTokens: "INTEGER NOT NULL DEFAULT 0",
cachedTokens: "INTEGER NOT NULL DEFAULT 0",
cacheWriteTokens: "INTEGER NOT NULL DEFAULT 0",
totalTokens: "INTEGER NOT NULL DEFAULT 0",
createdAt: "TEXT NOT NULL",
},
runAuditEvents: {
id: "TEXT PRIMARY KEY",
timestamp: "TEXT NOT NULL",
@@ -5506,6 +5523,39 @@ export class Database {
});
}
if (version < 136) {
this.applyMigration(136, () => {
/*
* FNXC:ChatTokenAccounting 2026-07-02-00:00:
* Durable chat token rows must be queryable independently from task.tokenUsage so Command Center can sum chat consumers while task detail panels remain execution-only.
*/
this.db.exec(`
CREATE TABLE IF NOT EXISTS chat_token_usage (
id TEXT PRIMARY KEY,
sourceKind TEXT NOT NULL,
chatSessionId TEXT,
roomId TEXT,
messageId TEXT,
projectId TEXT,
agentId TEXT,
modelProvider TEXT,
modelId TEXT,
inputTokens INTEGER NOT NULL DEFAULT 0,
outputTokens INTEGER NOT NULL DEFAULT 0,
cachedTokens INTEGER NOT NULL DEFAULT 0,
cacheWriteTokens INTEGER NOT NULL DEFAULT 0,
totalTokens INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idxChatTokenUsageCreatedAt ON chat_token_usage(createdAt);
CREATE INDEX IF NOT EXISTS idxChatTokenUsageProjectCreatedAt ON chat_token_usage(projectId, createdAt);
CREATE INDEX IF NOT EXISTS idxChatTokenUsageSessionMessage ON chat_token_usage(chatSessionId, messageId);
CREATE INDEX IF NOT EXISTS idxChatTokenUsageRoomMessage ON chat_token_usage(roomId, messageId);
CREATE INDEX IF NOT EXISTS idxChatTokenUsageAgentCreatedAt ON chat_token_usage(agentId, createdAt);
`);
});
}
}
/**

View File

@@ -1987,6 +1987,9 @@ export type {
ChatRoomUpdateInput,
ChatRoomMessageCreateInput,
ChatRoomMessagesFilter,
ChatTokenUsageSourceKind,
ChatTokenUsageRecord,
ChatTokenUsageCreateInput,
} from "./chat-types.js";
export { ChatStore } from "./chat-store.js";
export type { ChatStoreEvents } from "./chat-store.js";

View File

@@ -3,10 +3,12 @@ import { costFor, type CostResult, type ModelPricingOverrides } from "./model-pr
import type { TaskTokenUsagePerModel } from "./types.js";
/**
* Token-consumption analytics over the `tasks` table, generalizing the fixed
* 24h/7d/all-time windows of `agent-token-usage.ts` to an arbitrary `(from, to)`
* range. Sums the `tokenUsage*` columns filtered by `tokenUsageLastUsedAt` and
* groups by model / provider / node / agent.
* Token-consumption analytics over task execution rows plus durable chat-token
* rows, generalizing the fixed 24h/7d/all-time windows of
* `agent-token-usage.ts` to an arbitrary `(from, to)` range. Sums task
* `tokenUsage*` columns filtered by `tokenUsageLastUsedAt` and chat
* `chat_token_usage` rows filtered by `createdAt`, then groups by model /
* provider / node / agent.
*
* Inclusivity: `from`/`to` bounds are **inclusive** (`>= from AND <= to`),
* matching `usage-events.ts` and the range-scan house style. A task whose
@@ -30,6 +32,8 @@ export interface TokenTotals {
totalTokens: number;
/** Number of tasks that contributed to these totals. */
nTasks: number;
/** Number of chat assistant/room messages that contributed to these totals. Legacy callers may omit it in fixtures; analytics always returns a number. */
nChatMessages?: number;
}
/** One group's token totals, keyed by the grouped dimension value. */
@@ -97,6 +101,7 @@ function emptyTotals(): TokenTotals {
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
nChatMessages: 0,
};
}
@@ -117,20 +122,40 @@ interface TaskTokenRow {
tokenUsageLastUsedAt: string;
}
function groupKeyFor(row: TaskTokenRow, groupBy: TokenGroupBy): string | null {
interface ChatTokenRow {
id: string;
sourceKind: string;
chatSessionId: string | null;
roomId: string | null;
messageId: string | null;
projectId: string | null;
agentId: string | null;
inputTokens: number | null;
outputTokens: number | null;
cachedTokens: number | null;
cacheWriteTokens: number | null;
totalTokens: number | null;
modelProvider: string | null;
modelId: string | null;
createdAt: string;
}
type TokenContributionRow = (TaskTokenRow & { contributionKind: "task" }) | (ChatTokenRow & { contributionKind: "chat" });
function groupKeyFor(row: TokenContributionRow, groupBy: TokenGroupBy): string | null {
switch (groupBy) {
case "model":
/*
* FNXC:TokenAnalytics 2026-06-19-16:09:
* By-model analytics expands durable per-model buckets before this legacy path runs. Keep this single-snapshot fallback for pre-migration, empty, or malformed per-model rows so historical grouping never throws.
* By-model analytics expands durable per-model task buckets before this legacy path runs. Chat rows are already one model snapshot per assistant turn.
*/
return row.tokenUsageModelId ?? row.modelId;
return row.contributionKind === "task" ? row.tokenUsageModelId ?? row.modelId : row.modelId;
case "provider":
return row.tokenUsageModelProvider ?? row.modelProvider;
return row.contributionKind === "task" ? row.tokenUsageModelProvider ?? row.modelProvider : row.modelProvider;
case "node":
return row.checkoutNodeId;
return row.contributionKind === "task" ? row.checkoutNodeId : null;
case "agent":
return row.assignedAgentId;
return row.contributionKind === "task" ? row.assignedAgentId : row.agentId;
}
}
@@ -154,7 +179,7 @@ function emptyCostAccumulator(): CostAccumulator {
function addRowCost(
acc: CostAccumulator,
row: TaskTokenRow,
row: TokenContributionRow,
now?: number,
pricingOverrides?: ModelPricingOverrides,
): void {
@@ -170,8 +195,8 @@ function addRowCost(
cacheWriteTokens: row.cacheWriteTokens ?? 0,
},
{
provider: row.tokenUsageModelProvider ?? row.modelProvider,
model: row.tokenUsageModelId ?? row.modelId,
provider: row.contributionKind === "task" ? row.tokenUsageModelProvider ?? row.modelProvider : row.modelProvider,
model: row.contributionKind === "task" ? row.tokenUsageModelId ?? row.modelId : row.modelId,
},
now,
pricingOverrides,
@@ -195,7 +220,7 @@ function finalizeCost(acc: CostAccumulator): CostResult {
interface ParsedPerModelRows {
valid: boolean;
rows: TaskTokenRow[];
rows: TokenContributionRow[];
}
function parsePerModelRows(row: TaskTokenRow): ParsedPerModelRows {
@@ -223,6 +248,7 @@ function parsePerModelRows(row: TaskTokenRow): ParsedPerModelRows {
tokenUsageModelProvider: typeof entry.modelProvider === "string" ? entry.modelProvider : null,
tokenUsageModelId: typeof entry.modelId === "string" ? entry.modelId : null,
tokenUsageLastUsedAt: typeof entry.lastUsedAt === "string" ? entry.lastUsedAt : row.tokenUsageLastUsedAt,
contributionKind: "task" as const,
};
});
return { valid: rows.length > 0, rows };
@@ -235,7 +261,7 @@ function isWithinRange(isoTimestamp: string, from?: string, to?: string): boolea
return (from === undefined || isoTimestamp >= from) && (to === undefined || isoTimestamp <= to);
}
function addRow(totals: TokenTotals, row: TaskTokenRow, taskIds?: Set<string>): void {
function addRow(totals: TokenTotals, row: TokenContributionRow, ids?: Set<string>): void {
totals.inputTokens += row.inputTokens ?? 0;
totals.outputTokens += row.outputTokens ?? 0;
totals.cachedTokens += row.cachedTokens ?? 0;
@@ -249,9 +275,13 @@ function addRow(totals: TokenTotals, row: TaskTokenRow, taskIds?: Set<string>):
(row.outputTokens ?? 0) +
(row.cachedTokens ?? 0) +
(row.cacheWriteTokens ?? 0);
if (!taskIds || !taskIds.has(row.id)) {
totals.nTasks += 1;
taskIds?.add(row.id);
if (!ids || !ids.has(row.id)) {
if (row.contributionKind === "task") {
totals.nTasks += 1;
} else {
totals.nChatMessages = (totals.nChatMessages ?? 0) + 1;
}
ids?.add(row.id);
}
}
@@ -265,14 +295,19 @@ function isoWeekBucket(isoTimestamp: string): string {
return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
}
function bucketFor(row: TaskTokenRow, granularity: TokenTimeGranularity): string {
function contributionTimestamp(row: TokenContributionRow): string {
return row.contributionKind === "task" ? row.tokenUsageLastUsedAt : row.createdAt;
}
function bucketFor(row: TokenContributionRow, granularity: TokenTimeGranularity): string {
const timestamp = contributionTimestamp(row);
switch (granularity) {
case "hour":
return row.tokenUsageLastUsedAt.slice(0, 13);
return timestamp.slice(0, 13);
case "day":
return row.tokenUsageLastUsedAt.slice(0, 10);
return timestamp.slice(0, 10);
case "week":
return isoWeekBucket(row.tokenUsageLastUsedAt);
return isoWeekBucket(timestamp);
}
}
@@ -331,6 +366,39 @@ export function aggregateTokenAnalytics(
)
.all(...params) as TaskTokenRow[];
const chatClauses: string[] = [];
const chatParams: string[] = [];
if (query.from !== undefined) {
chatClauses.push("createdAt >= ?");
chatParams.push(query.from);
}
if (query.to !== undefined) {
chatClauses.push("createdAt <= ?");
chatParams.push(query.to);
}
const chatWhere = chatClauses.length > 0 ? `WHERE ${chatClauses.join(" AND ")}` : "";
const chatRows = db
.prepare(
`SELECT
id,
sourceKind,
chatSessionId,
roomId,
messageId,
projectId,
agentId,
inputTokens,
outputTokens,
cachedTokens,
cacheWriteTokens,
totalTokens,
modelProvider,
modelId,
createdAt
FROM chat_token_usage ${chatWhere}`,
)
.all(...chatParams) as ChatTokenRow[];
const totals = emptyTotals();
const totalCost = emptyCostAccumulator();
const groupMap = new Map<string | null, TokenGroupSummary>();
@@ -342,21 +410,26 @@ export function aggregateTokenAnalytics(
const now = query.now;
const pricingOverrides = query.pricingOverrides;
const totalTaskIds = new Set<string>();
const groupTaskIds = new Map<string | null, Set<string>>();
const seriesTaskIds = new Map<string, Set<string>>();
const totalContributionIds = new Set<string>();
const groupContributionIds = new Map<string | null, Set<string>>();
const seriesContributionIds = new Map<string, Set<string>>();
/*
* FNXC:ChatTokenAccounting 2026-07-02-00:00:
* Command Center token totals include durable chat turns alongside task execution tokens. `nTasks` remains task-only and `nChatMessages` counts chat assistant/room messages so labels never imply chat turns are tasks.
*/
for (const row of rows) {
const taskRow: TokenContributionRow = { ...row, contributionKind: "task" };
const perModel = parsePerModelRows(row);
const rowInRange = isWithinRange(row.tokenUsageLastUsedAt, query.from, query.to);
const contributionRows = perModel.valid
? perModel.rows.filter((bucketRow) => isWithinRange(bucketRow.tokenUsageLastUsedAt, query.from, query.to))
? perModel.rows.filter((bucketRow) => isWithinRange(contributionTimestamp(bucketRow), query.from, query.to))
: rowInRange
? [row]
? [taskRow]
: [];
for (const contributionRow of contributionRows) {
addRow(totals, contributionRow, totalTaskIds);
addRow(totals, contributionRow, totalContributionIds);
addRowCost(totalCost, contributionRow, now, pricingOverrides);
if (groupBy) {
const key = groupKeyFor(contributionRow, groupBy);
@@ -365,9 +438,9 @@ export function aggregateTokenAnalytics(
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
groupMap.set(key, group);
groupCostMap.set(key, emptyCostAccumulator());
groupTaskIds.set(key, new Set<string>());
groupContributionIds.set(key, new Set<string>());
}
addRow(group, contributionRow, groupTaskIds.get(key)!);
addRow(group, contributionRow, groupContributionIds.get(key)!);
addRowCost(groupCostMap.get(key)!, contributionRow, now, pricingOverrides);
}
if (granularity) {
@@ -377,14 +450,44 @@ export function aggregateTokenAnalytics(
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
seriesMap.set(bucket, point);
seriesCostMap.set(bucket, emptyCostAccumulator());
seriesTaskIds.set(bucket, new Set<string>());
seriesContributionIds.set(bucket, new Set<string>());
}
addRow(point, contributionRow, seriesTaskIds.get(bucket)!);
addRow(point, contributionRow, seriesContributionIds.get(bucket)!);
addRowCost(seriesCostMap.get(bucket)!, contributionRow, now, pricingOverrides);
}
}
}
for (const row of chatRows) {
const contributionRow: TokenContributionRow = { ...row, contributionKind: "chat" };
addRow(totals, contributionRow, totalContributionIds);
addRowCost(totalCost, contributionRow, now, pricingOverrides);
if (groupBy) {
const key = groupKeyFor(contributionRow, groupBy);
let group = groupMap.get(key);
if (!group) {
group = { key, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
groupMap.set(key, group);
groupCostMap.set(key, emptyCostAccumulator());
groupContributionIds.set(key, new Set<string>());
}
addRow(group, contributionRow, groupContributionIds.get(key)!);
addRowCost(groupCostMap.get(key)!, contributionRow, now, pricingOverrides);
}
if (granularity) {
const bucket = bucketFor(contributionRow, granularity);
let point = seriesMap.get(bucket);
if (!point) {
point = { bucket, ...emptyTotals(), cost: { usd: null, unavailable: false, stale: false } };
seriesMap.set(bucket, point);
seriesCostMap.set(bucket, emptyCostAccumulator());
seriesContributionIds.set(bucket, new Set<string>());
}
addRow(point, contributionRow, seriesContributionIds.get(bucket)!);
addRowCost(seriesCostMap.get(bucket)!, contributionRow, now, pricingOverrides);
}
}
// Finalize per-group cost from each group's accumulator.
for (const [key, group] of groupMap) {
group.cost = finalizeCost(groupCostMap.get(key)!);

View File

@@ -1015,8 +1015,8 @@ describe("CommandCenter shell", () => {
render(<CommandCenter />);
const tablist = screen.getByRole("tablist");
const tabs = within(tablist).getAllByRole("tab");
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(13);
// Overview, Tokens, Tools, Activity, Productivity, Team, Workflows, Ecosystem, GitHub, GitLab, Signals, System, Reliability, Mission Control.
expect(tabs.length).toBe(14);
expect(screen.queryByTestId("command-center-tab-nodes")).toBeNull();
// roving tabindex: exactly one tab is focusable.
const focusable = tabs.filter((tab) => tab.getAttribute("tabindex") === "0");

View File

@@ -200,6 +200,10 @@ export function TokensArea({ range }: { range: DateRange }) {
<div className="cc-stat-label">{t("commandCenter.tokens.tasks", "Tasks")}</div>
<div className="cc-stat-value">{formatCount(totals?.nTasks ?? 0)}</div>
</div>
<div className="card cc-stat-card">
<div className="cc-stat-label">{t("commandCenter.tokens.chatMessages", "Chat turns")}</div>
<div className="cc-stat-value">{formatCount(totals?.nChatMessages ?? 0)}</div>
</div>
</div>
</div>

View File

@@ -19,6 +19,7 @@ const mockChatStore = {
setCliSessionFile: vi.fn(),
setInFlightGeneration: vi.fn(),
getRoomMessages: vi.fn(),
recordTokenUsage: vi.fn(),
};
function makeManager(): ChatManager {
@@ -68,4 +69,43 @@ describe("ChatManager.sendMessage — cli-agent send branch", () => {
expect(ensureSession).toHaveBeenCalledWith("chat-cli2", { projectId: "proj-from-session" });
expect(send).toHaveBeenCalledWith("chat-cli2", "queued please");
});
it("persists cli-chat token usage from the runner telemetry snapshot", async () => {
mockChatStore.getSession.mockReturnValue({
id: "chat-cli3",
cliExecutorAdapterId: "claude-code",
projectId: "proj-1",
agentId: "agent-cli",
modelId: "claude-sonnet-4-5",
});
const ensureSession = vi.fn(async () => "cli-session-3");
const send = vi.fn(async () => "sent" as const);
const getTokenUsageSnapshot = vi.fn(async () => ({
tokens: { input: 17, output: 23, cacheRead: 5, cacheWrite: 7, total: 52 },
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
messageId: "msg-cli-assistant",
createdAt: "2026-07-02T00:00:00.000Z",
}));
const manager = makeManager();
manager.setCliChatRunner({ ensureSession, send, getTokenUsageSnapshot }, "proj-1");
await manager.sendMessage("chat-cli3", "hello cli");
expect(mockChatStore.recordTokenUsage).toHaveBeenCalledWith({
sourceKind: "cli-chat",
chatSessionId: "chat-cli3",
messageId: "msg-cli-assistant",
projectId: "proj-1",
agentId: "agent-cli",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
createdAt: "2026-07-02T00:00:00.000Z",
inputTokens: 17,
outputTokens: 23,
cachedTokens: 5,
cacheWriteTokens: 7,
totalTokens: 52,
});
});
});

View File

@@ -79,6 +79,7 @@ const mockChatStore = {
setCliSessionFile: vi.fn(),
setInFlightGeneration: vi.fn(),
getRoomMessages: vi.fn(),
recordTokenUsage: vi.fn(),
};
const mockAgentStore = {
@@ -188,6 +189,105 @@ describe("ChatManager.sendMessage", () => {
vi.restoreAllMocks();
});
it("records successful chat session token usage from provider stats", async () => {
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
model: { provider: "openai", id: "gpt-4o" },
getSessionStats: () => ({ tokens: { input: 21, output: 13, cacheRead: 5, cacheWrite: 2, total: 41 } }),
state: { messages: [{ role: "assistant", content: "Tokened response" }] },
},
}));
mockChatStore.getSession.mockReturnValue({
id: "chat-001",
agentId: "agent-001",
status: "active",
projectId: "project-a",
});
mockChatStore.addMessage.mockImplementation((_sessionId, input) => ({
id: input.role === "assistant" ? "assistant-msg" : "user-msg",
sessionId: "chat-001",
role: input.role,
content: input.content,
createdAt: "2026-07-02T00:00:00.000Z",
}));
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(mockChatStore.recordTokenUsage).toHaveBeenCalledWith(expect.objectContaining({
sourceKind: "chat",
chatSessionId: "chat-001",
messageId: "assistant-msg",
projectId: "project-a",
agentId: "agent-001",
modelProvider: "openai",
modelId: "gpt-4o",
inputTokens: 21,
outputTokens: 13,
cachedTokens: 5,
cacheWriteTokens: 2,
totalTokens: 41,
}));
});
it("records task-detail planner chat tokens separately from task execution usage", async () => {
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
getSessionStats: () => ({ tokens: { input: 10, output: 4, cacheRead: 0, cacheWrite: 0 } }),
state: { messages: [{ role: "assistant", content: "Planner response" }] },
},
}));
mockChatStore.getSession.mockReturnValue({
id: "chat-planner",
agentId: "task-planner:FN-7449",
status: "active",
projectId: "project-a",
});
mockChatStore.addMessage.mockImplementation((_sessionId, input) => ({
id: input.role === "assistant" ? "planner-assistant-msg" : "planner-user-msg",
sessionId: "chat-planner",
role: input.role,
content: input.content,
createdAt: "2026-07-02T00:00:00.000Z",
}));
const taskStore = {
getTask: vi.fn().mockResolvedValue({ id: "FN-7449", title: "Task", description: "desc", column: "todo", steps: [] }),
getSettings: vi.fn().mockResolvedValue({}),
};
const chatManager = new ChatManager(mockChatStore as any, "/tmp/test", mockAgentStore as any, undefined, undefined, undefined, taskStore as any);
await chatManager.sendMessage("chat-planner", "How many tokens?");
expect(mockChatStore.recordTokenUsage).toHaveBeenCalledWith(expect.objectContaining({
sourceKind: "task-planner-chat",
chatSessionId: "chat-planner",
messageId: "planner-assistant-msg",
agentId: "task-planner:FN-7449",
totalTokens: 14,
}));
});
it("does not record chat token usage when session stats are unavailable or zero", async () => {
__setCreateResolvedAgentSession(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 } }),
state: { messages: [{ role: "assistant", content: "No stats" }] },
},
}));
const chatManager = createChatManager();
await chatManager.sendMessage("chat-001", "Hello");
expect(mockChatStore.recordTokenUsage).not.toHaveBeenCalled();
});
describe("mention parsing and context", () => {
it("parseMentions extracts known agent names from content", async () => {
mockAgentStore.listAgents.mockResolvedValue([

View File

@@ -139,6 +139,7 @@ describe("tokenAnalyticsToTable", () => {
cacheWriteTokens: 0,
totalTokens: 0,
nTasks: 0,
nChatMessages: 0,
},
cost: { usd: null, unavailable: false, stale: false },
groups: [],
@@ -165,6 +166,7 @@ describe("tokenAnalyticsToTable", () => {
cacheWriteTokens: 0,
totalTokens: 30,
nTasks: 1,
nChatMessages: 2,
cost: { usd: 0.01, unavailable: false, stale: false },
},
];

View File

@@ -22,6 +22,7 @@ import type {
ChatRoomMessage,
ChatSession,
ChatSessionCreateInput,
ChatTokenUsageCreateInput,
MessageStore,
Settings,
TaskStore,
@@ -234,6 +235,43 @@ const MAX_REFERENCED_FILE_SIZE = 50 * 1024;
export const TASK_PLANNER_CHAT_AGENT_ID_PREFIX = "task-planner:";
const ROOM_AMBIENT_MAX_RESPONDERS = 5;
type ChatSessionStatsLike = { tokens?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } };
type ChatTokenDelta = Pick<ChatTokenUsageCreateInput, "inputTokens" | "outputTokens" | "cachedTokens" | "cacheWriteTokens" | "totalTokens">;
function normalizeChatTokenDelta(stats: ChatSessionStatsLike | undefined): ChatTokenDelta | undefined {
const tokens = stats?.tokens;
if (!tokens) return undefined;
const inputTokens = Math.max(0, Math.trunc(tokens.input ?? 0));
const outputTokens = Math.max(0, Math.trunc(tokens.output ?? 0));
const cachedTokens = Math.max(0, Math.trunc(tokens.cacheRead ?? 0));
const cacheWriteTokens = Math.max(0, Math.trunc(tokens.cacheWrite ?? 0));
const totalTokens = Math.max(0, Math.trunc(tokens.total ?? (inputTokens + outputTokens + cachedTokens + cacheWriteTokens)));
if (inputTokens === 0 && outputTokens === 0 && cachedTokens === 0 && cacheWriteTokens === 0 && totalTokens === 0) return undefined;
return { inputTokens, outputTokens, cachedTokens, cacheWriteTokens, totalTokens };
}
async function readChatSessionTokenDelta(session: unknown): Promise<ChatTokenDelta | undefined> {
const accessor = (session as { getSessionStats?: () => ChatSessionStatsLike | Promise<ChatSessionStatsLike> }).getSessionStats;
if (typeof accessor !== "function") return undefined;
try {
return normalizeChatTokenDelta(await accessor.call(session));
} catch {
return undefined;
}
}
function modelSnapshotForTokenUsage(session: unknown, fallback?: { fallbackModel?: string }): { provider: string | null; modelId: string | null } {
const model = (session as { model?: { provider?: string; id?: string } }).model;
if (model?.provider || model?.id) {
return { provider: model.provider ?? null, modelId: model.id ?? null };
}
if (fallback?.fallbackModel?.includes("/")) {
const [provider, ...modelParts] = fallback.fallbackModel.split("/");
return { provider: provider || null, modelId: modelParts.join("/") || null };
}
return { provider: null, modelId: fallback?.fallbackModel ?? null };
}
type ChatCustomTool = ReturnType<typeof createWorkflowAuthoringTools>[number];
type ChatToolExecute = (...args: unknown[]) => Promise<unknown>;
@@ -1053,6 +1091,20 @@ export class ChatManager {
private cliChatRunner?: {
ensureSession(chatSessionId: string, opts: { projectId: string; worktreePath?: string | null }): Promise<string>;
send(chatSessionId: string, text: string): Promise<"sent" | "queued">;
getSessionStats?(chatSessionId: string): ChatSessionStatsLike | Promise<ChatSessionStatsLike | undefined> | undefined;
getTokenUsageSnapshot?(chatSessionId: string): ({
tokens?: ChatSessionStatsLike["tokens"];
modelProvider?: string | null;
modelId?: string | null;
messageId?: string | null;
createdAt?: string | null;
}) | Promise<{
tokens?: ChatSessionStatsLike["tokens"];
modelProvider?: string | null;
modelId?: string | null;
messageId?: string | null;
createdAt?: string | null;
} | undefined> | undefined;
};
/** Project id used when the runner spawns a CLI session for a chat. */
private cliChatProjectId?: string;
@@ -1532,7 +1584,7 @@ export class ChatManager {
continue;
}
this.chatStore.addRoomMessage(roomId, {
const assistantMessage = this.chatStore.addRoomMessage(roomId, {
role: "assistant",
content: response.content,
thinkingOutput: response.thinkingOutput,
@@ -1540,6 +1592,17 @@ export class ChatManager {
senderAgentId: responder.id,
mentions: mentions.map((mention) => mention.agentId),
});
if (response.tokenUsage) {
this.chatStore.recordTokenUsage({
sourceKind: "room-chat",
roomId,
messageId: assistantMessage.id,
projectId: room.projectId ?? null,
agentId: responder.id,
createdAt: assistantMessage.createdAt,
...response.tokenUsage,
});
}
successfulResponderIds.push(responder.id);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
@@ -1583,7 +1646,7 @@ export class ChatManager {
responder: Agent;
modelProvider?: string;
modelId?: string;
}): Promise<{ content: string; thinkingOutput: string | null; metadata?: Record<string, unknown> }> {
}): Promise<{ content: string; thinkingOutput: string | null; metadata?: Record<string, unknown>; tokenUsage?: ChatTokenDelta & { modelProvider: string | null; modelId: string | null } }> {
await ensureEngineReady();
let systemPrompt = CHAT_SYSTEM_PROMPT;
@@ -1724,6 +1787,8 @@ export class ChatManager {
throw new Error("Room responder returned an empty reply");
}
const tokenDelta = await readChatSessionTokenDelta(resolvedSession.session);
const model = modelSnapshotForTokenUsage(resolvedSession.session, roomFallbackInfo);
return {
content: finalContent,
thinkingOutput: null,
@@ -1731,6 +1796,7 @@ export class ChatManager {
roomId: input.roomId,
...(roomFallbackInfo ? { fallback: roomFallbackInfo } : {}),
},
...(tokenDelta ? { tokenUsage: { ...tokenDelta, modelProvider: model.provider, modelId: model.modelId } } : {}),
};
} finally {
resolvedSession.session.dispose?.();
@@ -1807,6 +1873,26 @@ export class ChatManager {
projectId: this.cliChatProjectId ?? session.projectId ?? "",
});
await runner.send(sessionId, content);
const usageSnapshot = await runner.getTokenUsageSnapshot?.(sessionId);
const sessionStats = usageSnapshot ?? (await runner.getSessionStats?.(sessionId));
const tokenDelta = normalizeChatTokenDelta(sessionStats);
if (tokenDelta) {
/*
* FNXC:ChatTokenAccounting 2026-07-02-00:00:
* CLI-agent-backed chat returns before the dashboard model loop, so read the runner's per-turn telemetry snapshot here and persist it as `cli-chat`. This keeps CLI/pi chat tokens in Command Center while leaving task execution tokenUsage untouched.
*/
this.chatStore.recordTokenUsage({
sourceKind: "cli-chat",
chatSessionId: sessionId,
messageId: usageSnapshot?.messageId ?? null,
projectId: session.projectId ?? this.cliChatProjectId ?? null,
agentId: session.agentId ?? null,
modelProvider: usageSnapshot?.modelProvider ?? null,
modelId: usageSnapshot?.modelId ?? session.modelId ?? null,
createdAt: usageSnapshot?.createdAt ?? new Date().toISOString(),
...tokenDelta,
});
}
} finally {
const current = this.activeGenerations.get(sessionId);
if (current?.generationId === generationId) {
@@ -2296,6 +2382,26 @@ export class ChatManager {
metadata: Object.keys(assistantMetadata).length > 0 ? assistantMetadata : undefined,
});
const tokenDelta = await readChatSessionTokenDelta(agentResult.session);
if (tokenDelta) {
const model = modelSnapshotForTokenUsage(agentResult.session, fallbackInfo);
/*
* FNXC:ChatTokenAccounting 2026-07-02-00:00:
* Successful dashboard chat turns persist provider-reported session stats as chat-token rows. Task-detail planner chat uses sourceKind `task-planner-chat` instead of task.tokenUsage so the planner's own model call is visible in Command Center without mutating execution totals for the task it discusses.
*/
this.chatStore.recordTokenUsage({
sourceKind: taskPlannerChatTaskId ? "task-planner-chat" : "chat",
chatSessionId: sessionId,
messageId: assistantMessage.id,
projectId: session.projectId ?? null,
agentId: session.agentId ?? null,
modelProvider: model.provider,
modelId: model.modelId,
createdAt: assistantMessage.createdAt,
...tokenDelta,
});
}
this.flushInFlightGenerationPersist(sessionId, null);
// Broadcast done event with persisted assistant snapshot so clients can

View File

@@ -85,6 +85,7 @@ export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
"cacheWriteTokens",
"totalTokens",
"nTasks",
"nChatMessages",
"costUsd",
"costUnavailable",
];
@@ -98,6 +99,7 @@ export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
g.cacheWriteTokens,
g.totalTokens,
g.nTasks,
g.nChatMessages ?? 0,
g.cost.usd,
g.cost.unavailable,
]);
@@ -116,6 +118,7 @@ export function tokenAnalyticsToTable(result: TokenAnalytics): CsvTable {
t.cacheWriteTokens,
t.totalTokens,
t.nTasks,
t.nChatMessages ?? 0,
result.cost.usd,
result.cost.unavailable,
],