diff --git a/.changeset/delegation-tree-interface-panel.md b/.changeset/delegation-tree-interface-panel.md
new file mode 100644
index 00000000..695ef1ba
--- /dev/null
+++ b/.changeset/delegation-tree-interface-panel.md
@@ -0,0 +1,5 @@
+---
+"@perstack/tui-components": patch
+---
+
+Add delegation tree to InterfacePanel for real-time expert visualization
diff --git a/packages/tui-components/src/execution/app.tsx b/packages/tui-components/src/execution/app.tsx
index 422fa0a1..762097c1 100644
--- a/packages/tui-components/src/execution/app.tsx
+++ b/packages/tui-components/src/execution/app.tsx
@@ -68,10 +68,13 @@ export const ExecutionApp = (props: ExecutionAppProps) => {
}}
)
diff --git a/packages/tui-components/src/execution/components/activity-log-panel.tsx b/packages/tui-components/src/execution/components/activity-log-panel.tsx
index f99bdf5a..2e657126 100644
--- a/packages/tui-components/src/execution/components/activity-log-panel.tsx
+++ b/packages/tui-components/src/execution/components/activity-log-panel.tsx
@@ -32,7 +32,7 @@ export const ActivityLogItem = ({ activity }: ActivityLogItemProps): React.React
if (delegatedBy) {
return (
-
+
[{expertKey}]
diff --git a/packages/tui-components/src/execution/components/delegation-tree.tsx b/packages/tui-components/src/execution/components/delegation-tree.tsx
new file mode 100644
index 00000000..a7d379db
--- /dev/null
+++ b/packages/tui-components/src/execution/components/delegation-tree.tsx
@@ -0,0 +1,98 @@
+import { Text } from "ink"
+import type React from "react"
+import { colors } from "../../colors.js"
+import { USAGE_INDICATORS } from "../../constants.js"
+import type { DelegationTreeState, FlatTreeNode } from "../hooks/use-delegation-tree.js"
+import { flattenTree } from "../hooks/use-delegation-tree.js"
+import { useSpinner } from "../hooks/use-spinner.js"
+
+function getUsageIcon(percent: number): string {
+ if (percent <= 25) return USAGE_INDICATORS.LOW
+ if (percent <= 50) return USAGE_INDICATORS.MEDIUM
+ if (percent <= 75) return USAGE_INDICATORS.HIGH
+ return USAGE_INDICATORS.FULL
+}
+
+function buildPrefix(flatNode: FlatTreeNode): string {
+ if (flatNode.depth === 0) return ""
+
+ let prefix = ""
+ for (let i = 1; i < flatNode.ancestorIsLast.length; i++) {
+ prefix += flatNode.ancestorIsLast[i] ? " " : "│ "
+ }
+ prefix += flatNode.isLast ? "└ " : "├ "
+ return prefix
+}
+
+function TreeNodeLine({
+ flatNode,
+ spinner,
+}: {
+ flatNode: FlatTreeNode
+ spinner: string
+}): React.ReactNode {
+ const { node } = flatNode
+ const prefix = buildPrefix(flatNode)
+
+ let indicator: React.ReactNode
+ switch (node.status) {
+ case "running":
+ indicator = {spinner}
+ break
+ case "suspending":
+ indicator = ⏸
+ break
+ case "completed":
+ indicator = ✓
+ break
+ case "error":
+ indicator = ✗
+ break
+ }
+
+ const usagePercent = (node.contextWindowUsage * 100).toFixed(1)
+ const usageIcon = getUsageIcon(node.contextWindowUsage * 100)
+ const showUsage = node.status !== "completed"
+
+ return (
+
+ {prefix}
+ {indicator}
+
+ {node.expertName}
+ :
+ {node.actionLabel}
+ {node.actionFileArg ? {node.actionFileArg} : null}
+ {showUsage ? (
+ <>
+ ·
+
+ {usageIcon} {usagePercent}%
+
+ >
+ ) : null}
+
+ )
+}
+
+type DelegationTreeProps = {
+ state: DelegationTreeState
+}
+
+export const DelegationTree = ({ state }: DelegationTreeProps): React.ReactNode => {
+ const flatNodes = flattenTree(state)
+ const hasRunning = flatNodes.some(
+ (n) => n.node.status === "running" || n.node.status === "suspending",
+ )
+ const spinner = useSpinner({ isActive: hasRunning })
+
+ if (flatNodes.length === 0) return null
+
+ return (
+ <>
+ {flatNodes.map((flatNode) => (
+
+ ))}
+ >
+ )
+}
diff --git a/packages/tui-components/src/execution/components/index.ts b/packages/tui-components/src/execution/components/index.ts
index 7c4807b9..d4278d4d 100644
--- a/packages/tui-components/src/execution/components/index.ts
+++ b/packages/tui-components/src/execution/components/index.ts
@@ -1,2 +1,3 @@
export { ActivityLogItem } from "./activity-log-panel.js"
+export { DelegationTree } from "./delegation-tree.js"
export { InterfacePanel } from "./interface-panel.js"
diff --git a/packages/tui-components/src/execution/components/interface-panel.tsx b/packages/tui-components/src/execution/components/interface-panel.tsx
index 1cca47fc..1f1b9f69 100644
--- a/packages/tui-components/src/execution/components/interface-panel.tsx
+++ b/packages/tui-components/src/execution/components/interface-panel.tsx
@@ -1,38 +1,30 @@
-import type { StreamingState } from "@perstack/react"
import { Box, Text, useInput } from "ink"
import type React from "react"
import { colors } from "../../colors.js"
-import { USAGE_INDICATORS } from "../../constants.js"
import { useTextInput } from "../../hooks/use-text-input.js"
-import type { RuntimeInfo } from "../../types/index.js"
+import type { DelegationTreeState } from "../hooks/use-delegation-tree.js"
import type { RunStatus } from "../hooks/use-execution-state.js"
-import { useSpinner } from "../hooks/use-spinner.js"
-import { useStreamingPhase } from "../hooks/use-streaming-phase.js"
+import { DelegationTree } from "./delegation-tree.js"
type InterfacePanelProps = {
- runtimeInfo: RuntimeInfo
+ query: string | undefined
runStatus: RunStatus
- streaming: StreamingState
onSubmit: (query: string) => void
-}
-
-function getUsageIcon(percent: number): string {
- if (percent <= 25) return USAGE_INDICATORS.LOW
- if (percent <= 50) return USAGE_INDICATORS.MEDIUM
- if (percent <= 75) return USAGE_INDICATORS.HIGH
- return USAGE_INDICATORS.FULL
+ delegationTreeState: DelegationTreeState
+ inProgressCount: number
+ formattedTotalTokens: string
+ elapsedTime: string
}
export const InterfacePanel = ({
- runtimeInfo,
+ query,
runStatus,
- streaming,
onSubmit,
+ delegationTreeState,
+ inProgressCount,
+ formattedTotalTokens,
+ elapsedTime,
}: InterfacePanelProps): React.ReactNode => {
- const streamingPhase = useStreamingPhase(streaming)
- const isSpinnerActive = runStatus === "running"
- const spinner = useSpinner({ isActive: isSpinnerActive })
-
const { input, handleInput } = useTextInput({
onSubmit,
canSubmit: runStatus !== "running",
@@ -40,25 +32,7 @@ export const InterfacePanel = ({
useInput(handleInput)
- // Derive status label
- let statusLabel: React.ReactNode
- if (runStatus === "waiting") {
- statusLabel = Waiting for query...
- } else if (runStatus === "completed") {
- statusLabel = Completed
- } else if (runStatus === "stopped") {
- statusLabel = Stopped
- } else if (streamingPhase === "reasoning") {
- statusLabel = Streaming Reasoning...
- } else if (streamingPhase === "generating") {
- statusLabel = Streaming Generation...
- } else {
- statusLabel = Running...
- }
-
- const step = runtimeInfo.currentStep !== undefined ? String(runtimeInfo.currentStep) : "–"
- const usagePercent = (runtimeInfo.contextWindowUsage * 100).toFixed(1)
- const usageIcon = getUsageIcon(runtimeInfo.contextWindowUsage * 100)
+ const isWaiting = runStatus === "waiting"
return (
-
- {spinner ? {spinner} : null}
- {statusLabel}
- ┃
- ↻
- {step}
- ┃
- {usageIcon}
- {usagePercent}%
-
+ {isWaiting ? (
+ Waiting for query...
+ ) : (
+ <>
+
+ Query:
+ {query || "–"}
+
+
+ {inProgressCount} in progress experts
+ ·
+ {elapsedTime}
+ ·
+ {formattedTotalTokens} tokens
+
+ >
+ )}
+
>
{input}
diff --git a/packages/tui-components/src/execution/hooks/index.ts b/packages/tui-components/src/execution/hooks/index.ts
index 3b283eb1..3025ec99 100644
--- a/packages/tui-components/src/execution/hooks/index.ts
+++ b/packages/tui-components/src/execution/hooks/index.ts
@@ -1,3 +1,11 @@
+export {
+ type DelegationTreeNode,
+ type DelegationTreeState,
+ type ExpertRunStatus,
+ type FlatTreeNode,
+ useDelegationTree,
+} from "./use-delegation-tree.js"
+export { useElapsedTime } from "./use-elapsed-time.js"
export {
type ExecutionState,
type LogEntry,
@@ -6,4 +14,3 @@ export {
useExecutionState,
} from "./use-execution-state.js"
export { useSpinner } from "./use-spinner.js"
-export { type StreamingPhase, useStreamingPhase } from "./use-streaming-phase.js"
diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.test.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.test.ts
new file mode 100644
index 00000000..b8e877de
--- /dev/null
+++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.test.ts
@@ -0,0 +1,1230 @@
+import type { Checkpoint, PerstackEvent, ToolCall, Usage } from "@perstack/core"
+import { describe, expect, it } from "vitest"
+import {
+ createInitialDelegationTreeState,
+ deriveActionLabel,
+ flattenTree,
+ formatTokenCount,
+ getInProgressCount,
+ processDelegationTreeEvent,
+ resolveRunId,
+} from "./use-delegation-tree.js"
+import { formatElapsedTime } from "./use-elapsed-time.js"
+
+// --- Helpers ---
+
+const baseUsage: Usage = {
+ inputTokens: 0,
+ outputTokens: 0,
+ reasoningTokens: 0,
+ totalTokens: 0,
+ cachedInputTokens: 0,
+}
+
+function makeCheckpoint(overrides: Partial = {}): Checkpoint {
+ return {
+ id: "cp-1",
+ jobId: "job-1",
+ runId: "run-1",
+ status: "proceeding",
+ stepNumber: 1,
+ messages: [],
+ expert: { key: "test@1.0.0", name: "test", version: "1.0.0" },
+ usage: { ...baseUsage },
+ ...overrides,
+ }
+}
+
+let eventCounter = 0
+
+function makeRunEvent(
+ type: string,
+ runId: string,
+ expertKey: string,
+ payload: Record,
+): PerstackEvent {
+ eventCounter++
+ return {
+ id: `evt-${eventCounter}`,
+ type,
+ expertKey,
+ timestamp: 1000000,
+ jobId: "job-1",
+ runId,
+ stepNumber: 1,
+ ...payload,
+ } as PerstackEvent
+}
+
+function makeRuntimeEvent(type: string, payload: Record): PerstackEvent {
+ eventCounter++
+ return {
+ id: `evt-${eventCounter}`,
+ type,
+ timestamp: 1000000,
+ jobId: "job-1",
+ runId: "runtime-run-1",
+ ...payload,
+ } as PerstackEvent
+}
+
+function makeToolCall(toolName: string, args: Record = {}): ToolCall {
+ return { id: `tc-${Date.now()}`, skillName: "@perstack/base", toolName, args }
+}
+
+// --- Tests ---
+
+describe("formatTokenCount", () => {
+ it("formats zero", () => {
+ expect(formatTokenCount(0)).toBe("0")
+ })
+
+ it("formats small numbers", () => {
+ expect(formatTokenCount(500)).toBe("500")
+ })
+
+ it("formats thousands", () => {
+ expect(formatTokenCount(1000)).toBe("1.0k")
+ })
+
+ it("formats thousands with decimals", () => {
+ expect(formatTokenCount(22500)).toBe("22.5k")
+ })
+
+ it("formats millions", () => {
+ expect(formatTokenCount(1500000)).toBe("1.5M")
+ })
+})
+
+describe("formatElapsedTime", () => {
+ it("formats zero", () => {
+ expect(formatElapsedTime(0)).toBe("0s")
+ })
+
+ it("formats seconds", () => {
+ expect(formatElapsedTime(5000)).toBe("5s")
+ })
+
+ it("formats minutes and seconds", () => {
+ expect(formatElapsedTime(65000)).toBe("1m 05s")
+ })
+
+ it("formats large durations", () => {
+ expect(formatElapsedTime(3615000)).toBe("60m 15s")
+ })
+})
+
+describe("deriveActionLabel", () => {
+ it("returns Processing for empty tool calls", () => {
+ expect(deriveActionLabel([])).toEqual({ label: "Processing", fileArg: undefined })
+ })
+
+ it("extracts file path for readTextFile", () => {
+ const result = deriveActionLabel([makeToolCall("readTextFile", { path: "src/index.ts" })])
+ expect(result.label).toBe("Read Text File")
+ expect(result.fileArg).toBe("src/index.ts")
+ })
+
+ it("extracts file path for writeTextFile", () => {
+ const result = deriveActionLabel([
+ makeToolCall("writeTextFile", { path: "hoge.txt", text: "content" }),
+ ])
+ expect(result.label).toBe("Write Text File")
+ expect(result.fileArg).toBe("hoge.txt")
+ })
+
+ it("extracts command for exec", () => {
+ const result = deriveActionLabel([makeToolCall("exec", { command: "ls -la" })])
+ expect(result.label).toBe("Exec")
+ expect(result.fileArg).toBe("ls -la")
+ })
+
+ it("handles general tools without file arg", () => {
+ const result = deriveActionLabel([makeToolCall("addDelegate", { targetExpertKey: "foo" })])
+ expect(result.label).toBe("Add Delegate")
+ expect(result.fileArg).toBeUndefined()
+ })
+
+ it("handles snake_case tool names", () => {
+ const tc: ToolCall = {
+ id: "tc-1",
+ skillName: "my-skill",
+ toolName: "web_search",
+ args: { query: "test" },
+ }
+ const result = deriveActionLabel([tc])
+ expect(result.label).toBe("Web Search")
+ expect(result.fileArg).toBeUndefined()
+ })
+
+ it("shows count for multiple tool calls", () => {
+ const result = deriveActionLabel([
+ makeToolCall("readTextFile", { path: "a.txt" }),
+ makeToolCall("readTextFile", { path: "b.txt" }),
+ ])
+ expect(result.label).toBe("2 Tool Calls")
+ expect(result.fileArg).toBeUndefined()
+ })
+})
+
+describe("processDelegationTreeEvent", () => {
+ describe("single run lifecycle", () => {
+ it("handles initializeRuntime", () => {
+ const state = createInitialDelegationTreeState()
+ const event = makeRuntimeEvent("initializeRuntime", {
+ runtimeVersion: "1.0.0",
+ expertName: "test",
+ experts: [],
+ model: "gpt-4",
+ maxRetries: 3,
+ timeout: 30000,
+ query: "Hello world",
+ })
+ const changed = processDelegationTreeEvent(state, event)
+ expect(changed).toBe(true)
+ expect(state.jobStartedAt).toBe(1000000)
+ })
+
+ it("does not overwrite jobStartedAt on subsequent initializeRuntime events", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRuntimeEvent("initializeRuntime", {
+ runtimeVersion: "1.0.0",
+ expertName: "root",
+ experts: [],
+ model: "gpt-4",
+ maxRetries: 3,
+ timeout: 30000,
+ query: "Hello",
+ }),
+ )
+ expect(state.jobStartedAt).toBe(1000000)
+ // Second initializeRuntime from a delegate should not overwrite
+ processDelegationTreeEvent(state, {
+ id: "evt-99",
+ type: "initializeRuntime",
+ timestamp: 2000000,
+ jobId: "job-1",
+ runId: "delegate-run-1",
+ runtimeVersion: "1.0.0",
+ expertName: "delegate",
+ experts: [],
+ model: "gpt-4",
+ maxRetries: 3,
+ timeout: 30000,
+ query: "Delegated query",
+ } as PerstackEvent)
+ expect(state.jobStartedAt).toBe(1000000)
+ })
+
+ it("handles startRun for root expert", () => {
+ const state = createInitialDelegationTreeState()
+ const event = makeRunEvent("startRun", "run-1", "coordinator@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-1",
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ contextWindowUsage: 0.05,
+ }),
+ inputMessages: [],
+ })
+ const changed = processDelegationTreeEvent(state, event)
+ expect(changed).toBe(true)
+ expect(state.rootRunId).toBe("run-1")
+ expect(state.nodes.size).toBe(1)
+ const node = state.nodes.get("run-1")!
+ expect(node.expertName).toBe("coordinator")
+ expect(node.status).toBe("running")
+ expect(node.actionLabel).toBe("Starting...")
+ expect(node.contextWindowUsage).toBe(0.05)
+ expect(node.parentRunId).toBeUndefined()
+ })
+
+ it("handles callTools", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ const changed = processDelegationTreeEvent(
+ state,
+ makeRunEvent("callTools", "run-1", "test@1.0.0", {
+ newMessage: {},
+ toolCalls: [makeToolCall("readTextFile", { path: "foo.txt" })],
+ usage: { ...baseUsage, totalTokens: 1000 },
+ }),
+ )
+ expect(changed).toBe(true)
+ const node = state.nodes.get("run-1")!
+ expect(node.actionLabel).toBe("Read Text File")
+ expect(node.actionFileArg).toBe("foo.txt")
+ expect(node.totalTokens).toBe(1000)
+ expect(state.jobTotalTokens).toBe(1000)
+ })
+
+ it("handles completeRun", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ const changed = processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1", contextWindowUsage: 0.5 }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 500 },
+ }),
+ )
+ expect(changed).toBe(true)
+ const node = state.nodes.get("run-1")!
+ expect(node.status).toBe("completed")
+ expect(node.actionLabel).toBe("Completed")
+ expect(node.totalTokens).toBe(500)
+ expect(node.contextWindowUsage).toBe(0.5)
+ })
+ })
+
+ describe("delegation lifecycle", () => {
+ function setupDelegation() {
+ const state = createInitialDelegationTreeState()
+ // Root starts
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "coordinator@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-root",
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // Root calls tools and stops by delegate
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("callTools", "run-root", "coordinator@1.0.0", {
+ newMessage: {},
+ toolCalls: [makeToolCall("addDelegate", { targetExpertKey: "worker" })],
+ usage: { ...baseUsage, totalTokens: 200 },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-root", "coordinator@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root", contextWindowUsage: 0.1 }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ return state
+ }
+
+ it("creates child node with parentRunId", () => {
+ const state = setupDelegation()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-child", "worker@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-child",
+ expert: { key: "worker@1.0.0", name: "worker", version: "1.0.0" },
+ delegatedBy: {
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ expect(state.nodes.size).toBe(2)
+ const child = state.nodes.get("run-child")!
+ expect(child.parentRunId).toBe("run-root")
+ expect(child.expertName).toBe("worker")
+ const root = state.nodes.get("run-root")!
+ expect(root.childRunIds).toContain("run-child")
+ expect(root.status).toBe("suspending")
+ })
+
+ it("handles parallel delegation", () => {
+ const state = setupDelegation()
+ // Two children start
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-child-1", "worker@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-child-1",
+ expert: { key: "worker@1.0.0", name: "worker", version: "1.0.0" },
+ delegatedBy: {
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-child-2", "helper@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-child-2",
+ expert: { key: "helper@1.0.0", name: "helper", version: "1.0.0" },
+ delegatedBy: {
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ const root = state.nodes.get("run-root")!
+ expect(root.childRunIds).toEqual(["run-child-1", "run-child-2"])
+ expect(state.nodes.size).toBe(3)
+ })
+ })
+
+ describe("nested delegation", () => {
+ it("handles nested child with resolved parent", () => {
+ const state = createInitialDelegationTreeState()
+ // Root
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-a", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-a" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-a", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-a" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // Child B
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-b", "mid@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-b",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-a",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-b", "mid@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-b" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // Grandchild C
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-c", "leaf@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-c",
+ delegatedBy: {
+ expert: { key: "mid@1.0.0", name: "mid", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-2",
+ runId: "run-b",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ const nodeC = state.nodes.get("run-c")!
+ expect(nodeC.parentRunId).toBe("run-b")
+ const nodeB = state.nodes.get("run-b")!
+ expect(nodeB.childRunIds).toContain("run-c")
+ expect(nodeB.parentRunId).toBe("run-a")
+ })
+ })
+
+ describe("error handling", () => {
+ it("handles stopRunByError", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByError", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ error: { name: "Error", message: "Something went wrong", isRetryable: false },
+ }),
+ )
+ const node = state.nodes.get("run-1")!
+ expect(node.status).toBe("error")
+ expect(node.actionLabel).toBe("Something went wrong")
+ })
+ })
+
+ describe("runId aliasing", () => {
+ it("aliases new runId to original on resumeFromStop", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // Resume with new runId
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-1-resumed", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ }),
+ )
+ expect(state.runIdAliases.get("run-1-resumed")).toBe("run-1")
+ expect(resolveRunId(state, "run-1-resumed")).toBe("run-1")
+ const node = state.nodes.get("run-1")!
+ expect(node.status).toBe("running")
+ })
+
+ it("resolves events with aliased runId", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-1-v2", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ }),
+ )
+ // Event with the aliased runId should update the original node
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("callTools", "run-1-v2", "test@1.0.0", {
+ newMessage: {},
+ toolCalls: [makeToolCall("readTextFile", { path: "test.txt" })],
+ usage: { ...baseUsage, totalTokens: 300 },
+ }),
+ )
+ const node = state.nodes.get("run-1")!
+ expect(node.actionLabel).toBe("Read Text File")
+ expect(node.totalTokens).toBe(300)
+ })
+
+ it("chains aliases across multiple resumes", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ // First stop + resume
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-1-v2", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ }),
+ )
+ // Second stop + resume (checkpoint.runId is now run-1-v2)
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1-v2", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1-v2" }),
+ step: { stepNumber: 2, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-1-v3", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1-v2" }),
+ }),
+ )
+ expect(resolveRunId(state, "run-1-v3")).toBe("run-1")
+ expect(resolveRunId(state, "run-1-v2")).toBe("run-1")
+ })
+
+ it("falls back to expertKey lookup when checkpoint.runId equals event.runId", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // Runtime sets checkpoint.runId = event.runId (new runId on both)
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-1-v2", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1-v2" }),
+ }),
+ )
+ // Should still alias via expertKey fallback
+ expect(state.runIdAliases.get("run-1-v2")).toBe("run-1")
+ expect(resolveRunId(state, "run-1-v2")).toBe("run-1")
+ const node = state.nodes.get("run-1")!
+ expect(node.status).toBe("running")
+ })
+
+ it("links children correctly after expertKey-based resume", () => {
+ const state = createInitialDelegationTreeState()
+ // Root
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "root-1", "coordinator@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "root-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "root-1", "coordinator@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "root-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // First child completes
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "child-1", "worker-a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "child-1",
+ delegatedBy: {
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "root-1",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "child-1", "worker-a@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "child-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ // Root resumes with checkpoint.runId == event.runId (real runtime behavior)
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "root-2", "coordinator@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "root-2" }),
+ }),
+ )
+ // Root delegates again and stops
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "root-2", "coordinator@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "root-2" }),
+ step: { stepNumber: 2, newMessages: [], usage: baseUsage },
+ }),
+ )
+ // Second child starts with delegatedBy.runId = root-2 (parent's resumed runId)
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "child-2", "worker-b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "child-2",
+ delegatedBy: {
+ expert: { key: "coordinator@1.0.0", name: "coordinator", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-2",
+ runId: "root-2",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // child-2 should be linked to root node
+ const root = state.nodes.get("root-1")!
+ expect(root.childRunIds).toContain("child-2")
+ // Tree should show child-2 under root (not as orphan)
+ const flat = flattenTree(state)
+ const child2Flat = flat.find((f) => f.node.runId === "child-2")
+ expect(child2Flat).toBeDefined()
+ expect(child2Flat!.depth).toBe(1)
+ })
+ })
+
+ describe("streaming events", () => {
+ it("updates action label on startStreamingReasoning", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startStreamingReasoning", "run-1", "test@1.0.0", {}),
+ )
+ expect(state.nodes.get("run-1")!.actionLabel).toBe("Streaming Reasoning...")
+ })
+
+ it("updates action label on startStreamingRunResult", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startStreamingRunResult", "run-1", "test@1.0.0", {}),
+ )
+ expect(state.nodes.get("run-1")!.actionLabel).toBe("Streaming Generation...")
+ })
+ })
+
+ describe("continueToNextStep", () => {
+ it("updates contextWindowUsage from nextCheckpoint", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "test@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1", contextWindowUsage: 0.1 }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("continueToNextStep", "run-1", "test@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1", contextWindowUsage: 0.1 }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ nextCheckpoint: makeCheckpoint({ runId: "run-1", contextWindowUsage: 0.25 }),
+ }),
+ )
+ expect(state.nodes.get("run-1")!.contextWindowUsage).toBe(0.25)
+ })
+ })
+})
+
+describe("flattenTree", () => {
+ it("returns empty array for no root", () => {
+ const state = createInitialDelegationTreeState()
+ expect(flattenTree(state)).toEqual([])
+ })
+
+ it("returns single root", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(1)
+ expect(flat[0]!.depth).toBe(0)
+ expect(flat[0]!.isLast).toBe(true)
+ expect(flat[0]!.node.runId).toBe("run-1")
+ })
+
+ it("flattens root with children", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-root", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-a", "worker-a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-a",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-b", "worker-b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-b",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(3)
+ expect(flat[0]!.depth).toBe(0)
+ expect(flat[1]!.depth).toBe(1)
+ expect(flat[1]!.isLast).toBe(false)
+ expect(flat[2]!.depth).toBe(1)
+ expect(flat[2]!.isLast).toBe(true)
+ })
+
+ it("prunes children of completed nodes", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-root", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-child", "worker@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-child",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // Complete the root
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("resumeFromStop", "run-root-v2", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root" }),
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-root-v2", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root-v2" }),
+ step: { stepNumber: 2, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ const flat = flattenTree(state)
+ // Root is completed, so its children should be pruned
+ expect(flat).toHaveLength(1)
+ expect(flat[0]!.node.runId).toBe("run-root")
+ })
+
+ it("prunes children when all delegates are completed", () => {
+ const state = createInitialDelegationTreeState()
+ // Root suspending with two children
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-root", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-a", "worker-a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-a",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-b", "worker-b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-b",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // Both children still running: should show all 3
+ expect(flattenTree(state)).toHaveLength(3)
+
+ // Complete both children
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-a", "worker-a@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-a" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-b", "worker-b@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-b" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ // All delegates completed: children should be pruned, only root remains
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(1)
+ expect(flat[0]!.node.runId).toBe("run-root")
+ })
+
+ it("does not prune children when some delegates are still running", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-root", "root@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-root" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-a", "worker-a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-a",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-b", "worker-b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-b",
+ delegatedBy: {
+ expert: { key: "root@1.0.0", name: "root", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-root",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // Complete only one child
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-a", "worker-a@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-a" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ // One still running: completed sibling pruned, only root + running child shown
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(2)
+ expect(flat[0]!.node.runId).toBe("run-root")
+ expect(flat[1]!.node.runId).toBe("run-b")
+ expect(flat[1]!.isLast).toBe(true)
+ })
+
+ it("shows orphaned non-completed nodes not reachable from root", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ // Manually add an orphan node (not linked to root)
+ state.nodes.set("orphan-1", {
+ runId: "orphan-1",
+ expertName: "orphan",
+ expertKey: "orphan@1.0.0",
+ status: "running",
+ actionLabel: "Processing",
+ actionFileArg: undefined,
+ contextWindowUsage: 0,
+ parentRunId: "nonexistent-parent",
+ childRunIds: [],
+ totalTokens: 0,
+ })
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(2)
+ expect(flat[1]!.node.runId).toBe("orphan-1")
+ expect(flat[1]!.depth).toBe(0)
+ })
+
+ it("does not show orphaned completed nodes", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-root", "root@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-root" }),
+ inputMessages: [],
+ }),
+ )
+ // Manually add a completed orphan node
+ state.nodes.set("orphan-1", {
+ runId: "orphan-1",
+ expertName: "orphan",
+ expertKey: "orphan@1.0.0",
+ status: "completed",
+ actionLabel: "Completed",
+ actionFileArg: undefined,
+ contextWindowUsage: 0,
+ parentRunId: "nonexistent-parent",
+ childRunIds: [],
+ totalTokens: 0,
+ })
+ const flat = flattenTree(state)
+ // Only root, orphan completed node should not appear
+ expect(flat).toHaveLength(1)
+ expect(flat[0]!.node.runId).toBe("run-root")
+ })
+
+ it("handles deep nesting", () => {
+ const state = createInitialDelegationTreeState()
+ // A -> B -> C
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "a", "a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "a" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "a", "a@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "a" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "b", "b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "b",
+ delegatedBy: {
+ expert: { key: "a@1.0.0", name: "a", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "a",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "b", "b@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "b" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "c", "c@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "c",
+ delegatedBy: {
+ expert: { key: "b@1.0.0", name: "b", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-2",
+ runId: "b",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ const flat = flattenTree(state)
+ expect(flat).toHaveLength(3)
+ expect(flat[0]!.depth).toBe(0)
+ expect(flat[1]!.depth).toBe(1)
+ expect(flat[2]!.depth).toBe(2)
+ // Check ancestor tracking for tree drawing
+ expect(flat[2]!.ancestorIsLast).toEqual([true, true])
+ })
+})
+
+describe("getInProgressCount", () => {
+ it("counts running and suspending nodes", () => {
+ const state = createInitialDelegationTreeState()
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-1", "a@1.0.0", {
+ initialCheckpoint: makeCheckpoint({ runId: "run-1" }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("stopRunByDelegate", "run-1", "a@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-1" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-2", "b@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-2",
+ delegatedBy: {
+ expert: { key: "a@1.0.0", name: "a", version: "1.0.0" },
+ toolCallId: "tc-1",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-1",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("startRun", "run-3", "c@1.0.0", {
+ initialCheckpoint: makeCheckpoint({
+ runId: "run-3",
+ delegatedBy: {
+ expert: { key: "a@1.0.0", name: "a", version: "1.0.0" },
+ toolCallId: "tc-2",
+ toolName: "delegate",
+ checkpointId: "cp-1",
+ runId: "run-1",
+ },
+ }),
+ inputMessages: [],
+ }),
+ )
+ // Complete one child
+ processDelegationTreeEvent(
+ state,
+ makeRunEvent("completeRun", "run-2", "b@1.0.0", {
+ checkpoint: makeCheckpoint({ runId: "run-2" }),
+ step: { stepNumber: 1, newMessages: [], usage: baseUsage },
+ text: "Done",
+ usage: { ...baseUsage, totalTokens: 100 },
+ }),
+ )
+ // run-1 = suspending, run-2 = completed, run-3 = running
+ expect(getInProgressCount(state)).toBe(2)
+ })
+})
diff --git a/packages/tui-components/src/execution/hooks/use-delegation-tree.ts b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts
new file mode 100644
index 00000000..0feacadb
--- /dev/null
+++ b/packages/tui-components/src/execution/hooks/use-delegation-tree.ts
@@ -0,0 +1,399 @@
+import type { PerstackEvent, ToolCall } from "@perstack/core"
+import { parseExpertKey } from "@perstack/core"
+import { useCallback, useRef, useState } from "react"
+import { UI_CONSTANTS } from "../../constants.js"
+import { shortenPath, truncateText } from "../../helpers.js"
+
+// --- Types ---
+
+export type ExpertRunStatus = "running" | "suspending" | "completed" | "error"
+
+export type DelegationTreeNode = {
+ runId: string
+ expertName: string
+ expertKey: string
+ status: ExpertRunStatus
+ actionLabel: string
+ actionFileArg: string | undefined
+ contextWindowUsage: number
+ parentRunId: string | undefined
+ childRunIds: string[]
+ totalTokens: number
+}
+
+export type DelegationTreeState = {
+ nodes: Map
+ rootRunId: string | undefined
+ jobTotalTokens: number
+ jobStartedAt: number | undefined
+ runIdAliases: Map
+}
+
+export type FlatTreeNode = {
+ node: DelegationTreeNode
+ depth: number
+ isLast: boolean
+ ancestorIsLast: boolean[]
+}
+
+// --- Pure Functions ---
+
+export function createInitialDelegationTreeState(): DelegationTreeState {
+ return {
+ nodes: new Map(),
+ rootRunId: undefined,
+ jobTotalTokens: 0,
+ jobStartedAt: undefined,
+ runIdAliases: new Map(),
+ }
+}
+
+export function resolveRunId(state: DelegationTreeState, runId: string): string {
+ let current = runId
+ const visited = new Set()
+ while (state.runIdAliases.has(current)) {
+ if (visited.has(current)) break
+ visited.add(current)
+ current = state.runIdAliases.get(current)!
+ }
+ return current
+}
+
+function formatToolName(name: string): string {
+ if (name.includes("_")) {
+ return name
+ .split("_")
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
+ .join(" ")
+ }
+ return name
+ .replace(/([A-Z])/g, " $1")
+ .replace(/^./, (s) => s.toUpperCase())
+ .trim()
+}
+
+const FILE_ARG_TOOLS = new Set([
+ "readTextFile",
+ "writeTextFile",
+ "editTextFile",
+ "readImageFile",
+ "readPdfFile",
+])
+
+export function deriveActionLabel(toolCalls: ToolCall[]): {
+ label: string
+ fileArg: string | undefined
+} {
+ if (toolCalls.length === 0) return { label: "Processing", fileArg: undefined }
+ if (toolCalls.length > 1) return { label: `${toolCalls.length} Tool Calls`, fileArg: undefined }
+
+ const tc = toolCalls[0]!
+ const label = formatToolName(tc.toolName)
+
+ let fileArg: string | undefined
+ if (FILE_ARG_TOOLS.has(tc.toolName) && typeof tc.args.path === "string") {
+ fileArg = shortenPath(tc.args.path)
+ } else if (tc.toolName === "exec" && typeof tc.args.command === "string") {
+ fileArg = truncateText(tc.args.command, UI_CONSTANTS.TRUNCATE_TEXT_SHORT)
+ }
+
+ return { label, fileArg }
+}
+
+export function formatTokenCount(n: number): string {
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`
+ return String(n)
+}
+
+export function getInProgressCount(state: DelegationTreeState): number {
+ let count = 0
+ for (const node of state.nodes.values()) {
+ if (node.status === "running" || node.status === "suspending") count++
+ }
+ return count
+}
+
+export function flattenTree(state: DelegationTreeState): FlatTreeNode[] {
+ if (!state.rootRunId) return []
+ const root = state.nodes.get(state.rootRunId)
+ if (!root) return []
+
+ const result: FlatTreeNode[] = []
+ const visited = new Set()
+
+ function markSubtreeVisited(nodeId: string) {
+ visited.add(nodeId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ for (const childId of node.childRunIds) {
+ if (!visited.has(childId)) markSubtreeVisited(childId)
+ }
+ }
+ }
+
+ function dfs(nodeId: string, depth: number, isLast: boolean, ancestorIsLast: boolean[]) {
+ const node = state.nodes.get(nodeId)
+ if (!node) return
+
+ visited.add(nodeId)
+ result.push({ node, depth, isLast, ancestorIsLast: [...ancestorIsLast] })
+
+ const children = node.childRunIds
+
+ // Pruning: skip entire subtree of completed nodes
+ if (node.status === "completed") {
+ for (const childId of children) markSubtreeVisited(childId)
+ return
+ }
+
+ // Filter out individually completed children; keep non-completed ones visible
+ const visibleChildren = children.filter((id) => {
+ const child = state.nodes.get(id)
+ return child && child.status !== "completed"
+ })
+
+ // Mark completed children as visited so they don't appear as orphans
+ for (const childId of children) {
+ if (!visibleChildren.includes(childId)) {
+ markSubtreeVisited(childId)
+ }
+ }
+
+ for (let i = 0; i < visibleChildren.length; i++) {
+ const childIsLast = i === visibleChildren.length - 1
+ dfs(visibleChildren[i]!, depth + 1, childIsLast, [...ancestorIsLast, isLast])
+ }
+ }
+
+ dfs(state.rootRunId, 0, true, [])
+
+ // Safety net: show orphaned non-completed nodes that weren't reached from root
+ for (const [nodeId, node] of state.nodes) {
+ if (!visited.has(nodeId) && node.status !== "completed") {
+ result.push({ node, depth: 0, isLast: true, ancestorIsLast: [] })
+ }
+ }
+
+ return result
+}
+
+// --- Event Processing ---
+
+export function processDelegationTreeEvent(
+ state: DelegationTreeState,
+ event: PerstackEvent,
+): boolean {
+ switch (event.type) {
+ case "initializeRuntime": {
+ if (state.jobStartedAt === undefined) {
+ state.jobStartedAt = event.timestamp
+ }
+ return true
+ }
+
+ case "startRun": {
+ const initCheckpoint = event.initialCheckpoint
+ const delegatedBy = initCheckpoint.delegatedBy
+ const parentRawRunId = delegatedBy?.runId
+ const parentRunId = parentRawRunId ? resolveRunId(state, parentRawRunId) : undefined
+
+ let expertName: string
+ try {
+ expertName = parseExpertKey(event.expertKey).name
+ } catch {
+ expertName = event.expertKey
+ }
+
+ const node: DelegationTreeNode = {
+ runId: event.runId,
+ expertName,
+ expertKey: event.expertKey,
+ status: "running",
+ actionLabel: "Starting...",
+ actionFileArg: undefined,
+ contextWindowUsage: initCheckpoint.contextWindowUsage ?? 0,
+ parentRunId,
+ childRunIds: [],
+ totalTokens: 0,
+ }
+
+ state.nodes.set(event.runId, node)
+
+ if (parentRunId) {
+ const parent = state.nodes.get(parentRunId)
+ if (parent && !parent.childRunIds.includes(event.runId)) {
+ parent.childRunIds.push(event.runId)
+ }
+ } else {
+ state.rootRunId = event.runId
+ }
+
+ return true
+ }
+
+ case "resumeFromStop": {
+ const checkpoint = event.checkpoint
+ const originalRunId = checkpoint.runId
+
+ // Find the existing node this resume corresponds to
+ let targetNodeId: string | undefined
+
+ if (originalRunId !== event.runId) {
+ // Standard: checkpoint preserved the old runId
+ targetNodeId = resolveRunId(state, originalRunId)
+ }
+
+ // Fallback: runtime may set checkpoint.runId to the new runId,
+ // making it equal to event.runId. Search by expertKey instead.
+ if (!targetNodeId || !state.nodes.has(targetNodeId)) {
+ for (const [nodeId, node] of state.nodes) {
+ if (node.expertKey === event.expertKey && node.status === "suspending") {
+ targetNodeId = nodeId
+ break
+ }
+ }
+ }
+
+ if (targetNodeId && targetNodeId !== event.runId) {
+ state.runIdAliases.set(event.runId, targetNodeId)
+ }
+
+ const nodeId = targetNodeId ?? event.runId
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.status = "running"
+ if (checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = checkpoint.contextWindowUsage
+ }
+ }
+ return true
+ }
+
+ case "startStreamingReasoning": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.actionLabel = "Streaming Reasoning..."
+ node.actionFileArg = undefined
+ }
+ return true
+ }
+
+ case "startStreamingRunResult": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.actionLabel = "Streaming Generation..."
+ node.actionFileArg = undefined
+ }
+ return true
+ }
+
+ case "callTools": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ const { label, fileArg } = deriveActionLabel(event.toolCalls)
+ node.actionLabel = label
+ node.actionFileArg = fileArg
+ node.totalTokens += event.usage.totalTokens
+ state.jobTotalTokens += event.usage.totalTokens
+ }
+ return true
+ }
+
+ case "stopRunByDelegate": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.status = "suspending"
+ node.actionLabel = "Waiting for delegates"
+ node.actionFileArg = undefined
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ return true
+ }
+
+ case "completeRun": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.status = "completed"
+ node.actionLabel = "Completed"
+ node.actionFileArg = undefined
+ node.totalTokens += event.usage.totalTokens
+ state.jobTotalTokens += event.usage.totalTokens
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ return true
+ }
+
+ case "stopRunByError": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.status = "error"
+ node.actionLabel = event.error.message
+ ? truncateText(event.error.message, UI_CONSTANTS.TRUNCATE_TEXT_SHORT)
+ : "Error"
+ node.actionFileArg = undefined
+ if (event.checkpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.checkpoint.contextWindowUsage
+ }
+ }
+ return true
+ }
+
+ case "continueToNextStep": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ if (event.nextCheckpoint.contextWindowUsage !== undefined) {
+ node.contextWindowUsage = event.nextCheckpoint.contextWindowUsage
+ }
+ }
+ return true
+ }
+
+ case "retry": {
+ const nodeId = resolveRunId(state, event.runId)
+ const node = state.nodes.get(nodeId)
+ if (node) {
+ node.totalTokens += event.usage.totalTokens
+ state.jobTotalTokens += event.usage.totalTokens
+ }
+ return true
+ }
+
+ default:
+ return false
+ }
+}
+
+// --- React Hook ---
+
+export function useDelegationTree() {
+ const stateRef = useRef(createInitialDelegationTreeState())
+ const [, setVersion] = useState(0)
+
+ const processEvent = useCallback((event: PerstackEvent) => {
+ const changed = processDelegationTreeEvent(stateRef.current, event)
+ if (changed) {
+ setVersion((v) => v + 1)
+ }
+ }, [])
+
+ const state = stateRef.current
+
+ return {
+ state,
+ processEvent,
+ inProgressCount: getInProgressCount(state),
+ formattedTotalTokens: formatTokenCount(state.jobTotalTokens),
+ }
+}
diff --git a/packages/tui-components/src/execution/hooks/use-elapsed-time.ts b/packages/tui-components/src/execution/hooks/use-elapsed-time.ts
new file mode 100644
index 00000000..245ddaa1
--- /dev/null
+++ b/packages/tui-components/src/execution/hooks/use-elapsed-time.ts
@@ -0,0 +1,36 @@
+import { useEffect, useRef, useState } from "react"
+
+export function formatElapsedTime(ms: number): string {
+ const totalSeconds = Math.floor(ms / 1000)
+ if (totalSeconds < 60) return `${totalSeconds}s`
+ const minutes = Math.floor(totalSeconds / 60)
+ const seconds = totalSeconds % 60
+ return `${minutes}m ${String(seconds).padStart(2, "0")}s`
+}
+
+export function useElapsedTime(startedAt: number | undefined, isRunning: boolean): string {
+ const [now, setNow] = useState(Date.now())
+ const intervalRef = useRef(null)
+
+ useEffect(() => {
+ if (startedAt !== undefined && isRunning) {
+ intervalRef.current = setInterval(() => {
+ setNow(Date.now())
+ }, 1000)
+ } else {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ return () => {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ intervalRef.current = null
+ }
+ }
+ }, [startedAt, isRunning])
+
+ if (startedAt === undefined) return "0s"
+ return formatElapsedTime(now - startedAt)
+}
diff --git a/packages/tui-components/src/execution/hooks/use-execution-state.ts b/packages/tui-components/src/execution/hooks/use-execution-state.ts
index 4acb33fd..7ab437f8 100644
--- a/packages/tui-components/src/execution/hooks/use-execution-state.ts
+++ b/packages/tui-components/src/execution/hooks/use-execution-state.ts
@@ -3,6 +3,9 @@ import { useRun } from "@perstack/react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useRuntimeInfo } from "../../hooks/index.js"
import type { InitialRuntimeConfig } from "../../types/index.js"
+import type { DelegationTreeState } from "./use-delegation-tree.js"
+import { useDelegationTree } from "./use-delegation-tree.js"
+import { useElapsedTime } from "./use-elapsed-time.js"
export type RunStatus = "waiting" | "running" | "completed" | "stopped"
@@ -30,8 +33,13 @@ export type ExecutionState = {
staticItems: StaticItem[]
streaming: ReturnType["streaming"]
runtimeInfo: ReturnType["runtimeInfo"]
+ query: string | undefined
runStatus: RunStatus
handleSubmit: (query: string) => void
+ delegationTreeState: DelegationTreeState
+ inProgressCount: number
+ formattedTotalTokens: string
+ elapsedTime: string
}
let logEntryCounter = 0
@@ -69,7 +77,10 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
initialConfig: config,
})
+ const delegationTree = useDelegationTree()
const [runStatus, setRunStatus] = useState(query ? "running" : "waiting")
+ const elapsedTime = useElapsedTime(delegationTree.state.jobStartedAt, runStatus === "running")
+ const [userQuery, setUserQuery] = useState(query)
const [staticItems, setStaticItems] = useState([])
const lastSyncedCountRef = useRef(0)
@@ -87,12 +98,13 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
const logEntries: LogEntry[] = []
for (const event of historicalEvents) {
logEntries.push(...extractLogEntriesFromEvent(event))
+ delegationTree.processEvent(event)
}
if (logEntries.length > 0) {
setStaticItems((prev) => [...prev, ...logEntries])
}
}
- }, [historicalEvents, runState.appendHistoricalEvents])
+ }, [historicalEvents, runState.appendHistoricalEvents, delegationTree.processEvent])
// Sync new activities from runState into staticItems
useEffect(() => {
@@ -108,6 +120,7 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
onReady((event: PerstackEvent) => {
runState.addEvent(event)
const result = handleEvent(event)
+ delegationTree.processEvent(event)
// Extract log entries from runtime events
const logEntries = extractLogEntriesFromEvent(event)
@@ -119,7 +132,7 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
setRunStatus("stopped")
}
})
- }, [onReady, runState.addEvent, handleEvent])
+ }, [onReady, runState.addEvent, handleEvent, delegationTree.processEvent])
// Watch useRun's isComplete to detect root run completion
useEffect(() => {
@@ -134,6 +147,7 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
if (runStatus === "waiting") {
const trimmed = newQuery.trim()
+ setUserQuery(trimmed)
setQuery(trimmed)
setRunStatus("running")
onQueryReady?.(trimmed)
@@ -151,7 +165,12 @@ export const useExecutionState = (options: UseExecutionStateOptions): ExecutionS
staticItems,
streaming: runState.streaming,
runtimeInfo,
+ query: userQuery,
runStatus,
handleSubmit,
+ delegationTreeState: delegationTree.state,
+ inProgressCount: delegationTree.inProgressCount,
+ formattedTotalTokens: delegationTree.formattedTotalTokens,
+ elapsedTime,
}
}
diff --git a/packages/tui-components/src/execution/hooks/use-streaming-phase.ts b/packages/tui-components/src/execution/hooks/use-streaming-phase.ts
deleted file mode 100644
index eefdf2e2..00000000
--- a/packages/tui-components/src/execution/hooks/use-streaming-phase.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import type { StreamingState } from "@perstack/react"
-import { useMemo } from "react"
-
-export type StreamingPhase = "idle" | "reasoning" | "generating"
-
-export function deriveStreamingPhase(streaming: StreamingState): StreamingPhase {
- for (const run of Object.values(streaming.runs)) {
- if (run.isReasoningActive) return "reasoning"
- if (run.isRunResultActive) return "generating"
- }
- return "idle"
-}
-
-export const useStreamingPhase = (streaming: StreamingState): StreamingPhase => {
- return useMemo(() => deriveStreamingPhase(streaming), [streaming])
-}