@agentic-kit/react 0.2.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,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Dan Lynch <pyramation@gmail.com>
4
+ Copyright (c) 2025 Constructive <developers@constructive.io>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,190 @@
1
+ # @agentic-kit/react
2
+
3
+ <p align="center" width="100%">
4
+ <img height="250" src="https://raw.githubusercontent.com/constructive-io/constructive/refs/heads/main/assets/outline-logo.svg" />
5
+ </p>
6
+
7
+ <p align="center" width="100%">
8
+ <a href="https://github.com/constructive-io/agentic-kit/actions/workflows/run-tests.yaml">
9
+ <img height="20" src="https://github.com/constructive-io/agentic-kit/actions/workflows/run-tests.yaml/badge.svg" />
10
+ </a>
11
+ <a href="https://github.com/constructive-io/agentic-kit/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
12
+ <a href="https://www.npmjs.com/package/@agentic-kit/react"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/agentic-kit?filename=packages%2Freact%2Fpackage.json"/></a>
13
+ </p>
14
+
15
+ Headless React bindings for `@agentic-kit/agent`. Exposes a single hook —
16
+ `useChat` — that POSTs messages to a server endpoint, parses the SSE stream
17
+ back into typed `AgentEvent`s, and folds them into a `Message[]` plus a
18
+ small set of derived states (streaming snapshot, executing tools, pending
19
+ decisions).
20
+
21
+ The hook ships no UI. State lives in messages — there is no separate run
22
+ store and no `runId` to keep in sync.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @agentic-kit/react @agentic-kit/agent agentic-kit
28
+ ```
29
+
30
+ Peer-deps `react@>=18` and `react-dom@>=18`.
31
+
32
+ ## Server Contract
33
+
34
+ `useChat` POSTs JSON of the shape `{ messages, ...body() }` to `api`, and
35
+ expects an SSE response whose `data:` frames decode to `AgentEvent`s. The
36
+ easiest way to produce that is `AgentRunHandle#toResponse()`:
37
+
38
+ ```ts
39
+ // app/api/chat/route.ts (Next.js)
40
+ import { Agent } from '@agentic-kit/agent';
41
+
42
+ export async function POST(req: Request) {
43
+ const { messages } = await req.json();
44
+ const agent = new Agent({ initialState: { model, tools, systemPrompt } });
45
+ agent.replaceMessages(messages.slice(0, -1));
46
+ return agent.prompt(messages[messages.length - 1]).toResponse();
47
+ }
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ```tsx
53
+ import { useChat } from '@agentic-kit/react';
54
+
55
+ export function Chat() {
56
+ const {
57
+ messages,
58
+ streamingMessage,
59
+ isStreaming,
60
+ pendingDecisions,
61
+ executingToolCallIds,
62
+ send,
63
+ respondWithDecision,
64
+ abort,
65
+ } = useChat({ api: '/api/chat' });
66
+
67
+ return (
68
+ <div>
69
+ {messages.map((m, i) => <MessageView key={i} message={m} />)}
70
+ {streamingMessage && <MessageView message={streamingMessage} streaming />}
71
+
72
+ {[...pendingDecisions.values()].map((pending) => (
73
+ <DecisionPrompt
74
+ key={pending.toolCallId}
75
+ event={pending}
76
+ onApprove={() => respondWithDecision(pending.toolCallId, { approved: true })}
77
+ onDeny={() => respondWithDecision(pending.toolCallId, { approved: false })}
78
+ />
79
+ ))}
80
+
81
+ <Composer onSubmit={send} disabled={isStreaming} onAbort={abort} />
82
+ </div>
83
+ );
84
+ }
85
+ ```
86
+
87
+ ## API
88
+
89
+ ### `useChat(options): UseChatResult`
90
+
91
+ #### Options
92
+
93
+ - `api` — endpoint to POST messages to.
94
+ - `body?: () => Record<string, unknown>` — extra fields merged into each
95
+ request body (e.g. model id, conversation id).
96
+ - `initialMessages?: Message[]` — starting message log.
97
+ - `fetch?: typeof globalThis.fetch` — inject a custom fetch (useful for
98
+ auth headers, tests).
99
+ - `onMessage?(message)` — fired for each finalized message (assistant or
100
+ toolResult).
101
+ - `onFinish?(assistant)` — fired once per run with the final assistant
102
+ message.
103
+ - `onDecisionPending?(event)` — fired when a tool pauses awaiting a decision.
104
+ - `onToolExecutionStart?(event)` — fired when a tool begins running.
105
+ - `onToolExecutionEnd?(event)` — fired when a tool finishes (or errors).
106
+ - `onError?(err)` — fired on transport or server errors.
107
+
108
+ #### Result state
109
+
110
+ - `messages: Message[]` — committed log.
111
+ - `streamingMessage: AssistantMessage | null` — the in-flight assistant
112
+ message being streamed, including any partial tool-call blocks.
113
+ - `isStreaming: boolean`.
114
+ - `pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>` — keyed
115
+ by `toolCallId`.
116
+ - `executingToolCallIds: ReadonlySet<string>` — tool calls currently running
117
+ on the server.
118
+ - `error: unknown`.
119
+
120
+ #### Result actions
121
+
122
+ - `send(input: string | Message)` — append a user message and run.
123
+ - `sendMessages(msgs: Message[])` — replace the local log and run with that
124
+ exact list. Useful when the caller has already prepared a transcript.
125
+ - `setMessages(update)` — patch messages without sending. Accepts an array
126
+ or `(prev) => next`. Recomputes `pendingDecisions` from the new log.
127
+ - `respondWithDecision(toolCallId, value)` — attach a decision to the
128
+ matching pending `toolCall` block and re-POST. Walks the log backwards to
129
+ find the most recent assistant message owning that id with no decision
130
+ yet, so the caller is free to append unrelated messages between the pause
131
+ and the user's response. Throws if no pending match is found.
132
+ - `abort()` — cancel the in-flight request. Visible text in the streaming
133
+ message is preserved as a finalized assistant message; orphan tool-call
134
+ blocks are dropped so they don't re-pause the run on the next call.
135
+
136
+ ## Patterns
137
+
138
+ ### User types instead of clicking approve/deny
139
+
140
+ If the UI lets the user send a new message while a tool is paused, the
141
+ server can't resume on text alone — every dangling `toolCall` needs a
142
+ result before the next request. Compose `injectDeferralResults` from
143
+ `agentic-kit` with `sendMessages`:
144
+
145
+ ```ts
146
+ import { injectDeferralResults, createUserMessage } from 'agentic-kit';
147
+
148
+ await sendMessages([
149
+ ...injectDeferralResults(messages),
150
+ createUserMessage(text),
151
+ ]);
152
+ ```
153
+
154
+ That synthesizes a stand-in `toolResult` for each paused tool call so the
155
+ transcript is well-formed when the server picks it back up.
156
+
157
+ ---
158
+
159
+ ## Related Constructive Tooling
160
+
161
+ ### 🤖 AI & Agent Development
162
+
163
+ * [agentic-kit](https://github.com/constructive-io/agentic-kit): **🧰 Provider-agnostic LLM adapter** with streaming and multi-turn support. Swap between Anthropic, OpenAI, Ollama, and any OpenAI-compatible endpoint.
164
+
165
+ ### 📦 Package Management
166
+
167
+ * [pgpm](https://github.com/constructive-io/constructive/tree/main/pgpm/pgpm): **🖥️ PostgreSQL Package Manager** for modular Postgres development. Works with database workspaces, scaffolding, migrations, seeding, and installing database packages.
168
+
169
+ ### 🧪 Testing
170
+
171
+ * [pgsql-test](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-test): **📊 Isolated testing environments** with per-test transaction rollbacks—ideal for integration tests, complex migrations, and RLS simulation.
172
+ * [pgsql-seed](https://github.com/constructive-io/constructive/tree/main/postgres/pgsql-seed): **🌱 PostgreSQL seeding utilities** for CSV, JSON, SQL data loading, and pgpm deployment.
173
+ * [supabase-test](https://github.com/constructive-io/constructive/tree/main/postgres/supabase-test): **🧪 Supabase-native test harness** preconfigured for the local Supabase stack—per-test rollbacks, JWT/role context helpers, and CI/GitHub Actions ready.
174
+
175
+ ### 🧠 Parsing & AST
176
+
177
+ * [pgsql-parser](https://www.npmjs.com/package/pgsql-parser): **🔄 SQL conversion engine** that interprets and converts PostgreSQL syntax.
178
+ * [libpg-query-node](https://www.npmjs.com/package/libpg-query): **🌉 Node.js bindings** for `libpg_query`, converting SQL into parse trees.
179
+ * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
180
+ * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
181
+
182
+ ## Credits
183
+
184
+ **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
185
+
186
+ ## Disclaimer
187
+
188
+ AS DESCRIBED IN THE LICENSES, THE SOFTWARE IS PROVIDED "AS IS", AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND.
189
+
190
+ No developer or entity involved in creating this software will be liable for any claims or damages whatsoever associated with your use, inability to use, or your interaction with other users of the code, including any direct, indirect, incidental, special, exemplary, punitive or consequential damages, or loss of profits, cryptocurrencies, tokens, or anything else of value.
package/esm/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { type ToolDecisionPendingEvent, useChat, type UseChatOptions, type UseChatResult, } from './use-chat.js';
package/esm/index.js ADDED
@@ -0,0 +1 @@
1
+ export { useChat, } from './use-chat.js';
@@ -0,0 +1 @@
1
+ {"type":"module"}
@@ -0,0 +1,42 @@
1
+ import type { AgentEvent, AgentToolResult } from '@agentic-kit/agent';
2
+ import type { AssistantMessage, Message } from 'agentic-kit';
3
+ export type ToolDecisionPendingEvent = Extract<AgentEvent, {
4
+ type: 'tool_decision_pending';
5
+ }>;
6
+ export interface ToolExecutionStartEvent {
7
+ toolCallId: string;
8
+ toolName: string;
9
+ args: Record<string, unknown>;
10
+ }
11
+ export interface ToolExecutionEndEvent {
12
+ toolCallId: string;
13
+ toolName: string;
14
+ result: AgentToolResult;
15
+ isError: boolean;
16
+ }
17
+ export interface UseChatOptions {
18
+ api: string;
19
+ body?: () => Record<string, unknown>;
20
+ initialMessages?: Message[];
21
+ fetch?: typeof globalThis.fetch;
22
+ onMessage?: (message: Message) => void;
23
+ onFinish?: (message: AssistantMessage) => void;
24
+ onDecisionPending?: (event: ToolDecisionPendingEvent) => void;
25
+ onToolExecutionStart?: (event: ToolExecutionStartEvent) => void;
26
+ onToolExecutionEnd?: (event: ToolExecutionEndEvent) => void;
27
+ onError?: (error: unknown) => void;
28
+ }
29
+ export interface UseChatResult {
30
+ messages: Message[];
31
+ streamingMessage: AssistantMessage | null;
32
+ isStreaming: boolean;
33
+ pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>;
34
+ executingToolCallIds: ReadonlySet<string>;
35
+ error: unknown;
36
+ send: (input: string | Message) => Promise<void>;
37
+ sendMessages: (messages: Message[]) => Promise<void>;
38
+ setMessages: (msgs: Message[] | ((prev: Message[]) => Message[])) => void;
39
+ respondWithDecision: (toolCallId: string, value: unknown) => Promise<void>;
40
+ abort: () => void;
41
+ }
42
+ export declare function useChat(options: UseChatOptions): UseChatResult;
@@ -0,0 +1,331 @@
1
+ import { parseSSEStream } from '@agentic-kit/agent';
2
+ import { createUserMessage } from 'agentic-kit';
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ /**
5
+ * Compute the pendingDecisions map from a messages array. A toolCall counts as
6
+ * pending when it has neither a `decision` field nor a paired `toolResult`.
7
+ *
8
+ * Used on `setMessages` to recompute server-derived state from the new array.
9
+ * The synthesized event is a stub (no `input`, no `schema`) — the original
10
+ * decision-pending event is not recoverable from messages alone, but the map
11
+ * key (toolCallId) is what consumers actually need to render decision UI.
12
+ */
13
+ function rederivePendingDecisions(messages) {
14
+ const completed = new Set();
15
+ for (const m of messages) {
16
+ if (m.role === 'toolResult')
17
+ completed.add(m.toolCallId);
18
+ }
19
+ const pending = new Map();
20
+ for (const m of messages) {
21
+ if (m.role !== 'assistant')
22
+ continue;
23
+ for (const block of m.content) {
24
+ if (block.type !== 'toolCall')
25
+ continue;
26
+ if (completed.has(block.id))
27
+ continue;
28
+ if ('decision' in block && block.decision !== undefined)
29
+ continue;
30
+ pending.set(block.id, {
31
+ type: 'tool_decision_pending',
32
+ toolCallId: block.id,
33
+ toolName: block.name,
34
+ input: block.arguments,
35
+ schema: { type: 'object' },
36
+ });
37
+ }
38
+ }
39
+ return pending;
40
+ }
41
+ export function useChat(options) {
42
+ const [messages, setMessagesState] = useState(() => options.initialMessages ?? []);
43
+ const [streamingMessage, setStreamingMessage] = useState(null);
44
+ const [isStreaming, setIsStreaming] = useState(false);
45
+ const [pendingDecisions, setPendingDecisions] = useState(() => rederivePendingDecisions(options.initialMessages ?? []));
46
+ const [executingToolCallIds, setExecutingToolCallIds] = useState(() => new Set());
47
+ const [error, setError] = useState(undefined);
48
+ const messagesRef = useRef(messages);
49
+ useEffect(() => {
50
+ messagesRef.current = messages;
51
+ }, [messages]);
52
+ const streamingMessageRef = useRef(null);
53
+ useEffect(() => {
54
+ streamingMessageRef.current = streamingMessage;
55
+ }, [streamingMessage]);
56
+ const optionsRef = useRef(options);
57
+ useEffect(() => {
58
+ optionsRef.current = options;
59
+ }, [options]);
60
+ const runIdRef = useRef(0);
61
+ const abortControllerRef = useRef(null);
62
+ const failRun = useCallback((err) => {
63
+ setError(err);
64
+ optionsRef.current.onError?.(err);
65
+ setIsStreaming(false);
66
+ }, []);
67
+ const runStream = useCallback(async (requestMessages, optimisticUserMessage) => {
68
+ const opts = optionsRef.current;
69
+ const myRun = ++runIdRef.current;
70
+ abortControllerRef.current?.abort();
71
+ const controller = new AbortController();
72
+ abortControllerRef.current = controller;
73
+ const isCurrent = () => runIdRef.current === myRun;
74
+ setIsStreaming(true);
75
+ setError(undefined);
76
+ setPendingDecisions(new Map());
77
+ setExecutingToolCallIds(new Set());
78
+ setStreamingMessage(null);
79
+ if (optimisticUserMessage) {
80
+ setMessagesState((prev) => [...prev, optimisticUserMessage]);
81
+ }
82
+ let skipUserEcho = optimisticUserMessage !== null;
83
+ const fetchFn = opts.fetch ?? globalThis.fetch;
84
+ const extraBody = opts.body?.() ?? {};
85
+ let response;
86
+ try {
87
+ response = await fetchFn(opts.api, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ messages: requestMessages, ...extraBody }),
91
+ signal: controller.signal,
92
+ });
93
+ }
94
+ catch (err) {
95
+ if (!isCurrent())
96
+ return;
97
+ if (controller.signal.aborted) {
98
+ if (isCurrent())
99
+ setIsStreaming(false);
100
+ return;
101
+ }
102
+ failRun(err);
103
+ return;
104
+ }
105
+ if (!isCurrent())
106
+ return;
107
+ if (!response.ok) {
108
+ failRun(new Error(`HTTP ${response.status}: ${response.statusText}`));
109
+ return;
110
+ }
111
+ if (!response.body) {
112
+ failRun(new Error('Response has no body'));
113
+ return;
114
+ }
115
+ try {
116
+ for await (const event of parseSSEStream(response.body)) {
117
+ if (!isCurrent())
118
+ return;
119
+ switch (event.type) {
120
+ case 'message_start': {
121
+ if (skipUserEcho && event.message.role === 'user') {
122
+ skipUserEcho = false;
123
+ break;
124
+ }
125
+ if (event.message.role === 'assistant') {
126
+ setStreamingMessage(event.message);
127
+ }
128
+ break;
129
+ }
130
+ case 'message_update': {
131
+ setStreamingMessage(event.message);
132
+ break;
133
+ }
134
+ case 'message_end': {
135
+ if (event.message.role === 'assistant') {
136
+ setStreamingMessage(null);
137
+ setMessagesState((prev) => [...prev, event.message]);
138
+ }
139
+ else if (event.message.role === 'toolResult') {
140
+ setMessagesState((prev) => [...prev, event.message]);
141
+ }
142
+ opts.onMessage?.(event.message);
143
+ break;
144
+ }
145
+ case 'tool_execution_start': {
146
+ setExecutingToolCallIds((prev) => {
147
+ const next = new Set(prev);
148
+ next.add(event.toolCallId);
149
+ return next;
150
+ });
151
+ opts.onToolExecutionStart?.({
152
+ toolCallId: event.toolCallId,
153
+ toolName: event.toolName,
154
+ args: event.args,
155
+ });
156
+ break;
157
+ }
158
+ case 'tool_execution_end': {
159
+ setExecutingToolCallIds((prev) => {
160
+ if (!prev.has(event.toolCallId))
161
+ return prev;
162
+ const next = new Set(prev);
163
+ next.delete(event.toolCallId);
164
+ return next;
165
+ });
166
+ opts.onToolExecutionEnd?.({
167
+ toolCallId: event.toolCallId,
168
+ toolName: event.toolName,
169
+ result: event.result,
170
+ isError: event.isError,
171
+ });
172
+ break;
173
+ }
174
+ case 'tool_decision_pending': {
175
+ setPendingDecisions((prev) => {
176
+ const next = new Map(prev);
177
+ next.set(event.toolCallId, event);
178
+ return next;
179
+ });
180
+ opts.onDecisionPending?.(event);
181
+ break;
182
+ }
183
+ case 'agent_end': {
184
+ setStreamingMessage(null);
185
+ setMessagesState(() => {
186
+ if (!isCurrent())
187
+ return messagesRef.current;
188
+ return event.messages;
189
+ });
190
+ const lastAssistant = [...event.messages]
191
+ .reverse()
192
+ .find((m) => m.role === 'assistant');
193
+ if (lastAssistant) {
194
+ opts.onFinish?.(lastAssistant);
195
+ }
196
+ break;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ catch (err) {
202
+ if (!isCurrent())
203
+ return;
204
+ if (controller.signal.aborted)
205
+ return;
206
+ failRun(err);
207
+ return;
208
+ }
209
+ finally {
210
+ if (isCurrent()) {
211
+ setIsStreaming(false);
212
+ abortControllerRef.current = null;
213
+ }
214
+ }
215
+ }, [failRun]);
216
+ const sendMessages = useCallback(async (msgs) => {
217
+ setMessagesState(msgs);
218
+ messagesRef.current = msgs;
219
+ await runStream(msgs, null);
220
+ }, [runStream]);
221
+ const send = useCallback(async (input) => {
222
+ const userMessage = typeof input === 'string' ? createUserMessage(input) : input;
223
+ const requestMessages = [...messagesRef.current, userMessage];
224
+ messagesRef.current = requestMessages;
225
+ await runStream(requestMessages, userMessage);
226
+ }, [runStream]);
227
+ const respondWithDecision = useCallback(async (toolCallId, value) => {
228
+ const current = messagesRef.current;
229
+ let targetIdx = -1;
230
+ for (let i = current.length - 1; i >= 0; i--) {
231
+ const msg = current[i];
232
+ if (msg.role !== 'assistant')
233
+ continue;
234
+ const match = msg.content.find((block) => block.type === 'toolCall' && block.id === toolCallId);
235
+ if (!match)
236
+ continue;
237
+ if ('decision' in match && match.decision !== undefined)
238
+ continue;
239
+ targetIdx = i;
240
+ break;
241
+ }
242
+ if (targetIdx === -1) {
243
+ throw new Error(`No pending decision for toolCallId '${toolCallId}'`);
244
+ }
245
+ const target = current[targetIdx];
246
+ const updatedAssistant = {
247
+ ...target,
248
+ content: target.content.map((block) => {
249
+ if (block.type !== 'toolCall' || block.id !== toolCallId) {
250
+ return block;
251
+ }
252
+ return { ...block, decision: value };
253
+ }),
254
+ };
255
+ const requestMessages = [
256
+ ...current.slice(0, targetIdx),
257
+ updatedAssistant,
258
+ ...current.slice(targetIdx + 1),
259
+ ];
260
+ setMessagesState(requestMessages);
261
+ messagesRef.current = requestMessages;
262
+ setPendingDecisions((prev) => {
263
+ if (!prev.has(toolCallId))
264
+ return prev;
265
+ const next = new Map(prev);
266
+ next.delete(toolCallId);
267
+ return next;
268
+ });
269
+ await runStream(requestMessages, null);
270
+ }, [runStream]);
271
+ const setMessages = useCallback((update) => {
272
+ setMessagesState((prev) => {
273
+ const next = typeof update === 'function' ? update(prev) : update;
274
+ messagesRef.current = next;
275
+ setPendingDecisions(rederivePendingDecisions(next));
276
+ setExecutingToolCallIds(new Set());
277
+ setError(undefined);
278
+ return next;
279
+ });
280
+ }, []);
281
+ useEffect(() => {
282
+ return () => {
283
+ abortControllerRef.current?.abort();
284
+ abortControllerRef.current = null;
285
+ };
286
+ }, []);
287
+ const abort = useCallback(() => {
288
+ abortControllerRef.current?.abort();
289
+ abortControllerRef.current = null;
290
+ runIdRef.current++;
291
+ // Snapshot the in-flight assistant message before clearing so visible text
292
+ // survives the stop. Drop toolCall blocks — without results they'd surface
293
+ // as pending decisions via rederivePendingDecisions and re-pause the agent.
294
+ const partial = streamingMessageRef.current;
295
+ if (partial) {
296
+ const visible = partial.content.filter((b) => b.type === 'text' && b.text.length > 0);
297
+ if (visible.length > 0) {
298
+ setMessagesState((prev) => [...prev, { ...partial, content: visible }]);
299
+ }
300
+ }
301
+ setIsStreaming(false);
302
+ setStreamingMessage(null);
303
+ setExecutingToolCallIds(new Set());
304
+ setPendingDecisions(new Map());
305
+ }, []);
306
+ return useMemo(() => ({
307
+ messages,
308
+ streamingMessage,
309
+ isStreaming,
310
+ pendingDecisions,
311
+ executingToolCallIds,
312
+ error,
313
+ send,
314
+ sendMessages,
315
+ setMessages,
316
+ respondWithDecision,
317
+ abort,
318
+ }), [
319
+ messages,
320
+ streamingMessage,
321
+ isStreaming,
322
+ pendingDecisions,
323
+ executingToolCallIds,
324
+ error,
325
+ send,
326
+ sendMessages,
327
+ setMessages,
328
+ respondWithDecision,
329
+ abort,
330
+ ]);
331
+ }
package/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export { type ToolDecisionPendingEvent, useChat, type UseChatOptions, type UseChatResult, } from './use-chat.js';
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useChat = void 0;
4
+ var use_chat_js_1 = require("./use-chat.js");
5
+ Object.defineProperty(exports, "useChat", { enumerable: true, get: function () { return use_chat_js_1.useChat; } });
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@agentic-kit/react",
3
+ "version": "0.2.0",
4
+ "author": "Dan Lynch <pyramation@gmail.com>",
5
+ "description": "React bindings for agentic-kit (useChat hook)",
6
+ "main": "index.js",
7
+ "module": "esm/index.js",
8
+ "types": "index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./index.d.ts",
12
+ "import": "./esm/index.js",
13
+ "require": "./index.js"
14
+ }
15
+ },
16
+ "homepage": "https://github.com/constructive-io/agentic-kit",
17
+ "license": "SEE LICENSE IN LICENSE",
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "directory": "dist"
21
+ },
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "https://github.com/constructive-io/agentic-kit"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/constructive-io/agentic-kit/issues"
28
+ },
29
+ "scripts": {
30
+ "clean": "makage clean",
31
+ "prepack": "npm run build",
32
+ "build": "makage build",
33
+ "postbuild": "node ../../scripts/write-esm-package-json.js",
34
+ "build:dev": "makage build --dev",
35
+ "lint": "eslint . --fix",
36
+ "test": "jest",
37
+ "test:watch": "jest --watch"
38
+ },
39
+ "dependencies": {
40
+ "@agentic-kit/agent": "0.3.0",
41
+ "agentic-kit": "1.2.0"
42
+ },
43
+ "peerDependencies": {
44
+ "react": ">=18",
45
+ "react-dom": ">=18"
46
+ },
47
+ "devDependencies": {
48
+ "@testing-library/dom": "10.4.1",
49
+ "@testing-library/react": "16.3.2",
50
+ "@types/react": "19.2.14",
51
+ "@types/react-dom": "19.2.3",
52
+ "jest-environment-jsdom": "^29.7.0",
53
+ "react": "19.2.5",
54
+ "react-dom": "19.2.5"
55
+ },
56
+ "keywords": [],
57
+ "gitHead": "d4f3f660be621267efe12671c22db202ccdb3f0b"
58
+ }
package/use-chat.d.ts ADDED
@@ -0,0 +1,42 @@
1
+ import type { AgentEvent, AgentToolResult } from '@agentic-kit/agent';
2
+ import type { AssistantMessage, Message } from 'agentic-kit';
3
+ export type ToolDecisionPendingEvent = Extract<AgentEvent, {
4
+ type: 'tool_decision_pending';
5
+ }>;
6
+ export interface ToolExecutionStartEvent {
7
+ toolCallId: string;
8
+ toolName: string;
9
+ args: Record<string, unknown>;
10
+ }
11
+ export interface ToolExecutionEndEvent {
12
+ toolCallId: string;
13
+ toolName: string;
14
+ result: AgentToolResult;
15
+ isError: boolean;
16
+ }
17
+ export interface UseChatOptions {
18
+ api: string;
19
+ body?: () => Record<string, unknown>;
20
+ initialMessages?: Message[];
21
+ fetch?: typeof globalThis.fetch;
22
+ onMessage?: (message: Message) => void;
23
+ onFinish?: (message: AssistantMessage) => void;
24
+ onDecisionPending?: (event: ToolDecisionPendingEvent) => void;
25
+ onToolExecutionStart?: (event: ToolExecutionStartEvent) => void;
26
+ onToolExecutionEnd?: (event: ToolExecutionEndEvent) => void;
27
+ onError?: (error: unknown) => void;
28
+ }
29
+ export interface UseChatResult {
30
+ messages: Message[];
31
+ streamingMessage: AssistantMessage | null;
32
+ isStreaming: boolean;
33
+ pendingDecisions: ReadonlyMap<string, ToolDecisionPendingEvent>;
34
+ executingToolCallIds: ReadonlySet<string>;
35
+ error: unknown;
36
+ send: (input: string | Message) => Promise<void>;
37
+ sendMessages: (messages: Message[]) => Promise<void>;
38
+ setMessages: (msgs: Message[] | ((prev: Message[]) => Message[])) => void;
39
+ respondWithDecision: (toolCallId: string, value: unknown) => Promise<void>;
40
+ abort: () => void;
41
+ }
42
+ export declare function useChat(options: UseChatOptions): UseChatResult;
package/use-chat.js ADDED
@@ -0,0 +1,334 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.useChat = useChat;
4
+ const agent_1 = require("@agentic-kit/agent");
5
+ const agentic_kit_1 = require("agentic-kit");
6
+ const react_1 = require("react");
7
+ /**
8
+ * Compute the pendingDecisions map from a messages array. A toolCall counts as
9
+ * pending when it has neither a `decision` field nor a paired `toolResult`.
10
+ *
11
+ * Used on `setMessages` to recompute server-derived state from the new array.
12
+ * The synthesized event is a stub (no `input`, no `schema`) — the original
13
+ * decision-pending event is not recoverable from messages alone, but the map
14
+ * key (toolCallId) is what consumers actually need to render decision UI.
15
+ */
16
+ function rederivePendingDecisions(messages) {
17
+ const completed = new Set();
18
+ for (const m of messages) {
19
+ if (m.role === 'toolResult')
20
+ completed.add(m.toolCallId);
21
+ }
22
+ const pending = new Map();
23
+ for (const m of messages) {
24
+ if (m.role !== 'assistant')
25
+ continue;
26
+ for (const block of m.content) {
27
+ if (block.type !== 'toolCall')
28
+ continue;
29
+ if (completed.has(block.id))
30
+ continue;
31
+ if ('decision' in block && block.decision !== undefined)
32
+ continue;
33
+ pending.set(block.id, {
34
+ type: 'tool_decision_pending',
35
+ toolCallId: block.id,
36
+ toolName: block.name,
37
+ input: block.arguments,
38
+ schema: { type: 'object' },
39
+ });
40
+ }
41
+ }
42
+ return pending;
43
+ }
44
+ function useChat(options) {
45
+ const [messages, setMessagesState] = (0, react_1.useState)(() => options.initialMessages ?? []);
46
+ const [streamingMessage, setStreamingMessage] = (0, react_1.useState)(null);
47
+ const [isStreaming, setIsStreaming] = (0, react_1.useState)(false);
48
+ const [pendingDecisions, setPendingDecisions] = (0, react_1.useState)(() => rederivePendingDecisions(options.initialMessages ?? []));
49
+ const [executingToolCallIds, setExecutingToolCallIds] = (0, react_1.useState)(() => new Set());
50
+ const [error, setError] = (0, react_1.useState)(undefined);
51
+ const messagesRef = (0, react_1.useRef)(messages);
52
+ (0, react_1.useEffect)(() => {
53
+ messagesRef.current = messages;
54
+ }, [messages]);
55
+ const streamingMessageRef = (0, react_1.useRef)(null);
56
+ (0, react_1.useEffect)(() => {
57
+ streamingMessageRef.current = streamingMessage;
58
+ }, [streamingMessage]);
59
+ const optionsRef = (0, react_1.useRef)(options);
60
+ (0, react_1.useEffect)(() => {
61
+ optionsRef.current = options;
62
+ }, [options]);
63
+ const runIdRef = (0, react_1.useRef)(0);
64
+ const abortControllerRef = (0, react_1.useRef)(null);
65
+ const failRun = (0, react_1.useCallback)((err) => {
66
+ setError(err);
67
+ optionsRef.current.onError?.(err);
68
+ setIsStreaming(false);
69
+ }, []);
70
+ const runStream = (0, react_1.useCallback)(async (requestMessages, optimisticUserMessage) => {
71
+ const opts = optionsRef.current;
72
+ const myRun = ++runIdRef.current;
73
+ abortControllerRef.current?.abort();
74
+ const controller = new AbortController();
75
+ abortControllerRef.current = controller;
76
+ const isCurrent = () => runIdRef.current === myRun;
77
+ setIsStreaming(true);
78
+ setError(undefined);
79
+ setPendingDecisions(new Map());
80
+ setExecutingToolCallIds(new Set());
81
+ setStreamingMessage(null);
82
+ if (optimisticUserMessage) {
83
+ setMessagesState((prev) => [...prev, optimisticUserMessage]);
84
+ }
85
+ let skipUserEcho = optimisticUserMessage !== null;
86
+ const fetchFn = opts.fetch ?? globalThis.fetch;
87
+ const extraBody = opts.body?.() ?? {};
88
+ let response;
89
+ try {
90
+ response = await fetchFn(opts.api, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify({ messages: requestMessages, ...extraBody }),
94
+ signal: controller.signal,
95
+ });
96
+ }
97
+ catch (err) {
98
+ if (!isCurrent())
99
+ return;
100
+ if (controller.signal.aborted) {
101
+ if (isCurrent())
102
+ setIsStreaming(false);
103
+ return;
104
+ }
105
+ failRun(err);
106
+ return;
107
+ }
108
+ if (!isCurrent())
109
+ return;
110
+ if (!response.ok) {
111
+ failRun(new Error(`HTTP ${response.status}: ${response.statusText}`));
112
+ return;
113
+ }
114
+ if (!response.body) {
115
+ failRun(new Error('Response has no body'));
116
+ return;
117
+ }
118
+ try {
119
+ for await (const event of (0, agent_1.parseSSEStream)(response.body)) {
120
+ if (!isCurrent())
121
+ return;
122
+ switch (event.type) {
123
+ case 'message_start': {
124
+ if (skipUserEcho && event.message.role === 'user') {
125
+ skipUserEcho = false;
126
+ break;
127
+ }
128
+ if (event.message.role === 'assistant') {
129
+ setStreamingMessage(event.message);
130
+ }
131
+ break;
132
+ }
133
+ case 'message_update': {
134
+ setStreamingMessage(event.message);
135
+ break;
136
+ }
137
+ case 'message_end': {
138
+ if (event.message.role === 'assistant') {
139
+ setStreamingMessage(null);
140
+ setMessagesState((prev) => [...prev, event.message]);
141
+ }
142
+ else if (event.message.role === 'toolResult') {
143
+ setMessagesState((prev) => [...prev, event.message]);
144
+ }
145
+ opts.onMessage?.(event.message);
146
+ break;
147
+ }
148
+ case 'tool_execution_start': {
149
+ setExecutingToolCallIds((prev) => {
150
+ const next = new Set(prev);
151
+ next.add(event.toolCallId);
152
+ return next;
153
+ });
154
+ opts.onToolExecutionStart?.({
155
+ toolCallId: event.toolCallId,
156
+ toolName: event.toolName,
157
+ args: event.args,
158
+ });
159
+ break;
160
+ }
161
+ case 'tool_execution_end': {
162
+ setExecutingToolCallIds((prev) => {
163
+ if (!prev.has(event.toolCallId))
164
+ return prev;
165
+ const next = new Set(prev);
166
+ next.delete(event.toolCallId);
167
+ return next;
168
+ });
169
+ opts.onToolExecutionEnd?.({
170
+ toolCallId: event.toolCallId,
171
+ toolName: event.toolName,
172
+ result: event.result,
173
+ isError: event.isError,
174
+ });
175
+ break;
176
+ }
177
+ case 'tool_decision_pending': {
178
+ setPendingDecisions((prev) => {
179
+ const next = new Map(prev);
180
+ next.set(event.toolCallId, event);
181
+ return next;
182
+ });
183
+ opts.onDecisionPending?.(event);
184
+ break;
185
+ }
186
+ case 'agent_end': {
187
+ setStreamingMessage(null);
188
+ setMessagesState(() => {
189
+ if (!isCurrent())
190
+ return messagesRef.current;
191
+ return event.messages;
192
+ });
193
+ const lastAssistant = [...event.messages]
194
+ .reverse()
195
+ .find((m) => m.role === 'assistant');
196
+ if (lastAssistant) {
197
+ opts.onFinish?.(lastAssistant);
198
+ }
199
+ break;
200
+ }
201
+ }
202
+ }
203
+ }
204
+ catch (err) {
205
+ if (!isCurrent())
206
+ return;
207
+ if (controller.signal.aborted)
208
+ return;
209
+ failRun(err);
210
+ return;
211
+ }
212
+ finally {
213
+ if (isCurrent()) {
214
+ setIsStreaming(false);
215
+ abortControllerRef.current = null;
216
+ }
217
+ }
218
+ }, [failRun]);
219
+ const sendMessages = (0, react_1.useCallback)(async (msgs) => {
220
+ setMessagesState(msgs);
221
+ messagesRef.current = msgs;
222
+ await runStream(msgs, null);
223
+ }, [runStream]);
224
+ const send = (0, react_1.useCallback)(async (input) => {
225
+ const userMessage = typeof input === 'string' ? (0, agentic_kit_1.createUserMessage)(input) : input;
226
+ const requestMessages = [...messagesRef.current, userMessage];
227
+ messagesRef.current = requestMessages;
228
+ await runStream(requestMessages, userMessage);
229
+ }, [runStream]);
230
+ const respondWithDecision = (0, react_1.useCallback)(async (toolCallId, value) => {
231
+ const current = messagesRef.current;
232
+ let targetIdx = -1;
233
+ for (let i = current.length - 1; i >= 0; i--) {
234
+ const msg = current[i];
235
+ if (msg.role !== 'assistant')
236
+ continue;
237
+ const match = msg.content.find((block) => block.type === 'toolCall' && block.id === toolCallId);
238
+ if (!match)
239
+ continue;
240
+ if ('decision' in match && match.decision !== undefined)
241
+ continue;
242
+ targetIdx = i;
243
+ break;
244
+ }
245
+ if (targetIdx === -1) {
246
+ throw new Error(`No pending decision for toolCallId '${toolCallId}'`);
247
+ }
248
+ const target = current[targetIdx];
249
+ const updatedAssistant = {
250
+ ...target,
251
+ content: target.content.map((block) => {
252
+ if (block.type !== 'toolCall' || block.id !== toolCallId) {
253
+ return block;
254
+ }
255
+ return { ...block, decision: value };
256
+ }),
257
+ };
258
+ const requestMessages = [
259
+ ...current.slice(0, targetIdx),
260
+ updatedAssistant,
261
+ ...current.slice(targetIdx + 1),
262
+ ];
263
+ setMessagesState(requestMessages);
264
+ messagesRef.current = requestMessages;
265
+ setPendingDecisions((prev) => {
266
+ if (!prev.has(toolCallId))
267
+ return prev;
268
+ const next = new Map(prev);
269
+ next.delete(toolCallId);
270
+ return next;
271
+ });
272
+ await runStream(requestMessages, null);
273
+ }, [runStream]);
274
+ const setMessages = (0, react_1.useCallback)((update) => {
275
+ setMessagesState((prev) => {
276
+ const next = typeof update === 'function' ? update(prev) : update;
277
+ messagesRef.current = next;
278
+ setPendingDecisions(rederivePendingDecisions(next));
279
+ setExecutingToolCallIds(new Set());
280
+ setError(undefined);
281
+ return next;
282
+ });
283
+ }, []);
284
+ (0, react_1.useEffect)(() => {
285
+ return () => {
286
+ abortControllerRef.current?.abort();
287
+ abortControllerRef.current = null;
288
+ };
289
+ }, []);
290
+ const abort = (0, react_1.useCallback)(() => {
291
+ abortControllerRef.current?.abort();
292
+ abortControllerRef.current = null;
293
+ runIdRef.current++;
294
+ // Snapshot the in-flight assistant message before clearing so visible text
295
+ // survives the stop. Drop toolCall blocks — without results they'd surface
296
+ // as pending decisions via rederivePendingDecisions and re-pause the agent.
297
+ const partial = streamingMessageRef.current;
298
+ if (partial) {
299
+ const visible = partial.content.filter((b) => b.type === 'text' && b.text.length > 0);
300
+ if (visible.length > 0) {
301
+ setMessagesState((prev) => [...prev, { ...partial, content: visible }]);
302
+ }
303
+ }
304
+ setIsStreaming(false);
305
+ setStreamingMessage(null);
306
+ setExecutingToolCallIds(new Set());
307
+ setPendingDecisions(new Map());
308
+ }, []);
309
+ return (0, react_1.useMemo)(() => ({
310
+ messages,
311
+ streamingMessage,
312
+ isStreaming,
313
+ pendingDecisions,
314
+ executingToolCallIds,
315
+ error,
316
+ send,
317
+ sendMessages,
318
+ setMessages,
319
+ respondWithDecision,
320
+ abort,
321
+ }), [
322
+ messages,
323
+ streamingMessage,
324
+ isStreaming,
325
+ pendingDecisions,
326
+ executingToolCallIds,
327
+ error,
328
+ send,
329
+ sendMessages,
330
+ setMessages,
331
+ respondWithDecision,
332
+ abort,
333
+ ]);
334
+ }