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,