Merge pull request #8099 from HaoyueQin/feat/status-bar-per-request-tps

fix(desktop): refresh status bar TPS after each provider request / 底部信息栏吞吐速度改为每次请求后更新
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-10 03:03:15 +08:00
commit b22ef01193
5 files changed
+176 -32

No files matched your search

+1
View File
@@ -5416,6 +5416,7 @@ export default function App() {
lastTurnOutputTokens={state.lastTurnOutputTokens}
lastTurnModelMs={state.lastTurnModelMs}
lastTurnOutputEstimated={state.lastTurnOutputEstimated}
lastRequestTps={state.lastRequestTps}
turnCost={state.turnCost}
cost={state.sessionCost}
currency={state.sessionCurrency}
@@ -209,6 +209,24 @@ console.log("\nstatus bar workspace");
lastTurnOutputEstimated: true,
});
ok(estimated.includes("≈20 t/s"), "fallback TPS is visibly marked as estimated");
const perRequest = renderStatusBar({
items: ["turn_tps"],
lastRequestTps: 35,
lastTurnOutputTokens: 100,
lastTurnModelMs: 5_000,
});
ok(perRequest.includes("35 t/s"), "per-request TPS wins over the completed turn value");
const slowRequest = renderStatusBar({
items: ["turn_tps"], lastRequestTps: 1 / 3, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
});
ok(slowRequest.includes("<1 t/s") && !slowRequest.includes("20 t/s"), "sub-one request TPS replaces the stale turn fallback");
const unavailable = renderStatusBar({
items: ["turn_tps"], lastRequestTps: null, lastTurnOutputTokens: 100, lastTurnModelMs: 5_000,
});
ok(unavailable.includes('stat__value--empty">-</b>') && !unavailable.includes("20 t/s"), "unmeasured latest requests clear the stale turn fallback");
}
{
@@ -362,5 +362,133 @@ function ev(s: typeof initialState, e: WireEvent) {
}
}
// --- 7. lastRequestTps pairs the closed interval with the usage tokens ---
{
const originalNow = Date.now;
let now = 50_000;
Date.now = () => now;
try {
let s = ev({ ...initialState }, { kind: "turn_started" } as WireEvent);
now = 50_100;
s = ev(s, { kind: "text", text: "abcd" } as WireEvent);
now = 51_100;
// The message event closes the interval BEFORE the usage event arrives.
s = ev(s, { kind: "message", text: "abcd" } as WireEvent);
now = 51_200;
s = ev(s, { kind: "usage", usage: {
promptTokens: 130, completionTokens: 30, totalTokens: 160,
contextPromptTokens: 100, contextCompletionTokens: 20,
cacheHitTokens: 0, cacheMissTokens: 100, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 20, "sampling recovery pairs the interval with latest-attempt tokens");
now = 52_000;
s = ev(s, { kind: "text", text: "more" } as WireEvent);
now = 52_050;
s = ev(s, { kind: "message", text: "more" } as WireEvent);
now = 52_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 5, completionTokens: 50, totalTokens: 55,
cacheHitTokens: 0, cacheMissTokens: 5, source: "subagent",
} } as WireEvent);
eq(s.lastRequestTps, 20, "non-executor usage neither computes nor consumes the pending interval");
now = 52_300;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, null, "intervals under the 500ms gate clear stale request TPS");
now = 53_000;
s = ev(s, { kind: "text", text: "second" } as WireEvent);
now = 54_000;
s = ev(s, { kind: "message", text: "second" } as WireEvent);
now = 54_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 30, "a later executor usage refreshes the request TPS");
now = 55_000;
s = ev(s, { kind: "text", text: "direct" } as WireEvent);
now = 56_000;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 40, totalTokens: 50,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 40, "usage measures an interval still open at arrival");
now = 57_000;
s = ev(s, { kind: "turn_done" } as WireEvent);
eq(s.lastRequestTps, 40, "request TPS persists across turn boundaries");
now = 58_000;
s = ev(s, { kind: "turn_started" } as WireEvent);
now = 58_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 8, totalTokens: 18,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, null, "usage without a provider interval clears stale request TPS");
now = 58_200;
s = ev(s, { kind: "tool_dispatch", tool: { id: "final-only", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
eq(s.lastRequestTps, null, "a final-only tool dispatch after usage cannot resurrect stale TPS");
now = 59_000;
s = ev(s, { kind: "text", text: "toolcall" } as WireEvent);
now = 60_000;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t1", name: "read_file", args: "{}", readOnly: true } } as WireEvent);
now = 60_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 25, totalTokens: 35,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 25, "tool_dispatch closes the interval the next executor usage pairs with");
now = 61_000;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", readOnly: false, partial: true, argChars: 600 } } as WireEvent);
now = 62_000;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 30, "usage closes the interval started by a partial tool dispatch");
now = 62_100;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t2", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
eq(s.lastRequestTps, 30, "the later full tool dispatch preserves the measured request TPS");
now = 63_000;
s = ev(s, { kind: "text", text: "closing" } as WireEvent);
now = 64_000;
s = ev(s, { kind: "message", text: "closing" } as WireEvent);
now = 64_100;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", readOnly: false, partial: true, argChars: 300 } } as WireEvent);
now = 64_700;
s = ev(s, { kind: "tool_dispatch", tool: { id: "t3", name: "write_file", args: "{}", readOnly: false } } as WireEvent);
now = 64_800;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 30, totalTokens: 40,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
// The partial restart begins a new interval; the full dispatch closes it
// and overwrites the message-stashed pending with its own (≥500ms) tail.
eq(s.lastRequestTps, 50, "a full dispatch overwrites a message-stashed pending with its own tail close");
now = 65_000;
s = ev(s, { kind: "text", text: "slow" } as WireEvent);
now = 68_000;
s = ev(s, { kind: "message", text: "slow" } as WireEvent);
now = 68_100;
s = ev(s, { kind: "usage", usage: {
promptTokens: 10, completionTokens: 1, totalTokens: 11,
cacheHitTokens: 0, cacheMissTokens: 10, source: "executor",
} } as WireEvent);
eq(s.lastRequestTps, 1 / 3, "slow measurable requests retain their raw sub-one TPS");
} finally {
Date.now = originalNow;
}
}
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -66,14 +66,8 @@ function formatTurnCount(turns: number | undefined, t: Translator): string {
if (typeof turns !== "number" || turns < 0) return "-";
return t(turns === 1 ? "history.turnOne" : "history.turnOther", { n: turns });
}
function formatTps(outputTokens?: number, modelMs?: number, estimated = false): string | null {
if (!outputTokens || outputTokens <= 0) return null;
if (!modelMs || modelMs <= 0) return null;
const elapsedSec = modelMs / 1000;
if (elapsedSec < 0.001) return null;
const tps = outputTokens / elapsedSec;
function formatTps(tps?: number | null, estimated = false): string | null {
if (!tps || tps <= 0) return null;
const prefix = estimated ? "≈" : "";
if (tps < 1) return `${prefix}<1 t/s`;
return `${prefix}${Math.round(tps)} t/s`;
@@ -176,6 +170,7 @@ export function StatusBar({
lastTurnOutputTokens,
lastTurnModelMs,
lastTurnOutputEstimated = false,
lastRequestTps,
turnCost,
cost,
currency,
@@ -209,6 +204,7 @@ export function StatusBar({
lastTurnOutputTokens?: number;
lastTurnModelMs?: number;
lastTurnOutputEstimated?: boolean;
lastRequestTps?: number | null; // Null means the latest request was not measurable.
turnCost?: number;
cost?: number;
currency?: string;
@@ -255,7 +251,9 @@ export function StatusBar({
const tokenLabel = markEstimated(formatTokenCount(sessionTokens), sessionEstimated);
const turnTokenLabel = markEstimated(formatTokenCount(turnTokens), turnEstimated);
const balanceLabel = balance?.available && balance.display ? balance.display : "-";
const tpsLabel = formatTps(lastTurnOutputTokens, lastTurnModelMs, lastTurnOutputEstimated);
const tpsLabel = lastRequestTps === undefined
? formatTps(lastTurnOutputTokens && lastTurnModelMs ? lastTurnOutputTokens / (lastTurnModelMs / 1_000) : null, lastTurnOutputEstimated)
: formatTps(lastRequestTps);
const formatUsageToken = (value: number) => `${usage?.estimated ? "≈" : ""}${value.toLocaleString()}`;
const outputTokensLabel = usage && typeof usage.completionTokens === "number"
? formatUsageToken(usage.completionTokens)
+22 -23
View File
@@ -414,6 +414,9 @@ interface State {
lastTurnWaitAccumMs: number;
lastTurnModelMs: number;
lastTurnOutputEstimated: boolean;
// Per-request rate (null when unmeasurable) and pending interval are tab-local.
lastRequestTps?: number | null;
pendingRequestModelMs?: number;
promptWaitStartedAt?: number;
// promptEventClock() reading taken when the CURRENT pending prompt first
// arrived. Orders the prompt against reconciliation snapshots so a snapshot
@@ -1126,7 +1129,7 @@ function endPromptWaitIfIdle(s: State, now = Date.now()): State {
return endPromptWait(s, now);
}
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars"> {
function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDoneAt" | "turnWaitAccumMs" | "promptWaitStartedAt" | "turnTokens" | "turnTotalTokens" | "turnOutputTokens" | "turnOutputChars" | "turnOutputCharsAtUsage" | "turnOutputEstimated" | "turnModelActiveAt" | "turnModelActiveMs" | "turnCost" | "turnArgChars" | "pendingRequestModelMs"> {
return {
turnStartAt: now,
turnDoneAt: 0,
@@ -1139,7 +1142,7 @@ function resetTurnTiming(now = Date.now()): Pick<State, "turnStartAt" | "turnDon
turnOutputCharsAtUsage: 0,
turnOutputEstimated: false,
turnModelActiveAt: undefined,
turnModelActiveMs: 0,
turnModelActiveMs: 0, pendingRequestModelMs: undefined,
turnCost: 0,
turnArgChars: 0,
};
@@ -1156,13 +1159,11 @@ function beginTurnModelActivity(s: State, now = Date.now()): State {
: { ...s, turnModelActiveAt: now };
}
function endTurnModelActivity(s: State, now = Date.now()): State {
function endTurnModelActivity(s: State, now = Date.now(), stashForUsage = false): State {
if (!s.turnModelActiveAt || s.turnModelActiveAt <= 0) return s;
return {
...s,
turnModelActiveAt: undefined,
turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + Math.max(0, now - s.turnModelActiveAt),
};
const closedMs = Math.max(0, now - s.turnModelActiveAt);
return { ...s, turnModelActiveAt: undefined, turnModelActiveMs: Math.max(0, s.turnModelActiveMs) + closedMs,
pendingRequestModelMs: stashForUsage ? closedMs : s.pendingRequestModelMs };
}
function snapshotCompletedTurnTelemetry(s: State, now = Date.now()): State {
@@ -1485,10 +1486,10 @@ function applyEvent(s: State, e: WireEvent): State {
existingAssistant && existingAssistant.text.trim() === "" && existingAssistant.reasoning.trim() === "" && !existingAssistant.memoryCitations?.length
? s.items.filter((it) => !(it.kind === "assistant" && it.id === existingAssistant.id))
: s.items;
return { ...endTurnModelActivity(s), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
return { ...endTurnModelActivity(s, Date.now(), true), items, live: undefined, currentAssistant: undefined, turnOutputCharsAtUsage: 0 };
}
const now = Date.now();
const settled = endTurnModelActivity(s, now);
const settled = endTurnModelActivity(s, now, true);
const { items, id, seq } = ensureAssistant(settled);
const streamedChars = settled.live?.id === id ? settled.live.text.length + settled.live.reasoning.length : 0;
const turnOutputChars = Math.max(0, settled.turnOutputChars - streamedChars + text.length + reasoning.length);
@@ -1552,7 +1553,7 @@ function applyEvent(s: State, e: WireEvent): State {
items: [...activeState.items, { kind: "tool", id, name: t.name, args: "", readOnly: t.readOnly, resolvedName: t.resolvedName, capabilityId: t.capabilityId, status: "running", argChars: t.argChars || undefined, parentId: t.parentId, subagentProgress: SUBAGENT_PROGRESS_TOOLS.has(t.name) ? freshSubagentProgress() : undefined }],
}, id, false, undefined, { attemptId: t.attemptId, parentId: t.parentId, partial: true });
}
const settled = t.parentId ? s : endTurnModelActivity(s);
const settled = t.parentId ? s : endTurnModelActivity(s, Date.now(), true);
const id = t.id || `tool${s.seq}`;
const idx = settled.items.findIndex((it) => it.kind === "tool" && it.id === id);
if (idx >= 0) {
@@ -1661,18 +1662,16 @@ function applyEvent(s: State, e: WireEvent): State {
// Only executor usage belongs to the foreground model stream. Planner,
// subagent, and auxiliary usage still contributes to session totals and
// usageSeq, but must not close or inflate the executor TPS interval.
const settled = updateContextGauge ? endTurnModelActivity(s) : s;
// Prefer Context* (latest attempt) over billable aggregates when multi-
// attempt sampling recovery folds several provider calls into one Usage.
// Matches Controller.ContextSnapshot: latest prompt + completion.
const settled = updateContextGauge ? endTurnModelActivity(s, Date.now(), true) : s;
const hasRequestContext = (e.usage?.contextPromptTokens ?? 0) > 0 || (e.usage?.contextCompletionTokens ?? 0) > 0;
const requestModelMs = updateContextGauge ? (settled.pendingRequestModelMs ?? 0) : 0;
const requestTokens = updateContextGauge ? (hasRequestContext ? (e.usage?.contextCompletionTokens ?? 0) : (e.usage?.completionTokens ?? 0)) : 0;
const lastRequestTps = updateContextGauge ? (requestTokens > 0 && requestModelMs >= 500 ? requestTokens / (requestModelMs / 1000) : null) : s.lastRequestTps;
// Context* is the latest sampling attempt; other token fields are billable aggregates.
let used = settled.context.used;
if (e.usage && settled.context.window && updateContextGauge) {
const hasContext =
(e.usage.contextPromptTokens ?? 0) > 0 || (e.usage.contextCompletionTokens ?? 0) > 0;
used = hasContext
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
}
if (e.usage && settled.context.window && updateContextGauge) used = hasRequestContext
? (e.usage.contextPromptTokens ?? 0) + (e.usage.contextCompletionTokens ?? 0)
: (e.usage.promptTokens ?? 0) + (e.usage.completionTokens ?? 0);
const turnTokens = settled.turnTokens + (e.usage?.completionTokens ?? 0);
const turnOutputTokens = updateContextGauge
? settled.turnOutputTokens + (e.usage?.completionTokens ?? 0)
@@ -1693,7 +1692,7 @@ function applyEvent(s: State, e: WireEvent): State {
const usage = updateContextGauge ? e.usage : settled.usage;
// The completed round's usage now accounts for the streamed tool-call
// arguments, so drop the live estimate rather than double-count it.
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1 };
return { ...settled, usage, context: { ...settled.context, used, sessionTokens }, turnTokens, turnOutputTokens, turnOutputCharsAtUsage, turnOutputEstimated, turnTotalTokens, turnCost, turnArgChars: updateContextGauge ? 0 : settled.turnArgChars, sessionTokens, sessionCost, sessionCurrency, usageSeq: settled.usageSeq + 1, lastRequestTps, pendingRequestModelMs: updateContextGauge ? undefined : settled.pendingRequestModelMs };
}
case "notice":
return appendNoticeToState(s, e.level ?? "info", e.text ?? "", e.detail, e.code, e.decisionReceipt);