479 lines
13 KiB
TypeScript
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",
|
|
};
|