Compare commits

..

2 Commits

Author SHA1 Message Date
Narahari Raghava
1eb412608c fix(ui): correct rounding boundary comment from 999,500 to 999,950 2026-06-22 14:38:52 +08:00
Narahari Raghava
a6e5faed97 fix(ui): roll values near 1M over from k to M in compact token format 2026-06-22 14:38:52 +08:00
11 changed files with 166 additions and 17 deletions

View File

@@ -163,8 +163,8 @@ let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10328),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5185),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10327),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5184),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3247,

View File

@@ -10,6 +10,13 @@ const UPDATE_COMMAND_RE =
/^(?:pnpm|npm|bunx|npx)\s+openclaw\b.*(?:^|\s)update(?:\s|$)|^openclaw\b.*(?:^|\s)update(?:\s|$)/;
const CONTAINER_HINT_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/;
export function quoteCliArg(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}
/** Add active root options to a displayed command without duplicating explicit flags. */
export function formatCliCommand(
command: string,

View File

@@ -38,10 +38,9 @@ import {
type DevicePairingAccessSummary,
type PendingDeviceApprovalKind,
} from "../shared/device-pairing-access.js";
import { formatCliCommand } from "./command-format.js";
import { formatCliCommand, quoteCliArg } from "./command-format.js";
import { parseTimeoutMsWithFallback } from "./parse-timeout.js";
import { withProgress } from "./progress.js";
import { quoteCliArg } from "./quote-cli-arg.js";
type DevicesRpcOpts = {
url?: string;

View File

@@ -11,9 +11,8 @@ import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { defaultRuntime } from "../../runtime.js";
import { shortenHomeInString } from "../../utils.js";
import { formatCliCommand } from "../command-format.js";
import { formatCliCommand, quoteCliArg } from "../command-format.js";
import { parseDurationMs } from "../parse-duration.js";
import { quoteCliArg } from "../quote-cli-arg.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { formatPermissions, parseNodeList, parsePairingList } from "./format.js";
import { renderPendingPairingRequestsTable } from "./pairing-render.js";

View File

@@ -1,6 +0,0 @@
export function quoteCliArg(value: string): string {
if (/^[A-Za-z0-9_/:=.,@%+-]+$/.test(value)) {
return value;
}
return `'${value.replaceAll("'", "'\\''")}'`;
}

View File

@@ -3,8 +3,7 @@ import path from "node:path";
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
import { note } from "../../packages/terminal-core/src/note.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { formatCliCommand } from "../cli/command-format.js";
import { quoteCliArg } from "../cli/quote-cli-arg.js";
import { formatCliCommand, quoteCliArg } from "../cli/command-format.js";
import { resolveStateDir } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";

View File

@@ -206,6 +206,13 @@ export function pruneStaleEntries(
export const DEFAULT_QUOTA_SUSPENSION_TTL_MS = 30 * 60 * 1000; // 30 minutes
const QUOTA_SUSPENSION_CLEANUP_FACTOR = 2; // entries beyond N*ttl are deleted outright
export interface QuotaSuspensionMaintenanceResult {
/** Suspensions whose state was advanced from "suspended" to "resuming" so the next attempt injects a handoff. */
resumed: Array<{ sessionKey: string; laneId?: string }>;
/** Entries whose `quotaSuspension` field was removed entirely (already-resumed records past 2x TTL). */
cleared: number;
}
export type QuotaSuspensionEntryMaintenanceResult = {
/** Patch to apply to the entry, or null when no TTL transition is due. */
patch: Partial<SessionEntry> | null;
@@ -246,6 +253,54 @@ export function resolveQuotaSuspensionEntryMaintenance(params: {
return { patch: null, cleared: false };
}
/**
* Two-stage TTL maintenance for `quotaSuspension` records:
* 1. After `ttlMs`, transition `state: "suspended" → "resuming"` so the next
* attempt for that session sees the resume marker and injects a handoff.
* 2. After `2 * ttlMs`, drop the field entirely (the record has done its job).
*
* Mutates `store` in-place. The caller is responsible for translating the
* returned `resumed[]` into in-process lane-concurrency restoration calls,
* which keeps this module free of `process/*` dependencies.
*/
export function pruneQuotaSuspensions(params: {
store: Record<string, SessionEntry>;
now: number;
ttlMs?: number;
log?: boolean;
}): QuotaSuspensionMaintenanceResult {
const ttlMs = params.ttlMs ?? DEFAULT_QUOTA_SUSPENSION_TTL_MS;
const resumed: Array<{ sessionKey: string; laneId?: string }> = [];
let cleared = 0;
for (const [sessionKey, entry] of Object.entries(params.store)) {
const result = resolveQuotaSuspensionEntryMaintenance({
entry,
now: params.now,
ttlMs,
});
if (!result.patch) {
continue;
}
if (result.cleared) {
delete entry.quotaSuspension;
cleared++;
} else if (result.patch.quotaSuspension) {
entry.quotaSuspension = result.patch.quotaSuspension;
}
if (result.resumed) {
resumed.push({ sessionKey, laneId: result.resumed.laneId });
}
}
if ((resumed.length > 0 || cleared > 0) && params.log !== false) {
log.info("processed quota-suspension TTLs", {
resumed: resumed.length,
cleared,
ttlMs,
});
}
return { resumed, cleared };
}
function getEntryUpdatedAt(entry?: SessionEntry): number {
return entry?.updatedAt ?? Number.NEGATIVE_INFINITY;
}

View File

@@ -10,6 +10,7 @@ import {
import {
isProtectedSessionMaintenanceEntry,
resolveMaintenanceConfigFromInput,
pruneQuotaSuspensions,
resolveQuotaSuspensionEntryMaintenance,
resolveSessionEntryMaintenanceHighWater,
} from "./store-maintenance.js";
@@ -145,6 +146,57 @@ describe("resolveQuotaSuspensionEntryMaintenance", () => {
});
});
describe("pruneQuotaSuspensions", () => {
it("returns whole-store resume and clear results from the storage-neutral operation", () => {
const now = Date.now();
const store = makeStore([
[
"suspended",
{
...makeEntry(now),
quotaSuspension: {
schemaVersion: 1,
suspendedAt: now - 30_000,
expectedResumeBy: now - 1,
state: "suspended",
reason: "quota_exhausted",
failedProvider: "anthropic",
failedModel: "claude-opus-4-6",
laneId: "main",
},
},
],
[
"expired",
{
...makeEntry(now),
quotaSuspension: {
schemaVersion: 1,
suspendedAt: now - 61_000,
expectedResumeBy: now - 31_000,
state: "active",
reason: "circuit_open",
failedProvider: "anthropic",
failedModel: "claude-opus-4-6",
laneId: "fallback",
},
},
],
]);
const result = pruneQuotaSuspensions({
store,
now,
ttlMs: 30_000,
log: false,
});
expect(result).toEqual({ resumed: [{ sessionKey: "suspended", laneId: "main" }], cleared: 1 });
expect(store.suspended.quotaSuspension?.state).toBe("resuming");
expect(store.expired.quotaSuspension).toBeUndefined();
});
});
describe("applyFileBackedSessionStoreMaintenance", () => {
it("preserves the active session and cleans artifacts using the final referenced session set", async () => {
const now = Date.now();

View File

@@ -51,11 +51,11 @@ describe("plugin SDK surface report", () => {
it("keeps generated package declarations out of source surface counts", () => {
const result = runSurfaceReport({
OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: "5184",
OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS: "5183",
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("public callable exports 5185 > 5184");
expect(result.stderr).toContain("public callable exports 5184 > 5183");
});
it("rejects deprecated export growth by public entrypoint", () => {

View File

@@ -0,0 +1,36 @@
// Control UI tests for the compact token count formatter shared across chat surfaces.
import { describe, expect, it } from "vitest";
import { formatCompactTokenCount } from "./token-format.ts";
describe("formatCompactTokenCount", () => {
it("formats values under 1,000 as-is", () => {
expect(formatCompactTokenCount(0)).toBe("0");
expect(formatCompactTokenCount(999)).toBe("999");
});
it("formats thousands with one decimal, trimming a trailing .0", () => {
expect(formatCompactTokenCount(1_000)).toBe("1k");
expect(formatCompactTokenCount(214_500)).toBe("214.5k");
expect(formatCompactTokenCount(99_950)).toBe("100k");
});
it("formats millions with one decimal, trimming a trailing .0", () => {
expect(formatCompactTokenCount(1_000_000)).toBe("1M");
expect(formatCompactTokenCount(1_500_000)).toBe("1.5M");
});
it("rolls values that round up to 1000.0k over into the M branch instead of showing 1000k", () => {
// Regression test: 999,950-999,999 round to "1000.0" at one-decimal
// thousands precision. Before the fix, the >= 1_000_000 branch check
// ran on the raw value (which is still < 1_000_000), so these fell
// through to the k branch and displayed the nonsensical "1000k".
expect(formatCompactTokenCount(999_999)).toBe("1M");
expect(formatCompactTokenCount(999_950)).toBe("1M");
expect(formatCompactTokenCount(999_500)).toBe("999.5k");
});
it("does not roll over values just below the rounding boundary", () => {
expect(formatCompactTokenCount(999_949)).toBe("999.9k");
expect(formatCompactTokenCount(999_499)).toBe("999.5k");
});
});

View File

@@ -4,7 +4,15 @@ export function formatCompactTokenCount(tokens: number): string {
return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
// Values from 999,950-999,999 round to "1000.0" at one-decimal
// thousands precision, which would display the nonsensical "1000k"
// instead of rolling over to the M branch above. Re-check the
// rounded result before formatting.
const thousands = (tokens / 1_000).toFixed(1);
if (Number(thousands) >= 1_000) {
return `${(tokens / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
}
return `${thousands.replace(/\.0$/, "")}k`;
}
return String(tokens);
}