Files
beaver-gateway/ui/src/lib/panel/activity.svelte.ts
T

479 lines
13 KiB
TypeScript

import type {
BusEvent,
ConversationInfo,
PendingQuestion,
ToolSnapshot,
TurnUsage,
} from "$lib/api/types";
export type ToolStatus = "running" | "done" | "error" | "aborted";
export type TurnStatus = "running" | "done" | "interrupted" | "error";
export interface ToolNode {
children: string[];
endedAt: string | null;
id: string;
input: unknown;
name: string;
parent: string | null;
result: string;
startedAt: string;
status: ToolStatus;
}
export interface Turn {
endedAt: string | null;
id: string;
itemOrigin: string | null;
nodes: Record<string, ToolNode>;
origin: string;
// A tool call came between two pieces of text; the next piece starts a
// paragraph, as the final message will show it.
paused: boolean;
resultSubtype: string | null;
roots: string[];
says: string[];
startedAt: string;
status: TurnStatus;
text: string;
thinking: number;
usage: TurnUsage | null;
userText: string | null;
}
interface Cursor {
event: BusEvent;
parent: string | null;
ts: string;
turnId: string | null;
}
const MAX_TURNS = 30;
function str(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
export function joinText(head: string, tail: string, paused: boolean): string {
if (paused && head.trim() && !head.endsWith("\n")) {
return `${head.trimEnd()}\n\n${tail.trimStart()}`;
}
return head + tail;
}
export function isLive(turn: Turn): boolean {
return (
turn.status === "running" ||
Object.values(turn.nodes).some((node) => node.status === "running")
);
}
function stopStatus(turn: Turn, stop: string | null): TurnStatus {
if (stop === "end_turn") {
return turn.status === "error" ? "error" : "done";
}
return stop === "interrupted" ? "interrupted" : "error";
}
// Bus events in, turns → tool calls → subagent tool calls out. Reactive
// through $state so the tree re-renders per node, not per event.
export class ActivityModel {
conversation = $state<ConversationInfo | null>(null);
turns = $state<Turn[]>([]);
question = $state<PendingQuestion | null>(null);
// A subagent still working after its turn ended: nothing on this page
// knows the turn, so its tool calls hang under the launching Agent node
// of a turn named after that node.
private readonly handlers: Record<string, (c: Cursor) => void> = {
"conversation.created": (c) => this.onConversation(c),
"conversation.updated": (c) => this.onConversation(c),
question: (c) => this.onQuestion(c),
"question.answered": () => {
this.question = null;
},
"question.timeout": () => {
this.question = null;
},
reply: (c) => this.onReply(c),
result: (c) => this.onResult(c),
say: (c) => this.onSay(c),
stream: (c) => this.onStream(c),
tool: (c) => this.onTool(c),
"tool.result": (c) => this.onToolResult(c),
"turn.end": (c) => this.onTurnEnd(c),
"turn.start": (c) => this.onTurnStart(c),
};
get running(): Turn | null {
return this.turns.find((turn) => turn.status === "running") ?? null;
}
// A turn is live while it runs or while a subagent it launched still
// works: the CLI reports subagent tool calls after the turn's own end.
get live(): Turn[] {
return this.turns.filter((turn) => isLive(turn));
}
setConversation(info: ConversationInfo): void {
this.conversation = info;
this.question = info.question;
const now = new Date().toISOString();
for (const turn of this.turns) {
if (turn.status === "running" && turn.id !== info.running_turn) {
this.closeTurn(turn, "interrupted", now);
}
}
if (info.turn) {
const turn = this.ensureTurn(
info.turn.id,
info.turn.started_at ?? now,
info.turn.origin
);
turn.userText = info.turn.text ?? turn.userText;
for (const tool of info.turn.tools) {
this.applySnapshot(turn, tool);
}
} else if (info.running_turn) {
this.ensureTurn(info.running_turn, now, null);
}
}
apply(event: BusEvent): void {
const handler = this.handlers[event.type];
if (!handler) {
return;
}
handler({
event,
parent: str(event.parent_tool_use_id),
ts: event.ts ?? new Date().toISOString(),
turnId: str(event.turn_id),
});
}
private applySnapshot(turn: Turn, tool: ToolSnapshot): void {
const node = this.ensureNode(
turn,
tool.tool_use_id,
tool.name,
tool.parent_tool_use_id,
tool.started_at
);
node.input = tool.input;
if (tool.ended_at) {
node.status = tool.is_error ? "error" : "done";
node.endedAt = tool.ended_at;
node.result = tool.content ?? "";
}
}
private onTurnStart({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, str(event.origin));
turn.status = "running";
turn.startedAt = ts;
turn.itemOrigin = str(event.item_origin);
turn.userText = str(event.text) ?? turn.userText;
if (turn.origin === "task") {
this.settleAgents(ts);
}
}
// The report is in: whatever Agent node still runs has finished.
private settleAgents(ts: string): void {
for (const turn of this.turns) {
if (turn.origin === "agent" && turn.status === "running") {
turn.status = "done";
turn.endedAt = ts;
}
for (const node of Object.values(turn.nodes)) {
const agent = SUBAGENT_TOOLS.has(node.name) || node.name === "?";
if (node.status === "running" && node.parent === null && agent) {
node.status = "done";
node.endedAt = ts;
}
}
}
}
// Subagent traffic belongs to the turn that launched the subagent, whatever
// turn the gateway was in when it arrived.
private turnFor(turnId: string, ts: string, parent: string | null): Turn {
if (parent) {
const owner = this.turns.find((turn) => parent in turn.nodes);
if (owner) {
return owner;
}
const current = this.turns.find((turn) => turn.id === turnId);
if (current?.status !== "running") {
return this.ensureTurn(`agent:${parent}`, ts, "agent");
}
}
return this.ensureTurn(turnId, ts, null);
}
private onStream({ event, ts, turnId, parent }: Cursor): void {
const raw = event.event;
if (!(turnId && raw && typeof raw === "object")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
const sdk = raw as Record<string, unknown>;
if (sdk.type === "content_block_start") {
const block = sdk.content_block as Record<string, unknown> | undefined;
if (block?.type === "tool_use" && typeof block.id === "string") {
this.ensureNode(turn, block.id, str(block.name) ?? "?", parent, ts);
} else if (block?.type === "thinking" && parent === null) {
turn.thinking += 1;
}
return;
}
if (sdk.type === "content_block_delta" && parent === null) {
const delta = sdk.delta as Record<string, unknown> | undefined;
const progress = this.conversation?.thinking_is_progress === true;
const text =
delta?.type === "thinking_delta" && progress
? delta.thinking
: delta?.text;
if (typeof text === "string" && text) {
turn.text = joinText(turn.text, text, turn.paused);
turn.paused = false;
}
}
}
private onTool({ event, ts, turnId, parent }: Cursor): void {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
if (parent === null) {
turn.paused = true;
}
const node = this.ensureNode(
turn,
event.tool_use_id,
str(event.name) ?? "?",
parent,
ts
);
node.input = event.input ?? null;
}
private onToolResult({ event, ts, turnId, parent }: Cursor): void {
if (!(turnId && typeof event.tool_use_id === "string")) {
return;
}
const turn = this.turnFor(turnId, ts, parent);
const node = this.ensureNode(turn, event.tool_use_id, "?", parent, ts);
node.status = event.is_error ? "error" : "done";
node.endedAt = ts;
node.result = str(event.content) ?? "";
}
private onResult({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
turn.resultSubtype = str(event.subtype);
if (event.is_error) {
turn.status = "error";
}
}
private onTurnEnd({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, str(event.origin));
this.closeTurn(turn, stopStatus(turn, str(event.stop)), ts);
turn.usage =
event.usage && typeof event.usage === "object"
? (event.usage as TurnUsage)
: null;
}
private onReply({ event, ts, turnId }: Cursor): void {
if (!turnId) {
return;
}
const turn = this.ensureTurn(turnId, ts, null);
turn.userText = str(event.user_text);
const text = str(event.text);
if (text) {
turn.text = text;
}
}
private onSay({ event, ts, turnId }: Cursor): void {
const text = str(event.text);
if (!text) {
return;
}
const turn = turnId ? this.ensureTurn(turnId, ts, null) : this.turns[0];
turn?.says.push(text);
}
private onQuestion({ event }: Cursor): void {
if (typeof event.question_id === "string") {
this.question = {
id: event.question_id,
questions: Array.isArray(event.questions) ? event.questions : [],
};
}
}
private onConversation({ event }: Cursor): void {
if (!this.conversation || event.id !== this.conversation.id) {
return;
}
this.conversation = {
...this.conversation,
...(event as unknown as Partial<ConversationInfo>),
};
}
// The turn's own tool calls die with it; a subagent's keep going until
// their own results arrive.
private closeTurn(turn: Turn, status: TurnStatus, ts: string): void {
turn.status = status;
turn.endedAt = ts;
for (const node of Object.values(turn.nodes)) {
if (node.status === "running" && node.parent === null) {
node.status = "aborted";
node.endedAt = ts;
}
}
}
private ensureTurn(id: string, ts: string, origin: string | null): Turn {
const existing = this.turns.find((t) => t.id === id);
if (existing) {
if (origin && existing.origin === "?") {
existing.origin = origin;
}
return existing;
}
const turn: Turn = {
endedAt: null,
id,
itemOrigin: null,
nodes: {},
origin: origin ?? "?",
paused: false,
resultSubtype: null,
roots: [],
says: [],
startedAt: ts,
status: "running",
text: "",
thinking: 0,
usage: null,
userText: null,
};
this.turns.unshift(turn);
if (this.turns.length > MAX_TURNS) {
this.turns.length = MAX_TURNS;
}
return this.turns[0];
}
private ensureNode(
turn: Turn,
id: string,
name: string,
parent: string | null,
ts: string
): ToolNode {
const existing = turn.nodes[id];
if (existing) {
if (existing.name === "?" && name !== "?") {
existing.name = name;
}
return existing;
}
turn.nodes[id] = {
children: [],
endedAt: null,
id,
input: null,
name,
parent,
result: "",
startedAt: ts,
status: "running",
};
if (parent) {
this.ensureNode(turn, parent, "?", null, ts).children.push(id);
} else {
turn.roots.push(id);
}
return turn.nodes[id];
}
}
function pick(input: unknown, ...keys: string[]): string | null {
if (!input || typeof input !== "object") {
return null;
}
const record = input as Record<string, unknown>;
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value;
}
}
return null;
}
const WHITESPACE = /\s+/g;
const KEYS_BY_TOOL: Record<string, string[]> = {
Agent: ["description", "prompt"],
Bash: ["command", "description"],
Edit: ["file_path"],
Glob: ["pattern"],
Grep: ["pattern"],
MultiEdit: ["file_path"],
NotebookEdit: ["notebook_path"],
Read: ["file_path", "notebook_path"],
Skill: ["skill"],
Task: ["description", "prompt"],
WebFetch: ["url"],
WebSearch: ["query"],
Write: ["file_path"],
};
export const SUBAGENT_TOOLS = new Set(["Task", "Agent"]);
export function summarizeInput(name: string, input: unknown): string {
if (input === null || input === undefined) {
return "";
}
const keys =
KEYS_BY_TOOL[name] ??
(name.startsWith("mcp__gateway__") ? ["text", "title", "id"] : []);
let summary = keys.length > 0 ? pick(input, ...keys) : null;
if (summary === null && typeof input === "object") {
const first = Object.values(input as Record<string, unknown>).find(
(value) => typeof value === "string" && value.trim()
);
summary = typeof first === "string" ? first : JSON.stringify(input);
}
return (summary ?? String(input)).replace(WHITESPACE, " ").trim();
}
export function toolLabel(name: string): string {
return name.startsWith("mcp__") ? name.slice(5).replace("__", " · ") : name;
}
// How a turn was started, as a word for the header.
export const ORIGIN_LABELS: Record<string, string> = {
agent: "subagent at work",
inject: "inject",
task: "subagent report",
user: "user",
};