From ebb08dc70e1fa0690e9f879699454500d1969762 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 25 Apr 2026 00:54:33 -0700 Subject: [PATCH] fix(ui): use current context usage in Control UI --- CHANGELOG.md | 1 + ui/src/i18n/.i18n/de.meta.json | 6 +- ui/src/i18n/.i18n/es.meta.json | 6 +- ui/src/i18n/.i18n/fr.meta.json | 6 +- ui/src/i18n/.i18n/id.meta.json | 6 +- ui/src/i18n/.i18n/ja-JP.meta.json | 6 +- ui/src/i18n/.i18n/ko.meta.json | 6 +- ui/src/i18n/.i18n/pl.meta.json | 6 +- ui/src/i18n/.i18n/pt-BR.meta.json | 6 +- ui/src/i18n/.i18n/th.meta.json | 6 +- ui/src/i18n/.i18n/tr.meta.json | 6 +- ui/src/i18n/.i18n/uk.meta.json | 6 +- ui/src/i18n/.i18n/zh-CN.meta.json | 6 +- ui/src/i18n/.i18n/zh-TW.meta.json | 6 +- ui/src/i18n/locales/en.ts | 3 +- .../chat/slash-command-executor.node.test.ts | 65 ++++++++++++++++- ui/src/ui/chat/slash-command-executor.ts | 23 +++++-- ui/src/ui/views/usage-render-overview.test.ts | 69 +++++++++++++++++++ ui/src/ui/views/usage-render-overview.ts | 2 +- 19 files changed, 194 insertions(+), 47 deletions(-) create mode 100644 ui/src/ui/views/usage-render-overview.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a86abcc99b..bc36ba962f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai ### Fixes - OpenAI/Codex image generation: canonicalize legacy `openai-codex.baseUrl` values such as `https://chatgpt.com/backend-api` to the Codex Responses backend before calling `gpt-image-2`, matching the chat transport. Fixes #71460. +- Control UI: make `/usage` use the fresh context snapshot for context percentage, and include cache-write tokens in the Usage overview cache-hit denominator. Fixes #47885. Thanks @imwyvern and @Ante042. - Telegram/webhook: acknowledge validated webhook updates before running bot middleware, keeping slow agent turns from tripping Telegram delivery retries while preserving per-chat processing lanes. Fixes #71392. - MCP: retire one-shot embedded bundled MCP runtimes at run end, skip bundle-MCP startup when a runtime tool allowlist cannot reach bundle-MCP tools, and add `mcp.sessionIdleTtlMs` idle eviction for leaked session runtimes. Fixes #71106, #71110, #70389, and #70808. - MCP/config reload: hot-apply `mcp.*` changes by disposing cached session MCP runtimes, and dispose bundled MCP runtimes during gateway shutdown so removed `mcp.servers` entries reap child processes promptly. Fixes #60656. diff --git a/ui/src/i18n/.i18n/de.meta.json b/ui/src/i18n/.i18n/de.meta.json index 7eee7c91d0f..d77e9886ad3 100644 --- a/ui/src/i18n/.i18n/de.meta.json +++ b/ui/src/i18n/.i18n/de.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:26:59.540Z", + "generatedAt": "2026-04-25T07:32:16.675Z", "locale": "de", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/es.meta.json b/ui/src/i18n/.i18n/es.meta.json index 35418716251..fe1362a3ca0 100644 --- a/ui/src/i18n/.i18n/es.meta.json +++ b/ui/src/i18n/.i18n/es.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:27:55.136Z", + "generatedAt": "2026-04-25T07:32:18.681Z", "locale": "es", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/fr.meta.json b/ui/src/i18n/.i18n/fr.meta.json index 8c2d80ba9e2..950e0cf1e6c 100644 --- a/ui/src/i18n/.i18n/fr.meta.json +++ b/ui/src/i18n/.i18n/fr.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:29:33.727Z", + "generatedAt": "2026-04-25T07:32:27.034Z", "locale": "fr", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/id.meta.json b/ui/src/i18n/.i18n/id.meta.json index d88233d92ab..5fc100cec5d 100644 --- a/ui/src/i18n/.i18n/id.meta.json +++ b/ui/src/i18n/.i18n/id.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:30:08.920Z", + "generatedAt": "2026-04-25T07:32:34.367Z", "locale": "id", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/ja-JP.meta.json b/ui/src/i18n/.i18n/ja-JP.meta.json index 20ab449c664..fbe60d2f7f2 100644 --- a/ui/src/i18n/.i18n/ja-JP.meta.json +++ b/ui/src/i18n/.i18n/ja-JP.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:29:06.508Z", + "generatedAt": "2026-04-25T07:32:20.713Z", "locale": "ja-JP", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/ko.meta.json b/ui/src/i18n/.i18n/ko.meta.json index ce4fccf9877..fb60ff5ae7b 100644 --- a/ui/src/i18n/.i18n/ko.meta.json +++ b/ui/src/i18n/.i18n/ko.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:29:26.571Z", + "generatedAt": "2026-04-25T07:32:24.032Z", "locale": "ko", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/pl.meta.json b/ui/src/i18n/.i18n/pl.meta.json index c3d7f5b98ec..7d02ef8fccc 100644 --- a/ui/src/i18n/.i18n/pl.meta.json +++ b/ui/src/i18n/.i18n/pl.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:30:13.404Z", + "generatedAt": "2026-04-25T07:32:38.432Z", "locale": "pl", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/pt-BR.meta.json b/ui/src/i18n/.i18n/pt-BR.meta.json index e0790bd7dc7..ffecea7c5c3 100644 --- a/ui/src/i18n/.i18n/pt-BR.meta.json +++ b/ui/src/i18n/.i18n/pt-BR.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:27:01.151Z", + "generatedAt": "2026-04-25T07:32:14.907Z", "locale": "pt-BR", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/th.meta.json b/ui/src/i18n/.i18n/th.meta.json index a296b80ac03..25de8c6024f 100644 --- a/ui/src/i18n/.i18n/th.meta.json +++ b/ui/src/i18n/.i18n/th.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:30:41.102Z", + "generatedAt": "2026-04-25T07:32:40.554Z", "locale": "th", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/tr.meta.json b/ui/src/i18n/.i18n/tr.meta.json index 096d012a736..3c1b49654a5 100644 --- a/ui/src/i18n/.i18n/tr.meta.json +++ b/ui/src/i18n/.i18n/tr.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:29:55.552Z", + "generatedAt": "2026-04-25T07:32:30.128Z", "locale": "tr", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/uk.meta.json b/ui/src/i18n/.i18n/uk.meta.json index 0e7e918b07a..d9178755ddb 100644 --- a/ui/src/i18n/.i18n/uk.meta.json +++ b/ui/src/i18n/.i18n/uk.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:30:10.003Z", + "generatedAt": "2026-04-25T07:32:32.725Z", "locale": "uk", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/zh-CN.meta.json b/ui/src/i18n/.i18n/zh-CN.meta.json index 7f64387ed61..c32bd9c6977 100644 --- a/ui/src/i18n/.i18n/zh-CN.meta.json +++ b/ui/src/i18n/.i18n/zh-CN.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:26:33.969Z", + "generatedAt": "2026-04-25T07:32:11.950Z", "locale": "zh-CN", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/.i18n/zh-TW.meta.json b/ui/src/i18n/.i18n/zh-TW.meta.json index 45be92f8ce0..c90ed753dc4 100644 --- a/ui/src/i18n/.i18n/zh-TW.meta.json +++ b/ui/src/i18n/.i18n/zh-TW.meta.json @@ -1,10 +1,10 @@ { "fallbackKeys": [], - "generatedAt": "2026-04-24T20:27:07.043Z", + "generatedAt": "2026-04-25T07:32:13.730Z", "locale": "zh-TW", - "model": "gpt-5.5", + "model": "gpt-5.4", "provider": "openai", - "sourceHash": "33cba33627744c6bb03182f53b02a9a2640272ef0fa3d039ce52723a96f5099e", + "sourceHash": "2af900ae253948aab69216e38e0fce2dfde89801d178dee0ebb8dd28df2e11ef", "totalKeys": 734, "translatedKeys": 734, "workflow": 1 diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 042f020bc22..3151ab23a3b 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -535,7 +535,8 @@ export const en: TranslationMap = { errorHint: "Error rate = errors / total messages. Lower is better.", avgSession: "avg session", cacheHitRate: "Cache Hit Rate", - cacheHint: "Cache hit rate = cache read / (input + cache read). Higher is better.", + cacheHint: + "Cache hit rate = cache read / (input + cache read + cache write). Higher is better.", cached: "cached", prompt: "prompt", calls: "calls", diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index 74b1962eeab..e5fbb055d8c 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -488,7 +488,70 @@ describe("executeSlashCommand directives", () => { ); expect(result.content).toBe( - "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **30%** of 4k\nModel: `gpt-4.1-mini`", + "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **38%** of 4k\nModel: `gpt-4.1-mini`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("keeps /usage context hidden when the context snapshot is stale", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1500, + totalTokensFresh: false, + contextTokens: 4000, + }), + ], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "usage", + "", + ); + + expect(result.content).toBe( + "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **~1.5k** tokens\nModel: `gpt-4.1-mini`", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); + + it("uses the context snapshot for /usage while preserving cumulative total display", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + model: "gpt-4.1-mini", + inputTokens: 1200, + outputTokens: 300, + totalTokens: 1250, + contextTokens: 4000, + }), + ], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "usage", + "", + ); + + expect(result.content).toBe( + "**Session Usage**\nInput: **1.2k** tokens\nOutput: **300** tokens\nTotal: **1.5k** tokens\nContext: **31%** of 4k\nModel: `gpt-4.1-mini`", ); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); }); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index e99f50501a4..dcb14ef8f83 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -377,17 +377,30 @@ async function executeUsage( if (!session) { return { content: "No active session." }; } - const input = session.inputTokens ?? 0; - const output = session.outputTokens ?? 0; - const total = session.totalTokens ?? input + output; + const hasInputTokens = Number.isFinite(session.inputTokens); + const hasOutputTokens = Number.isFinite(session.outputTokens); + const input = hasInputTokens ? (session.inputTokens ?? 0) : 0; + const output = hasOutputTokens ? (session.outputTokens ?? 0) : 0; + const cumulativeTotal = hasInputTokens || hasOutputTokens ? input + output : null; + const contextSnapshotTotal = Number.isFinite(session.totalTokens) + ? (session.totalTokens ?? null) + : cumulativeTotal; + const totalTokensFresh = session.totalTokensFresh !== false; const ctx = session.contextTokens ?? 0; - const pct = ctx > 0 ? Math.round((input / ctx) * 100) : null; + const pct = + contextSnapshotTotal !== null && totalTokensFresh && ctx > 0 + ? Math.round((contextSnapshotTotal / ctx) * 100) + : null; + const totalDisplay = + cumulativeTotal === null + ? "n/a" + : `${totalTokensFresh ? "" : "~"}${fmtTokens(cumulativeTotal)}`; const lines = [ "**Session Usage**", `Input: **${fmtTokens(input)}** tokens`, `Output: **${fmtTokens(output)}** tokens`, - `Total: **${fmtTokens(total)}** tokens`, + `Total: **${totalDisplay}** tokens`, ]; if (pct !== null) { lines.push(`Context: **${pct}%** of ${fmtTokens(ctx)}`); diff --git a/ui/src/ui/views/usage-render-overview.test.ts b/ui/src/ui/views/usage-render-overview.test.ts new file mode 100644 index 00000000000..a1f8d17375b --- /dev/null +++ b/ui/src/ui/views/usage-render-overview.test.ts @@ -0,0 +1,69 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { describe, expect, it } from "vitest"; +import { renderUsageInsights } from "./usage-render-overview.ts"; +import type { UsageAggregates, UsageTotals } from "./usageTypes.ts"; + +const totals: UsageTotals = { + input: 100, + output: 40, + cacheRead: 300, + cacheWrite: 600, + totalTokens: 1040, + totalCost: 0, + inputCost: 0, + outputCost: 0, + cacheReadCost: 0, + cacheWriteCost: 0, + missingCostEntries: 0, +}; + +const aggregates = { + messages: { + total: 4, + user: 2, + assistant: 2, + toolCalls: 0, + toolResults: 0, + errors: 0, + }, + tools: { + totalCalls: 0, + uniqueTools: 0, + tools: [], + }, + byModel: [], + byProvider: [], + byAgent: [], + byChannel: [], + daily: [], +} as unknown as UsageAggregates; + +describe("renderUsageInsights", () => { + it("includes cache writes in cache-hit-rate denominator", () => { + const container = document.createElement("div"); + + render( + renderUsageInsights( + totals, + aggregates, + { + durationSumMs: 0, + durationCount: 0, + avgDurationMs: 0, + errorRate: 0, + }, + false, + [], + 1, + 1, + ), + container, + ); + + expect(container.textContent).toContain("30.0%"); + expect(container.textContent).toContain("300 cached"); + expect(container.textContent).toContain("1.0K prompt"); + }); +}); diff --git a/ui/src/ui/views/usage-render-overview.ts b/ui/src/ui/views/usage-render-overview.ts index 42e0ac7331d..f27a20c0c37 100644 --- a/ui/src/ui/views/usage-render-overview.ts +++ b/ui/src/ui/views/usage-render-overview.ts @@ -497,7 +497,7 @@ function renderUsageInsights( ? Math.round(totals.totalTokens / aggregates.messages.total) : 0; const avgCost = aggregates.messages.total ? totals.totalCost / aggregates.messages.total : 0; - const cacheBase = totals.input + totals.cacheRead; + const cacheBase = totals.input + totals.cacheRead + totals.cacheWrite; const cacheHitRate = cacheBase > 0 ? totals.cacheRead / cacheBase : 0; const cacheHitLabel = cacheBase > 0 ? `${(cacheHitRate * 100).toFixed(1)}%` : t("usage.common.emptyValue");