From e78d732317c7a5967507e62b0d8e9e5d884cc15c Mon Sep 17 00:00:00 2001 From: HiranoMasaaki Date: Thu, 26 Mar 2026 23:33:08 +0000 Subject: [PATCH] Add: activeRootRunId to useRun and useJobStream Captures the root run's runId from startRun events (where delegatedBy is absent). Available immediately when SSE events arrive, before the run tree API poll returns. Used by Studio to provide correct runId to StreamingOverlay without waiting for API polling. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/active-root-run-id.md | 5 ++ packages/react/src/hooks/use-job-stream.ts | 6 +- packages/react/src/hooks/use-run.test.ts | 91 ++++++++++++++++++++++ packages/react/src/hooks/use-run.ts | 12 +++ 4 files changed, 112 insertions(+), 2 deletions(-) create mode 100644 .changeset/active-root-run-id.md diff --git a/.changeset/active-root-run-id.md b/.changeset/active-root-run-id.md new file mode 100644 index 00000000..cc4f1c27 --- /dev/null +++ b/.changeset/active-root-run-id.md @@ -0,0 +1,5 @@ +--- +"@perstack/react": patch +--- + +Add activeRootRunId to useRun and useJobStream for immediate root run identification from startRun events diff --git a/packages/react/src/hooks/use-job-stream.ts b/packages/react/src/hooks/use-job-stream.ts index 6819ffcb..30c316e8 100644 --- a/packages/react/src/hooks/use-job-stream.ts +++ b/packages/react/src/hooks/use-job-stream.ts @@ -10,6 +10,8 @@ export type JobStreamState = { activities: ActivityOrGroup[] streaming: StreamingState latestActivity: ActivityOrGroup | null + /** The runId of the active root run, derived from startRun events (available immediately) */ + activeRootRunId: string | null isConnected: boolean error: Error | null } @@ -21,7 +23,7 @@ export function useJobStream(options: { }): JobStreamState { const { jobId, connect, enabled = true } = options const shouldConnect = Boolean(jobId && enabled) - const { activities, streaming, addEvent } = useRun() + const { activities, streaming, activeRootRunId, addEvent } = useRun() const [isConnected, setIsConnected] = useState(false) const [error, setError] = useState(null) const addEventRef = useRef(addEvent) @@ -63,5 +65,5 @@ export function useJobStream(options: { [activities], ) - return { activities, streaming, latestActivity, isConnected, error } + return { activities, streaming, latestActivity, activeRootRunId, isConnected, error } } diff --git a/packages/react/src/hooks/use-run.test.ts b/packages/react/src/hooks/use-run.test.ts index 9960e546..1aed35b5 100644 --- a/packages/react/src/hooks/use-run.test.ts +++ b/packages/react/src/hooks/use-run.test.ts @@ -483,6 +483,7 @@ describe("useRun hook", () => { expect(result.current.activities).toEqual([]) expect(result.current.streaming.runs).toEqual({}) expect(result.current.isComplete).toBe(false) + expect(result.current.activeRootRunId).toBeNull() expect(result.current.eventCount).toBe(0) }) @@ -619,6 +620,96 @@ describe("useRun hook", () => { expect(result.current.eventCount).toBe(1) }) + it("sets activeRootRunId on root startRun event", () => { + const { result } = renderHook(() => useRun()) + + act(() => { + result.current.addEvent( + createBaseEvent({ + type: "startRun", + runId: "root-run-1", + inputMessages: [{ type: "userMessage", contents: [{ type: "textPart", text: "Hello" }] }], + initialCheckpoint: { status: "init" }, + } as Partial) as RunEvent, + ) + }) + + expect(result.current.activeRootRunId).toBe("root-run-1") + }) + + it("does not set activeRootRunId on delegated startRun event", () => { + const { result } = renderHook(() => useRun()) + + act(() => { + result.current.addEvent( + createBaseEvent({ + type: "startRun", + runId: "root-run-1", + inputMessages: [{ type: "userMessage", contents: [{ type: "textPart", text: "Hello" }] }], + initialCheckpoint: { status: "init" }, + } as Partial) as RunEvent, + ) + }) + + expect(result.current.activeRootRunId).toBe("root-run-1") + + act(() => { + result.current.addEvent( + createBaseEvent({ + type: "startRun", + runId: "child-run-2", + inputMessages: [ + { type: "userMessage", contents: [{ type: "textPart", text: "Delegated" }] }, + ], + initialCheckpoint: { + status: "init", + delegatedBy: { + expert: { key: "root-expert@1.0.0" }, + toolCallId: "tc-1", + toolName: "delegate", + checkpointId: "cp-1", + runId: "root-run-1", + }, + }, + } as Partial) as RunEvent, + ) + }) + + expect(result.current.activeRootRunId).toBe("root-run-1") + }) + + it("updates activeRootRunId on continuation startRun event", () => { + const { result } = renderHook(() => useRun()) + + act(() => { + result.current.addEvent( + createBaseEvent({ + type: "startRun", + runId: "run-1", + inputMessages: [{ type: "userMessage", contents: [{ type: "textPart", text: "Hello" }] }], + initialCheckpoint: { status: "init" }, + } as Partial) as RunEvent, + ) + }) + + expect(result.current.activeRootRunId).toBe("run-1") + + act(() => { + result.current.addEvent( + createBaseEvent({ + type: "startRun", + runId: "run-2", + inputMessages: [ + { type: "userMessage", contents: [{ type: "textPart", text: "Continue" }] }, + ], + initialCheckpoint: { status: "completed" }, + } as Partial) as RunEvent, + ) + }) + + expect(result.current.activeRootRunId).toBe("run-2") + }) + it("appends historical events", () => { const { result } = renderHook(() => useRun()) diff --git a/packages/react/src/hooks/use-run.ts b/packages/react/src/hooks/use-run.ts index f7a74186..e12ecf8e 100644 --- a/packages/react/src/hooks/use-run.ts +++ b/packages/react/src/hooks/use-run.ts @@ -149,6 +149,8 @@ export type RunResult = { streaming: StreamingState /** Whether the run is complete */ isComplete: boolean + /** The runId of the active root run (not delegated), derived from startRun events */ + activeRootRunId: string | null /** Number of events processed */ eventCount: number /** Add a new event to be processed */ @@ -174,6 +176,7 @@ export function useRun(): RunResult { const [streaming, setStreaming] = useState({ runs: {} }) const [eventCount, setEventCount] = useState(0) const [isComplete, setIsComplete] = useState(false) + const [activeRootRunId, setActiveRootRunId] = useState(null) const stateRef = useRef(createInitialActivityProcessState()) @@ -205,6 +208,14 @@ export function useRun(): RunResult { (rs) => rs.isComplete && !rs.delegatedBy, ) setIsComplete(rootRunComplete) + + if ("type" in event && event.type === "startRun" && "runId" in event) { + const startRunId = event.runId as string + const runState = stateRef.current.runStates.get(startRunId) + if (runState && !runState.delegatedBy) { + setActiveRootRunId(startRunId) + } + } }, []) const clearRunStreaming = useCallback((runId: string) => { @@ -261,6 +272,7 @@ export function useRun(): RunResult { activities, streaming, isComplete, + activeRootRunId, eventCount, addEvent, appendHistoricalEvents,