fix(usage): warn on broken footer templates

This commit is contained in:
Ayaan Zaidi
2026-06-13 18:54:24 +05:30
parent 84cbaf1832
commit afe75b3387
6 changed files with 212 additions and 20 deletions

View File

@@ -48,8 +48,87 @@ footer when valid:
}
```
Missing, unreadable, invalid, or empty templates fall back to the built-in
footer.
Missing or empty templates fall back to the built-in footer quietly. Unreadable
or invalid configured templates also fall back to the built-in footer and emit an
operator warning.
Start custom templates from the built-in shape, then edit the parts you want to
change:
```jsonc
{
"schema": "openclaw.usageBar.v1",
"scales": {
"braille": "⠐⡀⡄⡆⡇⣇⣧⣷⣿",
"block": "░▏▎▍▌▋▊▉█",
"shade": "░▒▓█",
"moon": "🌑🌘🌗🌖🌕",
"level": "▁▂▃▄▅▆▇█",
"weather": ["🥶", "☁️", "🌥", "⛅️", "🌤", "☀️"],
"plants": ["🪾", "🍂", "🌱", "☘️", "🍀", "🌿"],
"moons6": ["🌑", "🌚", "🌘", "🌗", "🌖", "🌝"],
},
"aliases": {
"models": {
"claude-opus-4-6": "opus46",
"claude-opus-4-8": "opus48",
"claude-sonnet-4-6": "sonnet46",
"claude-haiku-4-5": "haiku45",
"gpt-5.5": "gpt5.5",
},
"reasoning": {
"off": "🌑",
"minimal": "🌚",
"low": "🌘",
"medium": "🌗",
"high": "🌕",
"xhigh": "🌝",
},
},
"output": {
"sep": "",
"default": [
{ "text": "{model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
{ "map": "model.is_fallback", "cases": { "true": " 🔄" } },
{ "map": "model.is_override", "cases": { "true": " 📌" } },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡", "false": " 🐌" } },
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
"when": "usage.has_split_tokens",
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
],
"surfaces": {
"discord": [
{ "text": "-# -\n" },
{ "text": "-# {model.provider}{identity.emoji|🤖} {model.display_name|alias:models}" },
{ "map": "model.is_fallback", "cases": { "true": "🔄" } },
{ "map": "model.is_override", "cases": { "true": "📌" } },
{ "when": "model.reasoning", "text": " {model.reasoning|alias:reasoning}" },
{ "map": "state.fast_mode", "cases": { "true": " ⚡️", "false": " 🐌" } },
{
"when": "context.max_tokens",
"text": " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
"when": "usage.has_split_tokens",
"text": " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ "when": "usage.has_total_only_tokens", "text": " ↕️ {usage.total_tokens|num}" },
{ "when": "usage.cache_hit_pct", "text": " 🗄 {usage.cache_hit_pct|pct}" },
{ "when": "cost.turn_usd", "text": " 💰{cost.turn_usd|fixed:4}" },
],
},
},
}
```
### Shape
@@ -92,7 +171,8 @@ empty (so a `when` guard or a `|fallback` keeps the piece clean).
| `model.is_fallback` / `model.is_override` | bool: fallback used / model pinned |
| `state.fast_mode` | bool: fast vs slow |
| `context.max_tokens` / `context.pct_used` | window budget / 0-100 used |
| `usage.input_tokens` / `usage.output_tokens` / `usage.cache_hit_pct` | turn aggregate |
| `usage.input_tokens` / `usage.output_tokens` / `usage.total_tokens` | turn aggregate |
| `usage.has_split_tokens` / `usage.has_total_only_tokens` / `usage.cache_hit_pct` | token display guards and cache percent |
| `usage.last.input_tokens` / `usage.last.output_tokens` / `usage.last.cache_hit_pct` | final model call only |
| `cost.turn_usd` | estimated turn cost |
| `identity.name` / `identity.emoji` | agent name / chosen emoji |

View File

@@ -2729,6 +2729,29 @@ describe("runReplyAgent response usage footer", () => {
expect(text).not.toContain("Usage:");
});
it("shows aggregate-only token totals in the built-in full footer", async () => {
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
agentMeta: {
provider: "anthropic",
model: "claude",
usage: { total: 1250 },
},
},
});
const res = await createRun({
responseUsage: "full",
sessionKey: "agent:main:whatsapp:dm:+1000",
});
const payload = Array.isArray(res) ? res[0] : res;
const text = payload?.text ?? "";
expect(text).toContain("↕️ 1.3k");
expect(text).not.toContain("↕️ ?/?");
expect(text).not.toContain("Usage:");
});
it("shows configured costs for aws-sdk providers when responseUsage=full", async () => {
runEmbeddedAgentMock.mockResolvedValueOnce({
payloads: [{ text: "ok" }],

View File

@@ -11,12 +11,10 @@ export function buildUsageContract(
const cacheRead = usage.cacheRead;
const cacheWrite = usage.cacheWrite;
const total = usage.total;
const hasSplitTokens = input !== undefined || output !== undefined;
const hasTotalOnlyTokens = !hasSplitTokens && total !== undefined;
const hasTokens =
input !== undefined ||
output !== undefined ||
cacheRead !== undefined ||
cacheWrite !== undefined ||
total !== undefined;
hasSplitTokens || cacheRead !== undefined || cacheWrite !== undefined || total !== undefined;
const promptTotal = (cacheRead ?? 0) + (cacheWrite ?? 0) + (input ?? 0);
const cacheHitPct =
@@ -77,6 +75,8 @@ export function buildUsageContract(
total_tokens: total,
cache_hit_pct: cacheHitPct,
has_tokens: hasTokens,
has_split_tokens: hasSplitTokens,
has_total_only_tokens: hasTotalOnlyTokens,
last: last
? {
input_tokens: last.input,

View File

@@ -35,9 +35,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
when: "usage.has_tokens",
when: "usage.has_split_tokens",
text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" },
{ when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" },
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
],
@@ -54,9 +55,10 @@ export const DEFAULT_USAGE_BAR_TEMPLATE: UsageBarTemplate = {
text: " | 📚 [{context.pct_used|meter:5:braille}]{context.max_tokens|num}",
},
{
when: "usage.has_tokens",
when: "usage.has_split_tokens",
text: " ↕️ {usage.input_tokens|num|?}/{usage.output_tokens|num|?}",
},
{ when: "usage.has_total_only_tokens", text: " ↕️ {usage.total_tokens|num}" },
{ when: "usage.cache_hit_pct", text: " 🗄 {usage.cache_hit_pct|pct}" },
{ when: "cost.turn_usd", text: " 💰{cost.turn_usd|fixed:4}" },
],

View File

@@ -1,10 +1,16 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
import { clearUsageBarTemplateCacheForTest, loadUsageBarTemplate } from "./template.js";
const warnSpy = vi.hoisted(() => vi.fn());
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: () => ({ warn: warnSpy }),
}));
const tplA = { segments: [{ text: "A" }] };
const tplB = { output: { default: [{ text: "B" }] } };
@@ -12,6 +18,7 @@ let dir: string | undefined;
afterEach(() => {
clearUsageBarTemplateCacheForTest();
warnSpy.mockClear();
if (dir) {
rmSync(dir, { recursive: true, force: true });
dir = undefined;
@@ -36,8 +43,17 @@ describe("loadUsageBarTemplate", () => {
it("falls back to the built-in template for an unusable inline object", () => {
expect(loadUsageBarTemplate({ nope: true })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]).toMatchObject([
"configured usage template could not be used; using built-in footer",
{ source: "inline", reason: "unsupported-shape" },
]);
});
it("falls back quietly for an empty inline template", () => {
expect(loadUsageBarTemplate({ output: {} })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(loadUsageBarTemplate({ output: { default: [] } })).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
});
it("loads and parses a template file", () => {
@@ -48,17 +64,24 @@ describe("loadUsageBarTemplate", () => {
it("falls back to the built-in template for invalid JSON", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy.mock.calls[0]).toMatchObject([
"configured usage template could not be used; using built-in footer",
{ source: "file", reason: "invalid-json", path },
]);
});
it("falls back to the built-in template for an empty template file", () => {
const path = tmpFile("empty.json", JSON.stringify({ output: { default: [] } }));
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
});
it("reloads a path after an initial miss", () => {
dir = mkdtempSync(join(tmpdir(), "usage-template-"));
const missing = join(dir, "missing.json");
expect(loadUsageBarTemplate(missing)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).not.toHaveBeenCalled();
writeFileSync(missing, JSON.stringify(tplB));
expect(loadUsageBarTemplate(missing)).toMatchObject(tplB);
});
@@ -66,6 +89,7 @@ describe("loadUsageBarTemplate", () => {
it("reloads a path after invalid JSON is fixed", () => {
const path = tmpFile("bad.json", "{ not json");
expect(loadUsageBarTemplate(path)).toBe(DEFAULT_USAGE_BAR_TEMPLATE);
expect(warnSpy).toHaveBeenCalledTimes(1);
writeFileSync(path, JSON.stringify(tplB));
expect(loadUsageBarTemplate(path)).toMatchObject(tplB);
});

View File

@@ -1,6 +1,7 @@
import { type FSWatcher, readFileSync, watch } from "node:fs";
import { homedir } from "node:os";
import { isAbsolute, resolve } from "node:path";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { DEFAULT_USAGE_BAR_TEMPLATE } from "./default-template.js";
import type { UsageBarTemplate } from "./translator.js";
@@ -8,6 +9,8 @@ export type UsageTemplateConfig = string | Record<string, unknown> | undefined;
type CacheEntry = { template: UsageBarTemplate | undefined; watcher?: FSWatcher };
const fileCache = new Map<string, CacheEntry>();
const warnedTemplateOverrides = new Set<string>();
const usageTemplateLog = createSubsystemLogger("usage-template");
function expandPath(p: string): string {
if (p === "~") {
@@ -41,6 +44,20 @@ function hasOutputPieces(output: unknown): boolean {
);
}
function isEmptyTemplate(value: unknown): boolean {
if (!isPlainObject(value)) {
return false;
}
if (Object.keys(value).length === 0) {
return true;
}
if ("segments" in value && Array.isArray(value.segments)) {
return value.segments.length === 0;
}
const output = value.output;
return isPlainObject(output) && !hasOutputPieces(output);
}
function isUsableTemplate(value: unknown): value is UsageBarTemplate {
if (!isPlainObject(value)) {
return false;
@@ -55,27 +72,68 @@ function isUsableTemplate(value: unknown): value is UsageBarTemplate {
);
}
function readTemplateFile(path: string): UsageBarTemplate | undefined {
type InvalidTemplateReason = "invalid-json" | "unreadable" | "unsupported-shape";
type TemplateReadResult = { template?: UsageBarTemplate; reason?: InvalidTemplateReason };
function getErrorCode(error: unknown): string | undefined {
if (typeof error !== "object" || error === null || !("code" in error)) {
return undefined;
}
const code = error.code;
return typeof code === "string" ? code : undefined;
}
function warnInvalidUsageTemplate(source: "inline" | "file", reason: string, path?: string): void {
const key = `${source}:${reason}:${path ?? ""}`;
if (warnedTemplateOverrides.has(key)) {
return;
}
warnedTemplateOverrides.add(key);
usageTemplateLog.warn("configured usage template could not be used; using built-in footer", {
source,
reason,
...(path ? { path } : {}),
});
}
function parseTemplate(value: unknown): TemplateReadResult {
if (isUsableTemplate(value)) {
return { template: value };
}
return isEmptyTemplate(value) ? {} : { reason: "unsupported-shape" };
}
function readTemplateFile(path: string): TemplateReadResult {
let raw: string;
try {
raw = readFileSync(path, "utf8");
} catch {
return undefined;
} catch (error) {
return getErrorCode(error) === "ENOENT" ? {} : { reason: "unreadable" };
}
if (raw.trim().length === 0) {
return {};
}
try {
const parsed: unknown = JSON.parse(raw);
return isUsableTemplate(parsed) ? parsed : undefined;
return parseTemplate(JSON.parse(raw));
} catch {
return undefined;
return { reason: "invalid-json" };
}
}
function cacheTemplateFile(path: string): UsageBarTemplate | undefined {
const entry: CacheEntry = { template: readTemplateFile(path) };
const result = readTemplateFile(path);
if (result.reason) {
warnInvalidUsageTemplate("file", result.reason, path);
}
const entry: CacheEntry = { template: result.template };
if (entry.template) {
try {
const watcher = watch(path, { persistent: false }, () => {
entry.template = readTemplateFile(path);
const next = readTemplateFile(path);
if (next.reason) {
warnInvalidUsageTemplate("file", next.reason, path);
}
entry.template = next.template;
});
watcher.on("error", () => {
watcher.close();
@@ -94,7 +152,11 @@ export function loadUsageBarTemplate(configured: UsageTemplateConfig): UsageBarT
return DEFAULT_USAGE_BAR_TEMPLATE;
}
if (typeof configured === "object") {
return isUsableTemplate(configured) ? configured : DEFAULT_USAGE_BAR_TEMPLATE;
const result = parseTemplate(configured);
if (result.reason) {
warnInvalidUsageTemplate("inline", result.reason);
}
return result.template ?? DEFAULT_USAGE_BAR_TEMPLATE;
}
const path = expandPath(configured);
const cached = fileCache.get(path);
@@ -110,4 +172,5 @@ export function clearUsageBarTemplateCacheForTest(): void {
entry.watcher?.close();
}
fileCache.clear();
warnedTemplateOverrides.clear();
}