@temporalio/agent-harness-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Temporal Technologies Inc. All rights reserved.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @temporalio/agent-harness-react
2
+
3
+ A React binding for `@temporalio/agent-harness-client`: `useAgentSession`, a hook that follows one
4
+ harness agent session. React 18.2 or later.
5
+
6
+ ```tsx
7
+ import { useAgentSession } from "@temporalio/agent-harness-react";
8
+ import type { TicTacToeAgent } from "./client_sdk/TicTacToeAgent"; // from harness-codegen
9
+
10
+ function Board({ sessionId }: { sessionId: string }) {
11
+ const agent = useAgentSession<TicTacToeAgent>({ sessionId });
12
+ const board = agent.states.board;
13
+ if (!board) return null;
14
+ return board.cells.map((cell, i) => (
15
+ <button key={i} onClick={() => agent.sendMessage("play", { cell: i + 1 })}>{cell ?? ""}</button>
16
+ ));
17
+ }
18
+ ```
19
+
20
+ - **Mounting opens the connection.** The first mounted component using a session opens its
21
+ stream; it closes once the last one unmounts. StrictMode's extra unmount and remount in
22
+ development does not reconnect.
23
+ - **Renders follow the stream.** The hook reads the session through `useSyncExternalStore`, and
24
+ the core writes once per flush, so a burst of frames is one render.
25
+ - **A changed `sessionId` moves the connection.** Pass it as a prop or state like any other value.
26
+ - **One connection per session.** Put a `<HarnessProvider>` above your components and every
27
+ `useAgentSession` below it with the same session id shares one connection and one state.
28
+ Without one, each component gets its own.
29
+ - **The first component to open a session decides its options** (`callbackTools`, transport and
30
+ so on). Later components, and later renders, share what it set up, so passing a new
31
+ `callbackTools` object each render is fine but does not replace the handlers.
32
+ - **The whole tree.** `messages`, `states` and `agentStatus` are the root agent's; `agents` and
33
+ `agent(id)` reach every subagent, and a `subagent` part's `messageId` names the child message
34
+ it ran. `pendingApprovals` and `pendingCallbacks` collect waiting tool calls from all of them.
35
+ - **Server rendering** shows the empty session and opens no connection; the client connects once
36
+ it hydrates.
37
+
38
+ ## Development
39
+
40
+ This package links `../client` and resolves it through that package's build:
41
+
42
+ ```sh
43
+ (cd ../client && npm ci && npm run build)
44
+ npm ci
45
+ npm run check && npm test && npm run build
46
+ ```
@@ -0,0 +1,3 @@
1
+ export { HarnessProvider, SessionStore, useAgentSession, type AgentSession } from "./session.js";
2
+ export { ReactSessionState, type SessionSnapshot } from "./state.js";
3
+ export * from "@temporalio/agent-harness-client";
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { HarnessProvider, SessionStore, useAgentSession } from "./session.js";
2
+ export { ReactSessionState } from "./state.js";
3
+ export * from "@temporalio/agent-harness-client";
@@ -0,0 +1,74 @@
1
+ import { type ReactNode } from "react";
2
+ import { AgentSessionCore, type AgentSchema, type AgentSessionOptions, type AgentSseFrame, type AgentView, type ConnectionStatus, type HandlerInput, type HandlerName, type HarnessMessage, type SubmitMessageResponse, type ToolApprovalDecision, type UntypedAgent, type WaitingToolCall } from "@temporalio/agent-harness-client";
3
+ import { ReactSessionState } from "./state.js";
4
+ /** One session's core and state, shared by every component that follows it. */
5
+ declare class SessionEntry<A extends AgentSchema> {
6
+ #private;
7
+ readonly state: ReactSessionState<A>;
8
+ readonly core: AgentSessionCore<A>;
9
+ constructor(options: AgentSessionOptions<A>);
10
+ acquire(): void;
11
+ release(): void;
12
+ }
13
+ /**
14
+ * Sessions by id, so that components following the same session share one connection and one
15
+ * state. Put one in context with `<HarnessProvider>`; a component using `useAgentSession`
16
+ * outside any provider gets a store of its own.
17
+ *
18
+ * The first component to follow an id decides that session's options (`callbackTools`,
19
+ * transport and so on); later ones, and later renders, share what it set up.
20
+ */
21
+ export declare class SessionStore {
22
+ #private;
23
+ entry<A extends AgentSchema>(options: AgentSessionOptions<A>): SessionEntry<A>;
24
+ }
25
+ /** Share sessions below this component: every `useAgentSession` in its subtree for the same
26
+ * session id uses one connection. */
27
+ export declare function HarnessProvider({ store, children }: {
28
+ store?: SessionStore;
29
+ children?: ReactNode;
30
+ }): import("react").FunctionComponentElement<import("react").ProviderProps<SessionStore | null>>;
31
+ /** What `useAgentSession` returns: the session's fields as of this render, and its actions. */
32
+ export interface AgentSession<A extends AgentSchema = UntypedAgent> {
33
+ readonly sessionId: string;
34
+ readonly connection: ConnectionStatus;
35
+ /** The last connection or action failure, until `clearError()`. */
36
+ readonly error: Error | undefined;
37
+ /** The root agent's messages. */
38
+ readonly messages: readonly HarnessMessage<A>[];
39
+ /** The root agent's observable state, by state id; each is undefined until its snapshot. */
40
+ readonly states: Readonly<Partial<A["states"]>>;
41
+ /** Whether the root agent has a turn open. */
42
+ readonly agentStatus: "idle" | "busy";
43
+ /** Messages sent from here that the server has not admitted yet, and failed sends. */
44
+ readonly pending: readonly HarnessMessage<A>[];
45
+ /** Every agent in the session's tree — the root and its subagents at any depth — by id. */
46
+ readonly agents: Readonly<Record<string, AgentView>>;
47
+ /** The agent the session was opened for, once the stream has named it. */
48
+ readonly rootAgentId: string | null;
49
+ /** Every frame received, from every agent. */
50
+ readonly frames: readonly AgentSseFrame[];
51
+ /** Gated tool calls waiting on a person, anywhere in the tree. */
52
+ readonly pendingApprovals: readonly WaitingToolCall[];
53
+ /** Callback tool calls waiting on a client, anywhere in the tree, other than the ones the
54
+ * session's `callbackTools` answer. */
55
+ readonly pendingCallbacks: readonly WaitingToolCall[];
56
+ /** Any agent in the tree by id — a subagent part's `subagentId`, say — typed as `B` when its
57
+ * generated type is given. */
58
+ agent<B extends AgentSchema = UntypedAgent>(agentId: string): AgentView<B> | undefined;
59
+ sendMessage<H extends HandlerName<A>>(handler: H, payload: HandlerInput<A, H>): Promise<SubmitMessageResponse | null>;
60
+ respondToApproval(toolId: string, decision: ToolApprovalDecision): Promise<boolean>;
61
+ provideToolOutput(toolId: string, result: unknown): Promise<boolean>;
62
+ provideToolError(toolId: string, error: string): Promise<boolean>;
63
+ /** Stop the agent: it finishes its turn loop and auto-denies anything pending. */
64
+ closeSession(): Promise<boolean>;
65
+ /** Drop a failed send from `pending`. */
66
+ dismiss(localId: string): void;
67
+ clearError(): void;
68
+ }
69
+ /**
70
+ * Follow one agent session. Re-renders when anything in it changes; a changed `sessionId`
71
+ * moves the connection to the new session.
72
+ */
73
+ export declare function useAgentSession<A extends AgentSchema = UntypedAgent>(options: AgentSessionOptions<A>): AgentSession<A>;
74
+ export {};
@@ -0,0 +1,113 @@
1
+ // useAgentSession: one agent session as React state. Call it in a component, render its fields,
2
+ // and call its actions. The connection is open exactly while a component using the session is
3
+ // mounted: the first to mount opens it, and it closes once the last one unmounts.
4
+ import { createContext, createElement, useContext, useEffect, useMemo, useState, useSyncExternalStore } from "react";
5
+ import { AgentSessionCore, rootView, waitingCalls } from "@temporalio/agent-harness-client";
6
+ import { ReactSessionState } from "./state.js";
7
+ /** One session's core and state, shared by every component that follows it. */
8
+ class SessionEntry {
9
+ state = new ReactSessionState();
10
+ core;
11
+ #readers = 0;
12
+ #stopScheduled = false;
13
+ constructor(options) {
14
+ this.core = new AgentSessionCore(options, this.state);
15
+ }
16
+ acquire() {
17
+ if (this.#readers++ > 0)
18
+ return;
19
+ /* A release that has not taken effect yet is cancelled rather than followed by a restart:
20
+ StrictMode unmounts and remounts every component once in development, and that should
21
+ not cost a reconnect. */
22
+ if (this.#stopScheduled)
23
+ this.#stopScheduled = false;
24
+ else
25
+ this.core.start();
26
+ }
27
+ release() {
28
+ if (--this.#readers > 0)
29
+ return;
30
+ this.#stopScheduled = true;
31
+ queueMicrotask(() => {
32
+ if (!this.#stopScheduled)
33
+ return;
34
+ this.#stopScheduled = false;
35
+ this.core.stop();
36
+ });
37
+ }
38
+ }
39
+ /**
40
+ * Sessions by id, so that components following the same session share one connection and one
41
+ * state. Put one in context with `<HarnessProvider>`; a component using `useAgentSession`
42
+ * outside any provider gets a store of its own.
43
+ *
44
+ * The first component to follow an id decides that session's options (`callbackTools`,
45
+ * transport and so on); later ones, and later renders, share what it set up.
46
+ */
47
+ export class SessionStore {
48
+ #entries = new Map();
49
+ entry(options) {
50
+ let entry = this.#entries.get(options.sessionId);
51
+ if (!entry) {
52
+ entry = new SessionEntry(options);
53
+ this.#entries.set(options.sessionId, entry);
54
+ }
55
+ return entry;
56
+ }
57
+ }
58
+ const StoreContext = createContext(null);
59
+ /** Share sessions below this component: every `useAgentSession` in its subtree for the same
60
+ * session id uses one connection. */
61
+ export function HarnessProvider({ store, children }) {
62
+ const [value] = useState(() => store ?? new SessionStore());
63
+ return createElement(StoreContext.Provider, { value }, children);
64
+ }
65
+ /**
66
+ * Follow one agent session. Re-renders when anything in it changes; a changed `sessionId`
67
+ * moves the connection to the new session.
68
+ */
69
+ export function useAgentSession(options) {
70
+ const context = useContext(StoreContext);
71
+ const [own] = useState(() => new SessionStore());
72
+ const entry = (context ?? own).entry(options);
73
+ useEffect(() => {
74
+ entry.acquire();
75
+ return () => entry.release();
76
+ }, [entry]);
77
+ /* The server snapshot is the same empty state: effects never run there, so nothing connects. */
78
+ const snapshot = useSyncExternalStore(entry.state.subscribe, entry.state.getSnapshot, entry.state.getSnapshot);
79
+ return useMemo(() => sessionView(entry, snapshot), [entry, snapshot]);
80
+ }
81
+ function sessionView(entry, snapshot) {
82
+ const { core } = entry;
83
+ const root = rootView(snapshot);
84
+ /* Walking every part of every agent is only worth doing for a render that reads the result. */
85
+ let waiting;
86
+ const waitingNow = () => (waiting ??= waitingCalls(snapshot.agents, (name) => core.handlesCallback(name)));
87
+ return {
88
+ sessionId: core.sessionId,
89
+ connection: snapshot.connection,
90
+ error: snapshot.error,
91
+ messages: root.messages,
92
+ states: root.states,
93
+ agentStatus: root.agentStatus,
94
+ pending: snapshot.pending,
95
+ agents: snapshot.agents,
96
+ rootAgentId: snapshot.rootAgentId,
97
+ frames: snapshot.frames,
98
+ get pendingApprovals() {
99
+ return waitingNow().approvals;
100
+ },
101
+ get pendingCallbacks() {
102
+ return waitingNow().callbacks;
103
+ },
104
+ agent: (agentId) => snapshot.agents[agentId],
105
+ sendMessage: (handler, payload) => core.sendMessage(handler, payload),
106
+ respondToApproval: (toolId, decision) => core.respondToApproval(toolId, decision),
107
+ provideToolOutput: (toolId, result) => core.provideToolOutput(toolId, result),
108
+ provideToolError: (toolId, error) => core.provideToolError(toolId, error),
109
+ closeSession: () => core.closeSession(),
110
+ dismiss: (localId) => core.dismiss(localId),
111
+ clearError: () => core.clearError()
112
+ };
113
+ }
@@ -0,0 +1,28 @@
1
+ import { type AgentSchema, type AgentSseFrame, type AgentUpdate, type AgentView, type ConnectionStatus, type HarnessMessage, type SessionState, type UntypedAgent } from "@temporalio/agent-harness-client";
2
+ export interface SessionSnapshot<A extends AgentSchema = UntypedAgent> {
3
+ readonly connection: ConnectionStatus;
4
+ readonly error: Error | undefined;
5
+ readonly rootAgentId: string | null;
6
+ readonly agents: Readonly<Record<string, AgentView>>;
7
+ readonly pending: readonly HarnessMessage<A>[];
8
+ readonly frames: readonly AgentSseFrame[];
9
+ }
10
+ export declare class ReactSessionState<A extends AgentSchema = UntypedAgent> implements SessionState<A> {
11
+ #private;
12
+ readonly subscribe: (listener: () => void) => (() => void);
13
+ readonly getSnapshot: () => SessionSnapshot<A>;
14
+ get connection(): ConnectionStatus;
15
+ set connection(connection: ConnectionStatus);
16
+ get error(): Error | undefined;
17
+ set error(error: Error | undefined);
18
+ get rootAgentId(): string | null;
19
+ set rootAgentId(rootAgentId: string | null);
20
+ get agents(): Readonly<Record<string, AgentView>>;
21
+ set agents(agents: Readonly<Record<string, AgentView>>);
22
+ get pending(): readonly HarnessMessage<A>[];
23
+ set pending(pending: readonly HarnessMessage<A>[]);
24
+ get frames(): readonly AgentSseFrame[];
25
+ set frames(frames: readonly AgentSseFrame[]);
26
+ updateAgents(updates: ReadonlyArray<readonly [string, AgentUpdate]>): void;
27
+ appendFrames(frames: readonly AgentSseFrame[]): void;
28
+ }
package/dist/state.js ADDED
@@ -0,0 +1,73 @@
1
+ // The SessionState the core writes into, as an immutable snapshot for `useSyncExternalStore`.
2
+ // Every write replaces the snapshot, so its identity changes exactly when something did; the
3
+ // core never mutates what it has handed over, so the fields inside can be shared as they are.
4
+ import { applyAgentUpdate } from "@temporalio/agent-harness-client";
5
+ export class ReactSessionState {
6
+ #snapshot = {
7
+ connection: "stopped",
8
+ error: undefined,
9
+ rootAgentId: null,
10
+ agents: {},
11
+ pending: [],
12
+ frames: []
13
+ };
14
+ #listeners = new Set();
15
+ /* Arrow properties: `useSyncExternalStore` resubscribes whenever these change identity. */
16
+ subscribe = (listener) => {
17
+ this.#listeners.add(listener);
18
+ return () => this.#listeners.delete(listener);
19
+ };
20
+ getSnapshot = () => this.#snapshot;
21
+ get connection() {
22
+ return this.#snapshot.connection;
23
+ }
24
+ set connection(connection) {
25
+ this.#set("connection", connection);
26
+ }
27
+ get error() {
28
+ return this.#snapshot.error;
29
+ }
30
+ set error(error) {
31
+ this.#set("error", error);
32
+ }
33
+ get rootAgentId() {
34
+ return this.#snapshot.rootAgentId;
35
+ }
36
+ set rootAgentId(rootAgentId) {
37
+ this.#set("rootAgentId", rootAgentId);
38
+ }
39
+ get agents() {
40
+ return this.#snapshot.agents;
41
+ }
42
+ set agents(agents) {
43
+ this.#set("agents", agents);
44
+ }
45
+ get pending() {
46
+ return this.#snapshot.pending;
47
+ }
48
+ set pending(pending) {
49
+ this.#set("pending", pending);
50
+ }
51
+ get frames() {
52
+ return this.#snapshot.frames;
53
+ }
54
+ set frames(frames) {
55
+ this.#set("frames", frames);
56
+ }
57
+ updateAgents(updates) {
58
+ const next = { ...this.#snapshot.agents };
59
+ for (const [id, update] of updates)
60
+ next[id] = applyAgentUpdate(next[id], id, update);
61
+ this.#set("agents", next);
62
+ }
63
+ appendFrames(frames) {
64
+ this.#set("frames", this.#snapshot.frames.concat(frames));
65
+ }
66
+ #set(key, value) {
67
+ if (this.#snapshot[key] === value)
68
+ return;
69
+ this.#snapshot = { ...this.#snapshot, [key]: value };
70
+ for (const listener of this.#listeners)
71
+ listener();
72
+ }
73
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@temporalio/agent-harness-react",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "type": "module",
6
+ "description": "React binding for @temporalio/agent-harness-client: a useAgentSession hook over a temporal-agent-harness agent's event stream.",
7
+ "publishConfig": {
8
+ "access": "public",
9
+ "registry": "https://registry.npmjs.org/"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.build.json",
22
+ "check": "tsc -p tsconfig.json",
23
+ "test": "vitest run"
24
+ },
25
+ "peerDependencies": {
26
+ "react": "^18.2.0 || ^19.0.0"
27
+ },
28
+ "dependencies": {
29
+ "@temporalio/agent-harness-client": "^0.1.0"
30
+ },
31
+ "devDependencies": {
32
+ "@testing-library/react": "^16.3.3",
33
+ "@types/node": "^22.20.4",
34
+ "@types/react": "^19.3.0",
35
+ "@types/react-dom": "^19.3.0",
36
+ "happy-dom": "^20.14.5",
37
+ "react": "^19.3.0",
38
+ "react-dom": "^19.3.0",
39
+ "typescript": "^7.0.2",
40
+ "vitest": "^5.0.1"
41
+ }
42
+ }