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]) -}