197 lines
7.7 KiB
TypeScript
197 lines
7.7 KiB
TypeScript
import type { RpcClient } from "../rpc/rpc-client.js";
|
|
|
|
const GENERIC_MODEL_FAILURE =
|
|
"Model request failed. Check provider connectivity, then Resume the session.";
|
|
const SUBSCRIPTION_MODEL_FAILURE =
|
|
"The selected model is unavailable for the current subscription. Choose another model and start a new session.";
|
|
const PHASE_STARTED_NOTIFICATION_PREFIX = "__tht_phase_started__:";
|
|
|
|
function phaseStartedNotification(message: unknown): string | null {
|
|
if (typeof message !== "string" || !message.startsWith(PHASE_STARTED_NOTIFICATION_PREFIX)) {
|
|
return null;
|
|
}
|
|
const phase = message.slice(PHASE_STARTED_NOTIFICATION_PREFIX.length);
|
|
return /^F[1-8]$/.test(phase) ? phase : "";
|
|
}
|
|
|
|
function safeModelFailure(error: unknown): string {
|
|
const detail = typeof error === "string" ? error : "";
|
|
const isSubscriptionFailure =
|
|
/\b429\b/.test(detail) &&
|
|
/(subscription plan|code["':\s]+1311|does not yet include access)/i.test(detail);
|
|
|
|
return isSubscriptionFailure ? SUBSCRIPTION_MODEL_FAILURE : GENERIC_MODEL_FAILURE;
|
|
}
|
|
|
|
export type ToolActivity = {
|
|
kind: "tool";
|
|
toolCallId: string;
|
|
toolName: string;
|
|
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; phase?: string };
|
|
|
|
export type TurnState = "idle" | "running" | "waiting" | "failed";
|
|
|
|
export class SessionBridge {
|
|
private state: TurnState = "idle";
|
|
private pending: any = null;
|
|
// Pi (rpc-mode createDialogPromise) assegna a ogni ctx.ui.input un id RPC PROPRIO
|
|
// (crypto.randomUUID) e correla extension_ui_response su quell'id — NON sull'id interno
|
|
// 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) {
|
|
rpc.on("event", (m) => {
|
|
if (m.type === "extension_ui_request" && m.method === "input") {
|
|
let descriptor: unknown;
|
|
try { descriptor = JSON.parse(m.title as string); } catch { return; }
|
|
this.pending = descriptor;
|
|
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") {
|
|
this.emitUsage(m.message.usage);
|
|
if (m.message.stopReason === "error") {
|
|
this.markFailed();
|
|
this.fan({
|
|
type: "info",
|
|
level: "error",
|
|
text: safeModelFailure(m.message.errorMessage),
|
|
});
|
|
}
|
|
} else if (m.type === "extension_ui_request" && m.method === "notify") {
|
|
const phase = phaseStartedNotification(m.message);
|
|
if (phase) this.fan({ type: "system_event", event: "phase_started", phase });
|
|
else if (phase === null) {
|
|
this.fan({ type: "info", level: m.notifyType ?? "info", text: m.message ?? "" });
|
|
}
|
|
} else if (m.type === "message_update" && m.assistantMessageEvent?.type === "text_delta") {
|
|
this.fan({ type: "text_delta", text: m.assistantMessageEvent.delta ?? "" });
|
|
} else if (m.type === "message_update" && m.assistantMessageEvent?.type === "thinking_delta") {
|
|
this.fan({ type: "activity_delta", text: m.assistantMessageEvent.delta ?? "" });
|
|
} else if (m.type === "text_delta") {
|
|
this.fan({ type: "text_delta", text: m.text ?? "" });
|
|
} else if (m.type === "tool_execution_start") {
|
|
this.emitToolActivity(m, "start");
|
|
} else if (m.type === "tool_execution_end") {
|
|
this.emitToolActivity(m, "end");
|
|
// Tool updates and raw payloads remain intentionally dropped.
|
|
} else if (m.type === "system_event") {
|
|
if (typeof m.event === "string" && m.event.trim() !== "") {
|
|
this.fan({ type: "system_event", event: m.event });
|
|
}
|
|
} else if (m.type === "agent_end") {
|
|
if (this.state !== "failed" && !this.pending) this.state = "idle";
|
|
this.fan({ type: "system_event", event: "agent_end" });
|
|
} else if (m.type === "agent_start") {
|
|
this.state = "running";
|
|
this.fan({ type: "system_event", event: "agent_start" });
|
|
} else if (m.type === "turn_end") {
|
|
this.fan({ type: "system_event", event: "turn_end" });
|
|
}
|
|
});
|
|
}
|
|
|
|
private emitToolActivity(message: any, lifecycle: "start" | "end"): void {
|
|
const toolCallId = message.toolCallId;
|
|
if (typeof toolCallId !== "string" || toolCallId.trim() === "") return;
|
|
|
|
const toolName =
|
|
typeof message.toolName === "string" && message.toolName.trim() !== ""
|
|
? message.toolName
|
|
: "Tool";
|
|
const status =
|
|
lifecycle === "start" ? "running" : message.isError === true ? "failed" : "completed";
|
|
|
|
this.fan({
|
|
type: "activity_event",
|
|
activity: { kind: "tool", toolCallId, toolName, status },
|
|
});
|
|
}
|
|
|
|
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"; }
|
|
|
|
onClientEvent(cb: (e: ClientEvent) => void): void { this.cbs.add(cb); }
|
|
|
|
/** Eventi generati dal backend stesso (es. exit inatteso del child Pi), non da Pi. */
|
|
emitClientEvent(e: ClientEvent): void { this.fan(e); }
|
|
|
|
/**
|
|
* Deliver a reviewer response to Pi. Accepts ONLY a response matching the pending
|
|
* descriptor: a stale/duplicate response (old gate id, double submit) would otherwise
|
|
* be sent with the CURRENT gate's RPC id, flip the state to running, and leave the
|
|
* real gate waiting. Returns false when rejected so the route can 409.
|
|
*/
|
|
respond(uiResponse: object & { id: string }): boolean {
|
|
if (!this.pending || uiResponse.id !== (this.pending as { id?: unknown }).id) return false;
|
|
// Correla sull'id RPC di Pi; `value` porta l'uiResponse (con l'id del descriptor) cosi'
|
|
// il check interno del gate (resp.id === descriptor.id) regge.
|
|
const piId = this.pendingPiId ?? uiResponse.id;
|
|
this.state = "running";
|
|
this.rpc.send({ type: "extension_ui_response", id: piId, value: JSON.stringify(uiResponse) });
|
|
this.pending = null;
|
|
this.pendingPiId = null;
|
|
return true;
|
|
}
|
|
|
|
steer(text: string): void {
|
|
this.state = "running";
|
|
this.rpc.send({ type: "steer", message: text });
|
|
}
|
|
|
|
pendingWidget(): object | null { return this.pending; }
|
|
}
|