diff --git a/backend/src/bridge/session-bridge.ts b/backend/src/bridge/session-bridge.ts
index 4fc2493c..1e65926a 100644
--- a/backend/src/bridge/session-bridge.ts
+++ b/backend/src/bridge/session-bridge.ts
@@ -7,11 +7,20 @@ export type ToolActivity = {
status: "running" | "completed" | "failed";
};
+export type TokenUsage = {
+ input: number;
+ cacheRead: number;
+ output: number;
+ totalTokens: number;
+ contextWindow: number;
+};
+
export type ClientEvent =
| { type: "ui_request"; ui_request: any }
| { type: "text_delta"; text: string }
| { type: "activity_delta"; text: string }
| { type: "activity_event"; activity: ToolActivity }
+ | { type: "usage"; usage: TokenUsage }
| { type: "info"; [k: string]: any }
| { type: "system_event"; event: string };
@@ -25,6 +34,7 @@ export class SessionBridge {
// del descriptor (che viaggia opaco nel `title`). Va memorizzato e rimandato indietro,
// altrimenti Pi scarta la risposta e ctx.ui.input non si risolve mai (stuck senza output).
private pendingPiId: string | null = null;
+ private contextWindow = 0;
private cbs = new Set<(e: ClientEvent) => void>();
constructor(private rpc: RpcClient) {
@@ -36,17 +46,16 @@ export class SessionBridge {
this.pendingPiId = m.id as string | null;
this.state = "waiting";
this.fan({ type: "ui_request", ui_request: descriptor });
- } else if (
- m.type === "message_end" &&
- m.message?.role === "assistant" &&
- m.message?.stopReason === "error"
- ) {
- this.markFailed();
- this.fan({
- type: "info",
- level: "error",
- text: "Model request failed. Check provider connectivity, then Resume the session.",
- });
+ } else if (m.type === "message_end" && m.message?.role === "assistant") {
+ this.emitUsage(m.message.usage);
+ if (m.message.stopReason === "error") {
+ this.markFailed();
+ this.fan({
+ type: "info",
+ level: "error",
+ text: "Model request failed. Check provider connectivity, then Resume the session.",
+ });
+ }
} else if (m.type === "extension_ui_request" && m.method === "notify") {
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
} else if (m.type === "message_update" && m.assistantMessageEvent?.type === "text_delta") {
@@ -93,12 +102,38 @@ export class SessionBridge {
});
}
+ private emitUsage(usage: any): void {
+ if (!usage || this.contextWindow <= 0) return;
+ const number = (value: unknown): number =>
+ typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
+ const input = number(usage.input);
+ const cacheRead = number(usage.cacheRead);
+ const output = number(usage.output);
+ const reportedTotal = number(usage.totalTokens);
+ this.fan({
+ type: "usage",
+ usage: {
+ input,
+ cacheRead,
+ output,
+ totalTokens: reportedTotal || input + cacheRead + output,
+ contextWindow: this.contextWindow,
+ },
+ });
+ }
+
private fan(e: ClientEvent) { for (const cb of this.cbs) cb(e); }
turnState(): TurnState { return this.state; }
beginTurn(): void { this.state = "running"; }
+ setContextWindow(value: unknown): void {
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
+ this.contextWindow = value;
+ }
+ }
+
/** Record a backend-detected failure; callers own any sanitized client message. */
markFailed(): void { this.state = "failed"; }
diff --git a/backend/src/pi/pi-process-manager.ts b/backend/src/pi/pi-process-manager.ts
index aa3e49a2..9b626c30 100644
--- a/backend/src/pi/pi-process-manager.ts
+++ b/backend/src/pi/pi-process-manager.ts
@@ -166,7 +166,10 @@ export class PiProcessManager {
const thinking = o.thinking ?? this.cfg.defaults.thinking;
if (provider && model) {
- await rt.rpc.request({ type: "set_model", provider, modelId: model } as object & { type: string });
+ const response = await rt.rpc.request(
+ { type: "set_model", provider, modelId: model } as object & { type: string },
+ );
+ rt.bridge.setContextWindow(response?.data?.contextWindow);
}
if (thinking) {
await rt.rpc.request({ type: "set_thinking_level", level: thinking } as object & { type: string });
diff --git a/backend/test/pi-process-manager.test.ts b/backend/test/pi-process-manager.test.ts
index 7d898370..9a552292 100644
--- a/backend/test/pi-process-manager.test.ts
+++ b/backend/test/pi-process-manager.test.ts
@@ -207,6 +207,43 @@ test("createFor does not prompt until start is called", async () => {
mgr.teardown("sid-deferred");
});
+test("configure gives the bridge the context window returned by set_model", async () => {
+ const child = recordingChild();
+ child.stdin.write = (data: unknown) => {
+ const request = JSON.parse(String(data));
+ child._writes.push(String(data));
+ if (request.type === "set_model") {
+ queueMicrotask(() => child.stdout.emit("data", `${JSON.stringify({
+ type: "response",
+ id: request.id,
+ success: true,
+ data: { contextWindow: 200_000 },
+ })}\n`));
+ }
+ return true;
+ };
+ const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
+ const rt = mgr.createFor("context-window", {});
+ const seen: any[] = [];
+ rt.bridge.onClientEvent((event) => seen.push(event));
+
+ await mgr.configure(rt, { provider: "zai", model: "glm-5.2" });
+ child.stdout.emit("data", `${JSON.stringify({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ stopReason: "stop",
+ usage: { input: 10, cacheRead: 20, output: 5, totalTokens: 35 },
+ },
+ })}\n`);
+
+ expect(seen).toContainEqual({
+ type: "usage",
+ usage: { input: 10, cacheRead: 20, output: 5, totalTokens: 35, contextWindow: 200_000 },
+ });
+ mgr.teardown("context-window");
+});
+
test("a created runtime is active during configure/bootstrap", () => {
const child = recordingChild();
const mgr = new PiProcessManager(loadConfig({}), { spawnFn: () => child as any });
diff --git a/backend/test/session-bridge.test.ts b/backend/test/session-bridge.test.ts
index e555ef91..fbfe1d50 100644
--- a/backend/test/session-bridge.test.ts
+++ b/backend/test/session-bridge.test.ts
@@ -42,6 +42,43 @@ test("real Pi thinking_delta becomes a dedicated activity_delta to the FE", () =
expect(seen).toEqual([{ type: "activity_delta", text: "Valuto le ambiguità" }]);
});
+test("assistant message_end exposes sanitized token usage with the configured context window", () => {
+ const { rpc, fire } = fakeRpc();
+ const bridge = new SessionBridge(rpc);
+ const seen: any[] = [];
+ bridge.onClientEvent((event) => seen.push(event));
+ bridge.setContextWindow(200_000);
+
+ fire({
+ type: "message_end",
+ message: {
+ role: "assistant",
+ stopReason: "stop",
+ usage: {
+ input: 110_348,
+ cacheRead: 633_344,
+ cacheWrite: 12,
+ output: 33_652,
+ totalTokens: 777_356,
+ cost: { total: 99 },
+ },
+ content: "DO_NOT_FORWARD",
+ },
+ });
+
+ expect(seen).toEqual([{
+ type: "usage",
+ usage: {
+ input: 110_348,
+ cacheRead: 633_344,
+ output: 33_652,
+ totalTokens: 777_356,
+ contextWindow: 200_000,
+ },
+ }]);
+ expect(JSON.stringify(seen)).not.toMatch(/DO_NOT_FORWARD|cost|cacheWrite/);
+});
+
test("assistant provider errors are sanitized and leave the turn failed", () => {
const { rpc, fire } = fakeRpc();
const bridge = new SessionBridge(rpc);
diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts
index 9901e7d8..4172cc9e 100644
--- a/frontend/src/api/types.ts
+++ b/frontend/src/api/types.ts
@@ -75,6 +75,14 @@ export interface ActivityEntry {
status?: "running" | "completed" | "failed";
}
+export interface TokenUsage {
+ input: number;
+ cacheRead: number;
+ output: number;
+ totalTokens: number;
+ contextWindow: number;
+}
+
export type StreamEvent =
| { type: "ui_request"; ui_request: WidgetDescriptor }
| { type: "text_delta"; text: string }
@@ -88,6 +96,7 @@ export type StreamEvent =
status: "running" | "completed" | "failed";
};
}
+ | { type: "usage"; usage: TokenUsage }
| { type: "info"; level?: "info" | "warning" | "error"; text: string }
| { type: "system_event"; event: string };
diff --git a/frontend/src/shell/SteerInput.test.tsx b/frontend/src/shell/SteerInput.test.tsx
index 4f8033be..efa2d656 100644
--- a/frontend/src/shell/SteerInput.test.tsx
+++ b/frontend/src/shell/SteerInput.test.tsx
@@ -2,8 +2,10 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { server } from "../test/msw";
-import { SteerInput } from "./SteerInput";
+import { useSessionStore } from "../store/sessionStore";
+import { ComposerFooter, ContextGauge, SteerInput } from "./SteerInput";
beforeEach(() => {
server.use(
@@ -83,6 +85,52 @@ test("marks the composer as awaiting input only when requested", () => {
expect(input).toHaveClass("thot-awaiting-input");
});
+test("the context gauge uses green, yellow, and red at the requested thresholds", () => {
+ const { rerender } = render(
No artifact content available.
; + } return (