Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions config/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2370,9 +2370,17 @@ export function loadConfig(argv = process.argv, options = {}) {
telegramBotEnabled,
telegramCommandEnabled,
telegramVerbosity,

telemetry: Object.freeze({
costPer1kTokensUsd: Object.freeze({
claude: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.claude)) ? Number(configData.telemetry.costPer1kTokensUsd.claude) : 0.003,
codex: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.codex)) ? Number(configData.telemetry.costPer1kTokensUsd.codex) : 0.002,
gemini: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.gemini)) ? Number(configData.telemetry.costPer1kTokensUsd.gemini) : 0.0001,
Comment on lines +2373 to +2377
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

triggerSystem is no longer included in the returned config object (it was replaced by the new telemetry block). triggerSystem is referenced in infra/server code and config-validation tests, so omitting it will break consumers. Add triggerSystem back to the exported config (alongside telemetry, not instead of it).

Copilot uses AI. Check for mistakes.
copilot: Number.isFinite(Number(configData.telemetry?.costPer1kTokensUsd?.copilot)) ? Number(configData.telemetry.costPer1kTokensUsd.copilot) : 0,
}),
}),
triggerSystem,
workflowWorktreeRecoveryCooldownMin,
workflows,
workflowWorktreeRecoveryCooldownMin,
worktreeBootstrap,

// GitHub Reconciler
Expand Down Expand Up @@ -2521,4 +2529,3 @@ export {
};
export default loadConfig;


137 changes: 137 additions & 0 deletions tests/tui-telemetry-screen.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from "vitest";

import {
buildProviderUsageRows,
buildRateLimitHeatmap,
buildTaskFunnel,
buildTelemetryModel,
estimateProviderCost,
normalizeTelemetryRateMap,
renderTelemetrySparkline,
} from "../tui/screens/telemetry-screen-helpers.mjs";
import { getNextScreenForInput, SCREEN_ORDER } from "../tui/lib/navigation.mjs";

describe("tui telemetry screen helpers", () => {
it("renders block-only sparklines capped to the latest 60 samples", () => {
const input = Array.from({ length: 80 }, (_, index) => index);
const line = renderTelemetrySparkline(input);

expect(line).toHaveLength(60);
expect(line).toMatch(/^[▁▂▃▄▅▆▇█]+$/u);
});

it("normalizes provider rate maps with documented defaults", () => {
expect(normalizeTelemetryRateMap({})).toEqual({
claude: 0.003,
codex: 0.002,
gemini: 0.0001,
copilot: 0,
});
});

it("estimates zero cost when a provider rate is missing", () => {
expect(estimateProviderCost({ totalTokens: 1500, ratePer1k: null })).toBe(0);
expect(estimateProviderCost({ totalTokens: 1500, ratePer1k: 0 })).toBe(0);
});

it("builds provider rows, highlights the busiest provider, and totals estimates", () => {
const rows = buildProviderUsageRows({
providers: {
codex: {
sessions: 3,
tokensIn: 3000,
tokensOut: 1000,
avgSessionLengthSec: 180,
errorCount: 1,
},
claude: {
sessions: 1,
tokensIn: 500,
tokensOut: 500,
avgSessionLengthSec: 60,
errorCount: 2,
},
},
rateMap: { codex: 0.002, claude: 0.003 },
});

expect(rows[0].provider).toBe("codex");
expect(rows[0].highlight).toBe("cyan");
expect(rows[0].costEstimateUsd).toBeCloseTo(0.008, 6);
expect(rows[1].provider).toBe("claude");
expect(rows[1].highlight).toBe(null);
});

it("builds a 24-hour 429 heatmap and marks the current hour", () => {
const cells = buildRateLimitHeatmap({
hourly429s: [0, 0, 1, 2, 0, 5],
currentHour: 5,
});

expect(cells).toHaveLength(24);
expect(cells[0]).toMatchObject({ label: "no data", tone: "dim", isCurrentHour: false });
expect(cells[2]).toMatchObject({ count: 1, tone: "yellow" });
expect(cells[5]).toMatchObject({ count: 5, tone: "red", isCurrentHour: true });
});

it("builds funnel counts and conversion percentages across stages", () => {
const funnel = buildTaskFunnel({ todo: 10, inProgress: 5, review: 4, done: 3, failed: 1 });

expect(funnel.stages.map((stage) => stage.key)).toEqual([
"todo",
"in_progress",
"review",
"done",
"failed",
]);
expect(funnel.stages[1].conversionPct).toBe(50);
expect(funnel.stages[3].conversionPct).toBe(30);
expect(funnel.stages[4].conversionPct).toBe(10);
});

it("builds a composite telemetry model with estimated session and day totals", () => {
const model = buildTelemetryModel({
stats: {
telemetry: {
throughputPerSecond: Array.from({ length: 60 }, (_, index) => index % 8),
errorsPerWindow: [0, 1, 0, 2],
retriesPerWindow: [1, 1, 2, 3],
providers: {
codex: {
sessions: 2,
tokensIn: 1000,
tokensOut: 500,
dayTokensIn: 4000,
dayTokensOut: 2000,
avgSessionLengthSec: 90,
errorCount: 1,
},
},
hourly429s: [0, 0, 0, 3],
taskFunnel: { todo: 4, inProgress: 2, review: 1, done: 1, failed: 0 },
},
},
config: {
telemetry: {
costPer1kTokensUsd: {
codex: 0.002,
},
},
},
currentHour: 3,
});

expect(model.providerRows[0].provider).toBe("codex");
expect(model.cost.sessionEstimateUsd).toBeCloseTo(0.003, 6);
expect(model.cost.dayEstimateUsd).toBeCloseTo(0.012, 6);
expect(model.heatmap[3].isCurrentHour).toBe(true);
expect(model.sparklines.throughput).toHaveLength(60);
});
});

describe("tui navigation telemetry tab", () => {
it("adds telemetry as the fourth screen", () => {
expect(SCREEN_ORDER).toEqual(["status", "tasks", "agents", "telemetry"]);
expect(getNextScreenForInput("status", "4")).toBe("telemetry");
});
});
101 changes: 101 additions & 0 deletions tests/tui-telemetry-ui.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";

import {
buildFunnel,
buildProviderStats,
buildRateLimitHours,
deriveTelemetrySnapshot,
renderSparkline,
} from "../ui/tui/telemetry-helpers.js";

describe("TelemetryScreen helpers", () => {
it("renders empty sparkline for no values", () => {
expect(renderSparkline([])).toBe("");
});

it("renders scaled block sparkline", () => {
expect(renderSparkline([0, 1, 2, 3])).toBe("▁▃▆█");
});

it("renders flat sparkline for all zeros", () => {
expect(renderSparkline([0, 0, 0])).toBe("▁▁▁");
});

it("aggregates provider stats with configured rates", () => {
const rows = buildProviderStats([
{
provider: "CLAUDE",
inputTokens: 1000,
outputTokens: 500,
startedAt: "2026-03-25T10:00:00Z",
lastActiveAt: "2026-03-25T10:10:00Z",
errorCount: 2,
},
{
provider: "codex",
inputTokens: 200,
outputTokens: 300,
durationSeconds: 30,
},
], {
claude: 0.003,
codex: 0.002,
});

const claude = rows.find((row) => row.provider === "claude");
const codex = rows.find((row) => row.provider === "codex");
expect(claude.totalTokens).toBe(1500);
expect(claude.estimatedCostUsd).toBeCloseTo(0.0045, 6);
expect(claude.errorCount).toBe(2);
expect(codex.totalTokens).toBe(500);
expect(codex.estimatedCostUsd).toBeCloseTo(0.001, 6);
});

it("builds funnel percentages from task states", () => {
const funnel = buildFunnel([
{ status: "todo" },
{ status: "todo" },
{ status: "in_progress" },
{ status: "done" },
]);

expect(funnel.find((item) => item.status === "todo")).toMatchObject({ count: 2, percent: 100 });
expect(funnel.find((item) => item.status === "in_progress")).toMatchObject({ count: 1, percent: 50 });
expect(funnel.find((item) => item.status === "done")).toMatchObject({ count: 1, percent: 50 });
});

it("marks the current hour in the 429 heatmap", () => {
const now = new Date("2026-03-25T14:30:00");
const hours = buildRateLimitHours([
{ timestamp: "2026-03-25T14:05:00", count: 2 },
{ timestamp: "2026-03-25T14:35:00", count: 1 },
], now);

const current = hours[14];
expect(current.currentHour).toBe(true);
expect(current.count).toBe(3);
expect(current.label.trim()).toBe("3");
});

it("derives telemetry snapshot from ws state data", () => {
const snapshot = deriveTelemetrySnapshot({
stats: { throughputTps: 7.5, tokensTotal: 1200, costPer1kTokensUsd: { claude: 0.003 } },
sessions: [{ provider: "claude", inputTokens: 600, outputTokens: 600, errorCount: 1 }],
tasks: [{ status: "todo" }, { status: "done" }],
logs: [
{ level: "error", line: "provider failed" },
{ level: "warn", line: "retry after backoff" },
{ level: "warn", line: "429 rate limit" },
],
now: Date.parse("2026-03-25T14:30:00Z"),
});

expect(snapshot.throughput).toBe(7.5);
expect(snapshot.tokenTotal).toBe(1200);
expect(snapshot.errors).toBe(1);
expect(snapshot.retries).toBe(1);
expect(snapshot.rateLimitEvents).toHaveLength(1);
expect(snapshot.sessionCostUsd).toBeCloseTo(0.0036, 6);
});
});

8 changes: 4 additions & 4 deletions tui/app.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { getNextScreenForInput } from "./lib/navigation.mjs";
import StatusHeader from "./components/status-header.mjs";
import TasksScreen from "./screens/tasks.mjs";
import AgentsScreen from "./screens/agents.mjs";
import StatusScreen from "./screens/status.mjs";
import StatusScreen from "./screens/status.mjs";`r`nimport TelemetryScreen from "./screens/telemetry.mjs";
import { readTuiHeaderConfig } from "./lib/header-config.mjs";
Comment on lines 9 to 11
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This import line includes stray `r`n characters, which makes the file fail to parse. Remove the stray characters and keep the TelemetryScreen import as a normal separate import statement.

Copilot uses AI. Check for mistakes.
import { listTasksFromApi } from "../ui/tui/tasks-screen-helpers.js";

Expand All @@ -16,14 +16,14 @@ const html = htm.bind(React.createElement);
const SCREENS = {
status: StatusScreen,
tasks: TasksScreen,
agents: AgentsScreen,
agents: AgentsScreen,`r`n telemetry: TelemetryScreen,
};
Comment on lines 16 to 20
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SCREENS object literal contains stray `r`n characters before the telemetry entry, which makes the object literal invalid JS. Remove the stray characters and add the telemetry screen as a normal key in the object.

Copilot uses AI. Check for mistakes.

function ScreenTabs({ screen }) {
const navItems = [
{ key: "status", num: "1", label: "Status" },
{ key: "tasks", num: "2", label: "Tasks" },
{ key: "agents", num: "3", label: "Agents" },
{ key: "agents", num: "3", label: "Agents" },`r`n { key: "telemetry", num: "4", label: "Telemetry" },
];
Comment on lines 23 to 27
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The navItems array has stray `r`n characters between entries, which will cause a syntax error at runtime. Remove the stray characters and add the telemetry tab item as its own element in the array.

Copilot uses AI. Check for mistakes.

return html`
Expand Down Expand Up @@ -237,4 +237,4 @@ export default function App({ host, port, connectOnly, initialScreen, refreshMs,
<//>
<//>
`;
}
}
6 changes: 3 additions & 3 deletions tui/lib/navigation.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
const SCREEN_ORDER = ["status", "tasks", "agents"];
const SCREEN_ORDER = ["status", "tasks", "agents", "telemetry"];
const SCREEN_BY_INPUT = new Map([
["1", "status"],
["2", "tasks"],
["3", "agents"],
["3", "agents"],`r`n ["4", "telemetry"],
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The array literal contains stray characters `r`n (looks like an accidental CRLF escape) inside the Map initializer, which makes this module invalid JS and will break navigation + tests. Remove the stray characters and put the ["4", "telemetry"] entry on its own line like the other entries.

Suggested change
["3", "agents"],`r`n ["4", "telemetry"],
["3", "agents"],
["4", "telemetry"],

Copilot uses AI. Check for mistakes.
]);

export function getNextScreenForInput(currentScreen = "status", input = "") {
Expand All @@ -12,4 +12,4 @@ export function getNextScreenForInput(currentScreen = "status", input = "") {
return "status";
}

export { SCREEN_ORDER };
export { SCREEN_ORDER };
Loading
Loading