fix: /status shows incorrect context percentage — totalTokens clamped to contextTokens (#15114) (#15133)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: a489669fc7
Co-authored-by: echoVic <16428813+echoVic@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
青雲
2026-02-13 12:52:19 +08:00
committed by GitHub
parent b93ad2cd48
commit fd076eb43a
28 changed files with 361 additions and 53 deletions

View File

@@ -66,14 +66,16 @@ export async function updateSessionStoreAfterAgentRun(params: {
if (hasNonzeroUsage(usage)) {
const input = usage.input ?? 0;
const output = usage.output ?? 0;
next.inputTokens = input;
next.outputTokens = output;
next.totalTokens =
const totalTokens =
deriveSessionTotalTokens({
usage,
contextTokens,
promptTokens,
}) ?? input;
next.inputTokens = input;
next.outputTokens = output;
next.totalTokens = totalTokens;
next.totalTokensFresh = true;
}
if (compactionsThisRun > 0) {
next.compactionCount = (entry.compactionCount ?? 0) + compactionsThisRun;

View File

@@ -66,6 +66,8 @@ describe("sessionsCommand", () => {
updatedAt: Date.now() - 45 * 60_000,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 2000,
totalTokensFresh: true,
model: "pi:opus",
},
});
@@ -99,8 +101,48 @@ describe("sessionsCommand", () => {
fs.rmSync(store);
const row = logs.find((line) => line.includes("discord:group:demo")) ?? "";
expect(row).toContain("-".padEnd(20));
expect(row).toContain("unknown/32k (?%)");
expect(row).toContain("think:high");
expect(row).toContain("5m ago");
});
it("exports freshness metadata in JSON output", async () => {
const store = writeStore({
main: {
sessionId: "abc123",
updatedAt: Date.now() - 10 * 60_000,
inputTokens: 1200,
outputTokens: 800,
totalTokens: 2000,
totalTokensFresh: true,
model: "pi:opus",
},
"discord:group:demo": {
sessionId: "xyz",
updatedAt: Date.now() - 5 * 60_000,
inputTokens: 20,
outputTokens: 10,
model: "pi:opus",
},
});
const { runtime, logs } = makeRuntime();
await sessionsCommand({ store, json: true }, runtime);
fs.rmSync(store);
const payload = JSON.parse(logs[0] ?? "{}") as {
sessions?: Array<{
key: string;
totalTokens: number | null;
totalTokensFresh: boolean;
}>;
};
const main = payload.sessions?.find((row) => row.key === "main");
const group = payload.sessions?.find((row) => row.key === "discord:group:demo");
expect(main?.totalTokens).toBe(2000);
expect(main?.totalTokensFresh).toBe(true);
expect(group?.totalTokens).toBeNull();
expect(group?.totalTokensFresh).toBe(false);
});
});

View File

@@ -3,7 +3,12 @@ import { lookupContextTokens } from "../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath, type SessionEntry } from "../config/sessions.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveStorePath,
type SessionEntry,
} from "../config/sessions.js";
import { info } from "../globals.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
import { isRich, theme } from "../terminal/theme.js";
@@ -25,6 +30,7 @@ type SessionRow = {
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
model?: string;
contextTokens?: number;
};
@@ -61,9 +67,15 @@ const colorByPct = (label: string, pct: number | null, rich: boolean) => {
return theme.muted(label);
};
const formatTokensCell = (total: number, contextTokens: number | null, rich: boolean) => {
if (!total) {
return "-".padEnd(TOKENS_PAD);
const formatTokensCell = (
total: number | undefined,
contextTokens: number | null,
rich: boolean,
) => {
if (total === undefined) {
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
const label = `unknown/${ctxLabel} (?%)`;
return rich ? theme.muted(label.padEnd(TOKENS_PAD)) : label.padEnd(TOKENS_PAD);
}
const totalLabel = formatKTokens(total);
const ctxLabel = contextTokens ? formatKTokens(contextTokens) : "?";
@@ -154,6 +166,7 @@ function toRows(store: Record<string, SessionEntry>): SessionRow[] {
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: entry?.totalTokens,
totalTokensFresh: entry?.totalTokensFresh,
model: entry?.model,
contextTokens: entry?.contextTokens,
} satisfies SessionRow;
@@ -209,6 +222,9 @@ export async function sessionsCommand(
activeMinutes: activeMinutes ?? null,
sessions: rows.map((r) => ({
...r,
totalTokens: resolveFreshSessionTotalTokens(r) ?? null,
totalTokensFresh:
typeof r.totalTokens === "number" ? r.totalTokensFresh !== false : false,
contextTokens:
r.contextTokens ?? lookupContextTokens(r.model) ?? configContextTokens ?? null,
model: r.model ?? configModel ?? null,
@@ -246,9 +262,7 @@ export async function sessionsCommand(
for (const row of rows) {
const model = row.model ?? configModel;
const contextTokens = row.contextTokens ?? lookupContextTokens(model) ?? configContextTokens;
const input = row.inputTokens ?? 0;
const output = row.outputTokens ?? 0;
const total = row.totalTokens ?? input + output;
const total = resolveFreshSessionTotalTokens(row);
const keyLabel = truncateKey(row.key).padEnd(KEY_PAD);
const keyCell = rich ? theme.accent(keyLabel) : keyLabel;

View File

@@ -22,8 +22,11 @@ export const shortenText = (value: string, maxLen: number) => {
export const formatTokensCompact = (
sess: Pick<SessionStatus, "totalTokens" | "contextTokens" | "percentUsed">,
) => {
const used = sess.totalTokens ?? 0;
const used = sess.totalTokens;
const ctx = sess.contextTokens;
if (used == null) {
return ctx ? `unknown/${formatKTokens(ctx)} (?%)` : "unknown used";
}
if (!ctx) {
return `${formatKTokens(used)} used`;
}

View File

@@ -5,6 +5,7 @@ import { resolveConfiguredModelRef } from "../agents/model-selection.js";
import { loadConfig } from "../config/config.js";
import {
loadSessionStore,
resolveFreshSessionTotalTokens,
resolveMainSessionKey,
resolveStorePath,
type SessionEntry,
@@ -120,12 +121,13 @@ export async function getStatusSummary(): Promise<StatusSummary> {
const model = entry?.model ?? configModel ?? null;
const contextTokens =
entry?.contextTokens ?? lookupContextTokens(model) ?? configContextTokens ?? null;
const input = entry?.inputTokens ?? 0;
const output = entry?.outputTokens ?? 0;
const total = entry?.totalTokens ?? input + output;
const remaining = contextTokens != null ? Math.max(0, contextTokens - total) : null;
const total = resolveFreshSessionTotalTokens(entry);
const totalTokensFresh =
typeof entry?.totalTokens === "number" ? entry?.totalTokensFresh !== false : false;
const remaining =
contextTokens != null && total !== undefined ? Math.max(0, contextTokens - total) : null;
const pct =
contextTokens && contextTokens > 0
contextTokens && contextTokens > 0 && total !== undefined
? Math.min(999, Math.round((total / contextTokens) * 100))
: null;
const parsedAgentId = parseAgentSessionKey(key)?.agentId;
@@ -147,6 +149,7 @@ export async function getStatusSummary(): Promise<StatusSummary> {
inputTokens: entry?.inputTokens,
outputTokens: entry?.outputTokens,
totalTokens: total ?? null,
totalTokensFresh,
remainingTokens: remaining,
percentUsed: pct,
model,

View File

@@ -23,6 +23,7 @@ const mocks = vi.hoisted(() => ({
thinkingLevel: "low",
inputTokens: 2_000,
outputTokens: 3_000,
totalTokens: 5_000,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",
@@ -120,6 +121,12 @@ vi.mock("../config/sessions.js", () => ({
loadSessionStore: mocks.loadSessionStore,
resolveMainSessionKey: mocks.resolveMainSessionKey,
resolveStorePath: mocks.resolveStorePath,
resolveFreshSessionTotalTokens: vi.fn(
(entry?: { totalTokens?: number; totalTokensFresh?: boolean }) =>
typeof entry?.totalTokens === "number" && entry?.totalTokensFresh !== false
? entry.totalTokens
: undefined,
),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
}));
@@ -303,6 +310,7 @@ describe("statusCommand", () => {
expect(payload.sessions.defaults.model).toBeTruthy();
expect(payload.sessions.defaults.contextTokens).toBeGreaterThan(0);
expect(payload.sessions.recent[0].percentUsed).toBe(50);
expect(payload.sessions.recent[0].totalTokensFresh).toBe(true);
expect(payload.sessions.recent[0].remainingTokens).toBe(5000);
expect(payload.sessions.recent[0].flags).toContain("verbose:on");
expect(payload.securityAudit.summary.critical).toBe(1);
@@ -311,6 +319,55 @@ describe("statusCommand", () => {
expect(payload.nodeService.label).toBe("LaunchAgent");
});
it("surfaces unknown usage when totalTokens is missing", async () => {
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
mocks.loadSessionStore.mockReturnValue({
"+1000": {
updatedAt: Date.now() - 60_000,
inputTokens: 2_000,
outputTokens: 3_000,
contextTokens: 10_000,
model: "pi:opus",
},
});
(runtime.log as vi.Mock).mockClear();
await statusCommand({ json: true }, runtime as never);
const payload = JSON.parse((runtime.log as vi.Mock).mock.calls.at(-1)?.[0]);
expect(payload.sessions.recent[0].totalTokens).toBeNull();
expect(payload.sessions.recent[0].totalTokensFresh).toBe(false);
expect(payload.sessions.recent[0].percentUsed).toBeNull();
expect(payload.sessions.recent[0].remainingTokens).toBeNull();
if (originalLoadSessionStore) {
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
}
});
it("prints unknown usage in formatted output when totalTokens is missing", async () => {
const originalLoadSessionStore = mocks.loadSessionStore.getMockImplementation();
mocks.loadSessionStore.mockReturnValue({
"+1000": {
updatedAt: Date.now() - 60_000,
inputTokens: 2_000,
outputTokens: 3_000,
contextTokens: 10_000,
model: "pi:opus",
},
});
try {
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
const logs = (runtime.log as vi.Mock).mock.calls.map((c) => String(c[0]));
expect(logs.some((line) => line.includes("unknown/") && line.includes("(?%)"))).toBe(true);
} finally {
if (originalLoadSessionStore) {
mocks.loadSessionStore.mockImplementation(originalLoadSessionStore);
}
}
});
it("prints formatted lines otherwise", async () => {
(runtime.log as vi.Mock).mockClear();
await statusCommand({}, runtime as never);
@@ -439,6 +496,7 @@ describe("statusCommand", () => {
updatedAt: Date.now() - 120_000,
inputTokens: 1_000,
outputTokens: 1_000,
totalTokens: 2_000,
contextTokens: 10_000,
model: "pi:opus",
},
@@ -451,6 +509,7 @@ describe("statusCommand", () => {
thinkingLevel: "low",
inputTokens: 2_000,
outputTokens: 3_000,
totalTokens: 5_000,
contextTokens: 10_000,
model: "pi:opus",
sessionId: "abc123",

View File

@@ -16,6 +16,7 @@ export type SessionStatus = {
inputTokens?: number;
outputTokens?: number;
totalTokens: number | null;
totalTokensFresh: boolean;
remainingTokens: number | null;
percentUsed: number | null;
model: string | null;