feat: harden workflow gates and expose token usage

This commit is contained in:
2026-07-21 12:14:26 +02:00
parent 8aa1676811
commit 2ff63d371f
20 changed files with 604 additions and 53 deletions
+46 -11
View File
@@ -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"; }
+4 -1
View File
@@ -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 });