use-agentenkit 0.2.3 → 0.4.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.
@@ -3,6 +3,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
3
3
  import { resolveConfig, routeUrl, withQuery, } from './config.js';
4
4
  import { mergeConfig, useAgentRunConfig } from './context.js';
5
5
  import { isToolError, messageToEntries, messageToEntry, stateActivity, toolCallOutcomes } from './format.js';
6
+ import { formatCursor, streamItemEvents } from './frames.js';
7
+ /** States in which a thread already has a run: a new one is refused. */
8
+ const ACTIVE = ['QUEUED', 'RUNNING', 'WAITING_FOR_INPUT'];
9
+ /** How long a closed stream waits before the thread is read again, doubled
10
+ * per try up to the cap. */
11
+ const RECONNECT_BASE_MS = 1_000;
12
+ const RECONNECT_MAX_MS = 30_000;
6
13
  /** Mark the call a result belongs to as done (or failed) on the entry that
7
14
  * announced it, so a tool card can flip state in place. */
8
15
  function settleToolCall(entries, toolCallId, result) {
@@ -21,19 +28,25 @@ function settleToolCall(entries, toolCallId, result) {
21
28
  });
22
29
  return touched ? next : entries;
23
30
  }
24
- /** The run a STATE_CHANGE speaks for, with the clock it carries: `startedAt`
25
- * while the run is running or waiting, `endedAt` once it ended. An event
31
+ /** The run a STATE_CHANGE speaks for, with the clock it carries:
32
+ * `enqueuedAt` while the run waits for a worker, `startedAt` once one has
33
+ * it, `endedAt` once it ended. A server that leaves a clock off still gets
34
+ * a timer: the frame's own `createdAt` is when the change happened. An event
26
35
  * without a run id says nothing about timing and leaves what is known. */
27
- function runFromStateChange(prev, state, p) {
36
+ function runFromStateChange(prev, state, p, at) {
28
37
  if (typeof p?.runId !== 'string')
29
38
  return prev;
30
39
  const same = prev?.id === p.runId;
31
40
  const next = { id: p.runId };
32
- const startedAt = typeof p.startedAt === 'string' ? p.startedAt : same ? prev?.startedAt : undefined;
41
+ const clock = (key, now) => typeof p[key] === 'string' ? p[key] : same && prev?.[key] ? prev[key] : now ? at : undefined;
42
+ const enqueuedAt = clock('enqueuedAt', state === 'QUEUED');
43
+ if (enqueuedAt)
44
+ next.enqueuedAt = enqueuedAt;
45
+ const startedAt = clock('startedAt', state === 'RUNNING' || state === 'WAITING_FOR_INPUT');
33
46
  if (startedAt)
34
47
  next.startedAt = startedAt;
35
48
  if (state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED') {
36
- const endedAt = typeof p.endedAt === 'string' ? p.endedAt : same ? prev?.endedAt : undefined;
49
+ const endedAt = clock('endedAt', true);
37
50
  if (endedAt)
38
51
  next.endedAt = endedAt;
39
52
  }
@@ -51,18 +64,59 @@ function latestRun(runs) {
51
64
  if (!latest)
52
65
  return null;
53
66
  const run = { id: latest.id };
54
- if (latest.startedAt)
67
+ if (latest.enqueuedAt)
68
+ run.enqueuedAt = latest.enqueuedAt;
69
+ // A run still waiting has no start of its own: its record's startedAt is
70
+ // the enqueue time until a worker picks it up.
71
+ if (latest.startedAt && latest.state !== 'QUEUED')
55
72
  run.startedAt = latest.startedAt;
56
73
  if (latest.endedAt)
57
74
  run.endedAt = latest.endedAt;
58
75
  return run;
59
76
  }
77
+ /** The number a live entry was given (see nextLive), or 0 for any other. */
78
+ function liveSerialOf(id) {
79
+ return id.startsWith('live:') ? Number(id.slice(id.lastIndexOf(':') + 1)) || 0 : 0;
80
+ }
81
+ /** Add streamed text to the conversation: onto the live entry of its kind
82
+ * when that is the last one and belongs to the same step, as a new live
83
+ * entry otherwise. */
84
+ function appendDelta(prev, d) {
85
+ const prefix = d.kind === 'text' ? 'live:assistant:' : 'live:reasoning:';
86
+ const last = prev.at(-1);
87
+ if (last?.kind === d.kind && last.id.startsWith(prefix) && !last.agentId && liveSerialOf(last.id) > d.floor) {
88
+ const text = last.text + d.text;
89
+ return [...prev.slice(0, -1), { ...last, text, parts: [{ type: d.kind, text }] }];
90
+ }
91
+ return [
92
+ ...prev,
93
+ { id: `${prefix}${d.serial}`, kind: d.kind, role: 'assistant', text: d.text, parts: [{ type: d.kind, text: d.text }] },
94
+ ];
95
+ }
96
+ /** Run `fn` on the next frame, or soon after where there are no frames. */
97
+ function nextFrame(fn) {
98
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
99
+ const id = window.requestAnimationFrame(fn);
100
+ return () => window.cancelAnimationFrame(id);
101
+ }
102
+ const id = setTimeout(fn, 16);
103
+ return () => clearTimeout(id);
104
+ }
60
105
  /** Hydrates durable messages first, then resumes the canonical event stream at
61
106
  * the snapshot cursor, so a reload — or a second tab — rebuilds the same
62
107
  * conversation with the server as the only source of truth.
63
108
  *
64
109
  * Every endpoint, label and formatter is replaceable through the options or a
65
110
  * surrounding provider; see `AgentRunConfig`. */
111
+ /** The platform's own event types. Any other type on the thread is an
112
+ * app's event, handed to `onCustom` as well. */
113
+ const PLATFORM_TYPES = new Set([
114
+ 'CHUNK', 'STATE_CHANGE', 'STEP_COMMITTED', 'STEP_FINISHED', 'INPUT_REQUIRED', 'INPUT_EXPIRED',
115
+ 'HITL_RESPONSE', 'MESSAGE_APPENDED', 'MESSAGES_DROPPED', 'CONTEXT_COMPACTED', 'SUBAGENT_STARTED',
116
+ 'SUBAGENT_CHUNK', 'SUBAGENT_COMPLETED', 'SUBAGENT_FAILED', 'TEXT_RESULT', 'THREAD_DELETED', 'HEARTBEAT',
117
+ 'RUN_REFUSED', 'TOKEN_BUDGET_EXHAUSTED', 'COST_BUDGET_EXHAUSTED', 'RUN_STARTED', 'RUN_ENDED',
118
+ 'RECORD_CHANGED', 'SNAPSHOT',
119
+ ]);
66
120
  export function useAgentThread(options = {}) {
67
121
  const { initialThreadId, ...config } = options;
68
122
  // Provider first, own options over it. Resolved every render rather than
@@ -77,7 +131,7 @@ export function useAgentThread(options = {}) {
77
131
  const [threadId, setThreadId] = useState(initialThreadId);
78
132
  const [entries, setEntries] = useState([]);
79
133
  const [agentState, setAgentState] = useState('IDLE');
80
- const [activity, setActivity] = useState(() => stateActivity('IDLE', resolved.labels));
134
+ const [activity, setActivityState] = useState(() => stateActivity('IDLE', resolved.labels));
81
135
  const [historyLoading, setHistoryLoading] = useState(false);
82
136
  // A parent step can park several nested runs at once, so the run waits on a
83
137
  // SET of approvals — the thread resumes when the last one is answered.
@@ -87,8 +141,26 @@ export function useAgentThread(options = {}) {
87
141
  const [threadsLoading, setThreadsLoading] = useState(resolved.loadThreadsOnMount);
88
142
  const [usage, setUsage] = useState(null);
89
143
  const [currentRun, setCurrentRun] = useState(null);
144
+ const [connection, setConnection] = useState('connecting');
145
+ const [error, setError] = useState(null);
146
+ /** Bumped to read the thread again and open a new stream after the old one
147
+ * closed for good. */
148
+ const [reloadKey, setReloadKey] = useState(0);
90
149
  const threadRef = useRef(threadId);
91
150
  threadRef.current = threadId;
151
+ /** What the last render showed, for `run()` to put back if a send fails. */
152
+ const shownRef = useRef({ entries, agentState, activity, pendingInputs, subagents, currentRun });
153
+ shownRef.current = { entries, agentState, activity, pendingInputs, subagents, currentRun };
154
+ /** The activity as last set, so a delta only re-renders when the phase
155
+ * actually changes. */
156
+ const activityRef = useRef(activity);
157
+ const setActivity = useCallback((next) => {
158
+ setActivityState((current) => {
159
+ const value = typeof next === 'function' ? next(current) : next;
160
+ activityRef.current = value;
161
+ return value;
162
+ });
163
+ }, []);
92
164
  /** When each run ended, as its terminal STATE_CHANGE said. A stop is
93
165
  * accepted before the worker that held the run has torn down, so a
94
166
  * snapshot taken in between can still lack the end; the event's word
@@ -101,6 +173,80 @@ export function useAgentThread(options = {}) {
101
173
  /** Results already visible from the durable messages, kept apart from the
102
174
  * calls: a live result must still render after its live call did. */
103
175
  const seenToolResults = useRef(new Set());
176
+ /** The seq of the last event applied. An event at or below it is one this
177
+ * client already has — a replay, or a transport that resent — and is
178
+ * dropped before anything sees it. Notices (seq 0) always pass. */
179
+ const lastSeqRef = useRef(-1);
180
+ /** Numbers the live entries. Not the event's seq: a run stream item has
181
+ * none (it arrives as seq 0), and two entries with one id share a React
182
+ * key. */
183
+ const liveSerial = useRef(0);
184
+ const nextLive = () => ++liveSerial.current;
185
+ /** The last live number from a step the server saved: its step ended, or
186
+ * a new segment began. What a failed step streamed after it was never
187
+ * saved, and is dropped. */
188
+ const committedSerial = useRef(0);
189
+ /** The run stream being read: its id, the offset of the last item
190
+ * applied, and every offset applied from it. An item already applied —
191
+ * one a snapshot covered, or a transport resent — is dropped. A new stream
192
+ * starts afresh. */
193
+ const streamRef = useRef({ seen: new Set() });
194
+ /** The messages the latest snapshot held, so a SNAPSHOT frame, which
195
+ * carries only the newer ones, can be laid over them. */
196
+ const messagesRef = useRef([]);
197
+ /** Streamed text waiting for the next frame. */
198
+ const pendingDeltas = useRef([]);
199
+ const cancelFlush = useRef(null);
200
+ /** A send is on its way: a second one would race it (two threads made by
201
+ * two fast sends on a new thread). */
202
+ const sending = useRef(false);
203
+ /** Only the latest thread-list request may land. */
204
+ const threadsRequest = useRef(0);
205
+ /** Closed streams in a row, for the reconnect backoff. */
206
+ const reconnectTries = useRef(0);
207
+ /** The thread the per-thread state belongs to. */
208
+ const shownThread = useRef(threadId);
209
+ /** Show the streamed text gathered so far, in one render. */
210
+ const flushDeltas = useCallback(() => {
211
+ cancelFlush.current?.();
212
+ cancelFlush.current = null;
213
+ const batch = pendingDeltas.current;
214
+ if (batch.length === 0)
215
+ return;
216
+ pendingDeltas.current = [];
217
+ setEntries((prev) => batch.reduce(appendDelta, prev));
218
+ }, []);
219
+ const queueDelta = useCallback((d) => {
220
+ const batch = pendingDeltas.current;
221
+ const last = batch.at(-1);
222
+ if (last?.kind === d.kind)
223
+ last.text += d.text;
224
+ else
225
+ batch.push({ ...d });
226
+ cancelFlush.current ??= nextFrame(() => {
227
+ cancelFlush.current = null;
228
+ flushDeltas();
229
+ });
230
+ }, [flushDeltas]);
231
+ /** Everything that belongs to one thread, back to empty. */
232
+ const clearThreadState = useCallback(() => {
233
+ cancelFlush.current?.();
234
+ cancelFlush.current = null;
235
+ pendingDeltas.current = [];
236
+ lastSeqRef.current = -1;
237
+ streamRef.current = { seen: new Set() };
238
+ messagesRef.current = [];
239
+ // runEndings stays: it is keyed by run id, and a stop's end must outlive
240
+ // a switch away and back.
241
+ seenToolCalls.current = new Set();
242
+ seenToolResults.current = new Set();
243
+ setEntries([]);
244
+ setSubagents([]);
245
+ setPendingInputs([]);
246
+ setUsage(null);
247
+ setCurrentRun(null);
248
+ setError(null);
249
+ }, []);
104
250
  /** One place where the caller's headers and fetch are applied. */
105
251
  const request = useCallback(async (url, init = {}) => {
106
252
  const cfg = cfgRef.current;
@@ -134,22 +280,26 @@ export function useAgentThread(options = {}) {
134
280
  // usage is a read-only extra — never break the conversation over it
135
281
  }
136
282
  }, [request]);
137
- /** Thread picker / sidebar: best-effort refresh, most recent first. */
283
+ /** Thread picker / sidebar: best-effort refresh, most recent first. Only
284
+ * the latest request lands: an older, slower answer never replaces it. */
138
285
  const loadThreads = useCallback(async () => {
139
286
  const cfg = cfgRef.current;
287
+ const mine = ++threadsRequest.current;
140
288
  try {
141
289
  setThreadsLoading(true);
142
290
  const res = await request(cfg.baseUrl + cfg.routes.threads);
143
- if (!res.ok)
291
+ if (!res.ok || mine !== threadsRequest.current)
144
292
  return;
145
293
  const data = await res.json();
146
- setThreads(data.threads ?? []);
294
+ if (mine === threadsRequest.current)
295
+ setThreads(data.threads ?? []);
147
296
  }
148
297
  catch {
149
298
  // sidebar is best-effort — ignore transport errors
150
299
  }
151
300
  finally {
152
- setThreadsLoading(false);
301
+ if (mine === threadsRequest.current)
302
+ setThreadsLoading(false);
153
303
  }
154
304
  }, [request]);
155
305
  useEffect(() => {
@@ -168,16 +318,13 @@ export function useAgentThread(options = {}) {
168
318
  }, [loadThreads, resolved.threadsRefreshMs]);
169
319
  /** Start a new thread: clear the pointer so the next run creates one. */
170
320
  const newThread = useCallback(() => {
321
+ clearThreadState();
322
+ shownThread.current = undefined;
171
323
  setThreadId(undefined);
172
- setEntries([]);
173
- setSubagents([]);
174
- setPendingInputs([]);
175
- setUsage(null);
176
- setCurrentRun(null);
177
324
  setAgentState('IDLE');
178
325
  setActivity(stateActivity('IDLE', cfgRef.current.labels));
179
326
  cfgRef.current.persistence?.clear();
180
- }, []);
327
+ }, [clearThreadState, setActivity]);
181
328
  /** Select an existing thread — hydration and stream resume run in the
182
329
  * threadId effect below. */
183
330
  const selectThread = useCallback((id) => {
@@ -210,9 +357,34 @@ export function useAgentThread(options = {}) {
210
357
  setThreadId(recovered);
211
358
  }, [initialThreadId]);
212
359
  const applyEvent = useCallback((data) => {
360
+ // Already applied: a replay the snapshot covered, or a transport that
361
+ // resent after reconnecting. Dropped before anything sees it.
362
+ if (data.seq !== 0 && data.seq <= lastSeqRef.current)
363
+ return;
364
+ if (data.seq !== 0)
365
+ lastSeqRef.current = data.seq;
213
366
  const cfg = cfgRef.current;
214
367
  const { labels, format } = cfg;
215
368
  const p = data.payload ?? {};
369
+ // Streamed text is gathered and shown once per frame; the activity only
370
+ // changes when the phase does.
371
+ if (data.type === 'CHUNK' && (p?.type === 'text-delta' || p?.type === 'reasoning')) {
372
+ if (cfg.onEvent?.(data) === true)
373
+ return;
374
+ const kind = p.type === 'text-delta' ? 'text' : 'reasoning';
375
+ const phase = kind === 'text' ? 'responding' : 'thinking';
376
+ if (activityRef.current.phase !== phase) {
377
+ setActivity({ phase, label: kind === 'text' ? labels.responding : labels.thinking });
378
+ }
379
+ // Providers that do not expose reasoning send none; an empty one is
380
+ // only a phase change.
381
+ if (typeof p.textDelta === 'string' && p.textDelta) {
382
+ queueDelta({ kind, text: p.textDelta, serial: nextLive(), floor: committedSerial.current });
383
+ }
384
+ return;
385
+ }
386
+ // Anything else lands after the text before it.
387
+ flushDeltas();
216
388
  // The app sees every event first, and can claim it.
217
389
  if (cfg.onEvent?.(data) === true)
218
390
  return;
@@ -223,7 +395,7 @@ export function useAgentThread(options = {}) {
223
395
  if (typeof p.runId === 'string' && typeof p.endedAt === 'string') {
224
396
  runEndings.current.set(p.runId, p.endedAt);
225
397
  }
226
- setCurrentRun((prev) => runFromStateChange(prev, nextState, p));
398
+ setCurrentRun((prev) => runFromStateChange(prev, nextState, p, data.createdAt));
227
399
  if (nextState === 'RUNNING') {
228
400
  // The park was resolved: every child that was waiting is re-entered
229
401
  // where it stopped.
@@ -234,7 +406,13 @@ export function useAgentThread(options = {}) {
234
406
  ['thinking', 'responding', 'tool-call', 'tool-result'].includes(current.phase)) {
235
407
  return current;
236
408
  }
237
- return stateActivity(nextState, labels);
409
+ // The park already said what it waits on: a tool waiting on its
410
+ // own work is not "waiting for approval".
411
+ if (nextState === 'WAITING_FOR_INPUT' && current.phase === 'waiting-input')
412
+ return current;
413
+ const next = stateActivity(nextState, labels);
414
+ // A failure says why.
415
+ return nextState === 'FAILED' && typeof p.error === 'string' ? { ...next, detail: p.error } : next;
238
416
  });
239
417
  if (nextState !== 'WAITING_FOR_INPUT')
240
418
  setPendingInputs([]);
@@ -246,9 +424,10 @@ export function useAgentThread(options = {}) {
246
424
  }
247
425
  break;
248
426
  }
249
- // Another client sent a message on this thread. The sending client
250
- // added it to its own state before the request went out, so this is
251
- // where every OTHER one learns what was asked.
427
+ // Another client sent a message on this thread, or this one's send
428
+ // was confirmed. The sending client added it to its own state before
429
+ // the request went out, so this is where every OTHER one learns what
430
+ // was asked.
252
431
  case 'MESSAGE_APPENDED': {
253
432
  const entry = messageToEntry({
254
433
  id: String(p.id),
@@ -261,10 +440,14 @@ export function useAgentThread(options = {}) {
261
440
  setEntries((prev) => {
262
441
  // Already have it — a replayed event, or our own optimistic copy
263
442
  // now confirmed. Replace the optimistic one so the real id lands
264
- // (editing a message needs it), otherwise it would show twice.
443
+ // (editing a message needs it), otherwise it would show twice. It
444
+ // is found by the id this client gave it; a server that does not
445
+ // echo one is matched by text.
265
446
  if (prev.some((e) => e.id === entry.id))
266
447
  return prev;
267
- const optimistic = prev.findIndex((e) => e.id.startsWith('optimistic:user:') && e.text === entry.text);
448
+ const optimistic = typeof p.clientMessageId === 'string'
449
+ ? prev.findIndex((e) => e.id === `optimistic:user:${p.clientMessageId}`)
450
+ : prev.findIndex((e) => e.id.startsWith('optimistic:user:') && e.text === entry.text);
268
451
  if (optimistic !== -1) {
269
452
  const next = [...prev];
270
453
  next[optimistic] = entry;
@@ -283,61 +466,51 @@ export function useAgentThread(options = {}) {
283
466
  });
284
467
  break;
285
468
  }
286
- case 'CHUNK': {
287
- if (p?.type === 'text-delta' && typeof p.textDelta === 'string') {
288
- setActivity({ phase: 'responding', label: labels.responding });
289
- setEntries((prev) => {
290
- const last = prev.at(-1);
291
- if (last?.kind === 'text' && last.id.startsWith('live:assistant:') && !last.agentId) {
292
- const text = last.text + p.textDelta;
293
- return [...prev.slice(0, -1), { ...last, text, parts: [{ type: 'text', text }] }];
294
- }
295
- return [
296
- ...prev,
297
- {
298
- id: `live:assistant:${data.seq}`,
299
- kind: 'text',
300
- role: 'assistant',
301
- text: p.textDelta,
302
- parts: [{ type: 'text', text: p.textDelta }],
303
- },
304
- ];
305
- });
306
- }
307
- else if (p?.type === 'reasoning') {
308
- // The model's thinking, streamed like the answer but kept apart so
309
- // a UI can show it live and fold it away after. Providers that do
310
- // not expose reasoning never send these.
311
- setActivity({ phase: 'thinking', label: labels.thinking });
312
- if (typeof p.textDelta === 'string' && p.textDelta) {
313
- setEntries((prev) => {
314
- const last = prev.at(-1);
315
- if (last?.kind === 'reasoning' && last.id.startsWith('live:reasoning:')) {
316
- const text = last.text + p.textDelta;
317
- return [...prev.slice(0, -1), { ...last, text, parts: [{ type: 'reasoning', text }] }];
318
- }
319
- return [
320
- ...prev,
321
- {
322
- id: `live:reasoning:${data.seq}`,
323
- kind: 'reasoning',
324
- role: 'assistant',
325
- text: p.textDelta,
326
- parts: [{ type: 'reasoning', text: p.textDelta }],
327
- },
328
- ];
329
- });
330
- }
469
+ // A generate-text agent streams nothing: its whole answer arrives here.
470
+ case 'TEXT_RESULT': {
471
+ if (typeof p.text !== 'string' || !p.text)
472
+ break;
473
+ const id = `live:text-result:${nextLive()}`;
474
+ setEntries((prev) => [
475
+ ...prev,
476
+ {
477
+ id,
478
+ kind: 'text',
479
+ role: 'assistant',
480
+ text: p.text,
481
+ parts: [{ type: 'text', text: p.text }],
482
+ },
483
+ ]);
484
+ break;
485
+ }
486
+ // The thread is gone, deleted here or elsewhere: start afresh.
487
+ case 'THREAD_DELETED': {
488
+ if (!p.threadId || p.threadId === threadRef.current) {
489
+ newThread();
490
+ void loadThreads();
331
491
  }
332
- else if (p?.type === 'source') {
492
+ break;
493
+ }
494
+ // An approval was answered, here or in another tab: its card goes.
495
+ case 'HITL_RESPONSE': {
496
+ setPendingInputs((prev) => prev.filter((r) => r.toolCallId !== p.toolCallId));
497
+ break;
498
+ }
499
+ // The server would not start the run: billing, a full queue.
500
+ case 'RUN_REFUSED': {
501
+ const why = typeof p.error === 'string' ? p.error : String(p.reason ?? '');
502
+ setError(why || labels.runRefused);
503
+ setActivity({ phase: 'failed', label: labels.runRefused, ...(why ? { detail: why } : {}) });
504
+ break;
505
+ }
506
+ case 'CHUNK': {
507
+ if (p?.type === 'source') {
333
508
  setActivity({ phase: 'thinking', label: labels.reviewingSources });
334
509
  }
335
510
  else if (p?.type === 'tool-call-streaming-start' || p?.type === 'tool-call-delta') {
336
- setActivity({
337
- phase: 'tool-call',
338
- label: labels.preparingToolCall,
339
- detail: p.toolName,
340
- });
511
+ if (activityRef.current.phase !== 'tool-call' || activityRef.current.detail !== p.toolName) {
512
+ setActivity({ phase: 'tool-call', label: labels.preparingToolCall, detail: p.toolName });
513
+ }
341
514
  }
342
515
  else if (p?.type === 'tool-call') {
343
516
  if (p.toolCallId && seenToolCalls.current.has(p.toolCallId))
@@ -345,10 +518,11 @@ export function useAgentThread(options = {}) {
345
518
  if (p.toolCallId)
346
519
  seenToolCalls.current.add(p.toolCallId);
347
520
  setActivity({ phase: 'tool-call', label: labels.callingTool, detail: p.toolName });
521
+ const id = `live:tool-call:${nextLive()}`;
348
522
  setEntries((prev) => [
349
523
  ...prev,
350
524
  {
351
- id: `live:tool-call:${data.seq}`,
525
+ id,
352
526
  kind: 'tool',
353
527
  role: 'tool',
354
528
  text: format.toolCall(p.toolName, p.args ?? {}),
@@ -374,10 +548,11 @@ export function useAgentThread(options = {}) {
374
548
  if (p.toolCallId)
375
549
  seenToolResults.current.add(p.toolCallId);
376
550
  setActivity({ phase: 'tool-result', label: labels.toolCompleted, detail: p.toolName });
551
+ const id = `live:tool-result:${nextLive()}`;
377
552
  setEntries((prev) => [
378
553
  ...settleToolCall(prev, p.toolCallId, p.result),
379
554
  {
380
- id: `live:tool-result:${data.seq}`,
555
+ id,
381
556
  kind: 'tool',
382
557
  role: 'tool',
383
558
  text: format.toolResult(p.toolName, p.result),
@@ -423,12 +598,13 @@ export function useAgentThread(options = {}) {
423
598
  setPendingInputs((prev) => prev.filter((r) => r.toolCallId !== p.toolCallId));
424
599
  setActivity({ phase: 'failed', label: labels.approvalExpired });
425
600
  break;
426
- case 'SUBAGENT_STARTED':
601
+ case 'SUBAGENT_STARTED': {
427
602
  setActivity({ phase: 'tool-call', label: labels.subagentWorking, detail: p.name });
603
+ const id = `live:subagent:${p.agentId}:${nextLive()}`;
428
604
  setEntries((prev) => [
429
605
  ...prev,
430
606
  {
431
- id: `live:subagent:${p.agentId}:${data.seq}`,
607
+ id,
432
608
  kind: 'tool',
433
609
  role: 'tool',
434
610
  text: format.subagentStarted(p.name),
@@ -449,8 +625,13 @@ export function useAgentThread(options = {}) {
449
625
  },
450
626
  ]);
451
627
  break;
628
+ }
452
629
  case 'SUBAGENT_CHUNK':
453
- setSubagents((prev) => prev.map((s) => s.agentId === p.agentId ? { ...s, text: s.text + (p.chunk?.textDelta ?? '') } : s));
630
+ // The child's answer only: its thinking and its tool traffic are
631
+ // not what it said.
632
+ if (p.chunk?.type !== 'text-delta' || typeof p.chunk.textDelta !== 'string')
633
+ break;
634
+ setSubagents((prev) => prev.map((s) => s.agentId === p.agentId ? { ...s, text: s.text + p.chunk.textDelta } : s));
454
635
  break;
455
636
  case 'SUBAGENT_COMPLETED':
456
637
  setActivity({ phase: 'tool-result', label: labels.subagentCompleted, detail: p.name });
@@ -462,25 +643,204 @@ export function useAgentThread(options = {}) {
462
643
  : s));
463
644
  break;
464
645
  }
465
- }, [loadThreads, loadUsage]);
646
+ }, [flushDeltas, loadThreads, loadUsage, newThread, queueDelta, setActivity]);
647
+ /** One item from a run stream. A new stream starts its own cursor; an
648
+ * item already applied from this one is dropped. The reducer sees it as
649
+ * the thread event it stands for (see streamItemEvents). */
650
+ const applyStreamItem = useCallback((streamId, item) => {
651
+ const at = streamRef.current;
652
+ if (at.streamId !== streamId) {
653
+ streamRef.current = { streamId, seen: new Set() };
654
+ committedSerial.current = liveSerial.current;
655
+ }
656
+ const cur = streamRef.current;
657
+ if (cur.seen.has(item.offset))
658
+ return;
659
+ cur.seen.add(item.offset);
660
+ cur.offset = item.offset;
661
+ if (item.type === 'CUSTOM')
662
+ cfgRef.current.onCustom?.(String(item.name), item.value);
663
+ for (const event of streamItemEvents(item))
664
+ applyEvent(event);
665
+ if (item.type === 'STEP_FINISHED') {
666
+ flushDeltas();
667
+ committedSerial.current = liveSerial.current;
668
+ }
669
+ else if (item.type === 'RUN_ERROR') {
670
+ // The step that failed was never saved, and a retry streams it
671
+ // again: what it showed goes, so the retry does not add to it. A
672
+ // subagent's card stays; its run is its own.
673
+ flushDeltas();
674
+ const floor = committedSerial.current;
675
+ setEntries((prev) => prev.filter((e) => e.id.startsWith('live:subagent:') || liveSerialOf(e.id) <= floor));
676
+ }
677
+ }, [applyEvent, flushDeltas]);
678
+ /** Show a snapshot: the durable messages, the subagent cards, the run's
679
+ * clocks, the unfinished run's record entries and its run stream. The
680
+ * history read and a SNAPSHOT frame both come through here. */
681
+ const hydrate = useCallback((snapshot, forThread) => {
682
+ const cfg = cfgRef.current;
683
+ // The snapshot is the whole durable truth: a re-read after a closed
684
+ // stream starts from it too, with nothing streamed half-shown.
685
+ cancelFlush.current?.();
686
+ cancelFlush.current = null;
687
+ pendingDeltas.current = [];
688
+ // A nested run's turns live in the same log under its own agentId.
689
+ // They are its transcript, not the main conversation's.
690
+ messagesRef.current = snapshot.messages;
691
+ const mainMessages = snapshot.messages.filter((m) => (m.agentId ?? null) === null);
692
+ // A durable result settles its call, as done or as failed: a denied
693
+ // approval or a stop never streams a result, so this is where a
694
+ // reload learns how those calls ended.
695
+ const outcomes = toolCallOutcomes(snapshot.messages);
696
+ setEntries(mainMessages.flatMap((m) => messageToEntries(m, cfg.format, outcomes)));
697
+ // Rebuild each child's card from what it actually wrote, so a reload
698
+ // does not lose a subagent's output.
699
+ const durableParts = snapshot.messages.flatMap((m) => Array.isArray(m.content) ? m.content : []);
700
+ seenToolCalls.current = new Set(durableParts
701
+ .filter((part) => part?.type === 'tool-call')
702
+ .map((part) => part.toolCallId)
703
+ .filter((id) => typeof id === 'string'));
704
+ seenToolResults.current = new Set(outcomes.keys());
705
+ // Name, depth and final state come from the durable run rows; the
706
+ // SUBAGENT_* events only replay while a run is unfinished, so on a
707
+ // completed thread they are all a client has.
708
+ const byAgent = new Map(
709
+ // Nested runs only: depth 0 is this thread's own dispatched run,
710
+ // which the transcript already represents.
711
+ (snapshot.runs ?? [])
712
+ .filter((r) => r.depth > 0)
713
+ .map((r) => [
714
+ r.id,
715
+ // A nested run is never queued: it runs inside its parent's
716
+ // segment. The type allows QUEUED for the dispatched run only.
717
+ {
718
+ agentId: r.id,
719
+ name: r.agent,
720
+ depth: r.depth,
721
+ status: r.state === 'QUEUED' ? 'RUNNING' : r.state,
722
+ text: '',
723
+ },
724
+ ]));
725
+ for (const m of snapshot.messages) {
726
+ const id = m.agentId ?? null;
727
+ if (id === null)
728
+ continue;
729
+ const view = byAgent.get(id) ?? {
730
+ agentId: id,
731
+ name: id.slice(0, 8),
732
+ depth: 1,
733
+ status: 'RUNNING',
734
+ text: '',
735
+ };
736
+ if (m.role === 'assistant') {
737
+ const text = messageToEntry(m, cfg.format)?.text ?? '';
738
+ if (text)
739
+ view.text = view.text ? `${view.text}\n${text}` : text;
740
+ }
741
+ byAgent.set(id, view);
742
+ }
743
+ // Trailing break so live deltas from a resumed child start on their
744
+ // own line instead of running into what it already wrote.
745
+ for (const view of byAgent.values())
746
+ if (view.text)
747
+ view.text += '\n';
748
+ setSubagents([...byAgent.values()]);
749
+ setPendingInputs([]);
750
+ void loadUsage(forThread);
751
+ // The latest run's clocks. An end this client already saw on the
752
+ // wire stands over a snapshot that does not carry it yet.
753
+ const run = latestRun(snapshot.runs);
754
+ if (run && !run.endedAt) {
755
+ const ended = runEndings.current.get(run.id);
756
+ if (ended)
757
+ run.endedAt = ended;
758
+ }
759
+ setCurrentRun(run);
760
+ setAgentState(snapshot.thread.state);
761
+ setActivity(stateActivity(snapshot.thread.state, cfg.labels));
762
+ // The active run's record entries are at or below the snapshot's
763
+ // cursor, so the cursor starts before them; after, it is the
764
+ // snapshot's.
765
+ lastSeqRef.current = -1;
766
+ for (const event of snapshot.activeEvents)
767
+ applyEvent(event);
768
+ // The run stream's items the messages do not have yet, then the
769
+ // stream's own offset: a live read picks up after it.
770
+ streamRef.current = { seen: new Set() };
771
+ if (snapshot.stream) {
772
+ for (const item of snapshot.stream.items)
773
+ applyStreamItem(snapshot.stream.streamId, item);
774
+ streamRef.current.streamId = snapshot.stream.streamId;
775
+ if (snapshot.stream.offset)
776
+ streamRef.current.offset = snapshot.stream.offset;
777
+ }
778
+ flushDeltas();
779
+ // A stream that already ended says nothing about now: its replayed
780
+ // text must not leave the thread "Responding".
781
+ if (snapshot.stream?.end)
782
+ setActivity(stateActivity(snapshot.thread.state, cfg.labels));
783
+ lastSeqRef.current = Math.max(lastSeqRef.current, snapshot.lastEventSeq);
784
+ }, [applyEvent, applyStreamItem, flushDeltas, loadUsage, setActivity]);
785
+ /** Where the hook is, as the server reads it. */
786
+ const cursor = useCallback(() => formatCursor({ seq: lastSeqRef.current, streamId: streamRef.current.streamId, offset: streamRef.current.offset }), []);
787
+ /** One frame off the wire: a thread event, a run stream item, or a
788
+ * SNAPSHOT that lays the messages it carries over the ones on screen.
789
+ * A plain event is what a server older than run streams sends. */
790
+ const applyFrame = useCallback((frame, forThread) => {
791
+ if (!('kind' in frame)) {
792
+ applyEvent(frame);
793
+ return;
794
+ }
795
+ switch (frame.kind) {
796
+ case 'thread':
797
+ if (!PLATFORM_TYPES.has(frame.event.type) && (frame.event.seq === 0 || frame.event.seq > lastSeqRef.current)) {
798
+ cfgRef.current.onCustom?.(frame.event.type, frame.event.payload);
799
+ }
800
+ applyEvent(frame.event);
801
+ return;
802
+ case 'stream':
803
+ applyStreamItem(frame.streamId, frame.item);
804
+ return;
805
+ case 'snapshot': {
806
+ const known = new Set(messagesRef.current.map((m) => m.id));
807
+ const messages = [...messagesRef.current, ...frame.snapshot.messages.filter((m) => !known.has(m.id))];
808
+ hydrate({ ...frame.snapshot, messages }, forThread);
809
+ return;
810
+ }
811
+ }
812
+ }, [applyEvent, applyStreamItem, hydrate]);
466
813
  useEffect(() => {
467
814
  if (!threadId)
468
815
  return;
469
816
  const cfg = cfgRef.current;
470
817
  cfg.persistence?.save(threadId);
818
+ // Another thread's messages, cards and run must not stay on screen while
819
+ // this one loads. A thread this client just created keeps what it shows:
820
+ // that is its own optimistic turn.
821
+ if (shownThread.current !== undefined && shownThread.current !== threadId)
822
+ clearThreadState();
823
+ shownThread.current = threadId;
471
824
  let cancelled = false;
472
825
  let stream;
826
+ let retry;
827
+ // A slow answer for a thread the user already left must not land.
828
+ const abort = new AbortController();
473
829
  setHistoryLoading(true);
830
+ setConnection('connecting');
474
831
  setActivity({ phase: 'loading', label: cfg.labels.loading });
475
832
  void (async () => {
476
833
  try {
477
- const res = await request(routeUrl(cfg.routes.history, { threadId }, cfg.baseUrl));
834
+ const res = await request(routeUrl(cfg.routes.history, { threadId }, cfg.baseUrl), {
835
+ signal: abort.signal,
836
+ });
837
+ if (cancelled)
838
+ return;
478
839
  if (res.status === 404) {
479
840
  cfg.persistence?.clear();
841
+ clearThreadState();
842
+ shownThread.current = undefined;
480
843
  setThreadId(undefined);
481
- setEntries([]);
482
- setUsage(null);
483
- setCurrentRun(null);
484
844
  setAgentState('IDLE');
485
845
  setActivity(stateActivity('IDLE', cfg.labels));
486
846
  return;
@@ -490,89 +850,58 @@ export function useAgentThread(options = {}) {
490
850
  const snapshot = (await res.json());
491
851
  if (cancelled)
492
852
  return;
493
- // A nested run's turns live in the same log under its own agentId.
494
- // They are its transcript, not the main conversation's.
495
- const mainMessages = snapshot.messages.filter((m) => (m.agentId ?? null) === null);
496
- // A durable result settles its call, as done or as failed: a denied
497
- // approval or a stop never streams a result, so this is where a
498
- // reload learns how those calls ended.
499
- const outcomes = toolCallOutcomes(snapshot.messages);
500
- setEntries(mainMessages.flatMap((m) => messageToEntries(m, cfg.format, outcomes)));
501
- // Rebuild each child's card from what it actually wrote, so a reload
502
- // does not lose a subagent's output.
503
- const durableParts = snapshot.messages.flatMap((m) => Array.isArray(m.content) ? m.content : []);
504
- seenToolCalls.current = new Set(durableParts
505
- .filter((part) => part?.type === 'tool-call')
506
- .map((part) => part.toolCallId)
507
- .filter((id) => typeof id === 'string'));
508
- seenToolResults.current = new Set(outcomes.keys());
509
- // Name, depth and final state come from the durable run rows; the
510
- // SUBAGENT_* events only replay while a run is unfinished, so on a
511
- // completed thread they are all a client has.
512
- const byAgent = new Map(
513
- // Nested runs only: depth 0 is this thread's own dispatched run,
514
- // which the transcript already represents.
515
- (snapshot.runs ?? [])
516
- .filter((r) => r.depth > 0)
517
- .map((r) => [
518
- r.id,
519
- { agentId: r.id, name: r.agent, depth: r.depth, status: r.state, text: '' },
520
- ]));
521
- for (const m of snapshot.messages) {
522
- const id = m.agentId ?? null;
523
- if (id === null)
524
- continue;
525
- const view = byAgent.get(id) ?? {
526
- agentId: id,
527
- name: id.slice(0, 8),
528
- depth: 1,
529
- status: 'RUNNING',
530
- text: '',
531
- };
532
- if (m.role === 'assistant') {
533
- const text = messageToEntry(m, cfg.format)?.text ?? '';
534
- if (text)
535
- view.text = view.text ? `${view.text}\n${text}` : text;
536
- }
537
- byAgent.set(id, view);
538
- }
539
- // Trailing break so live deltas from a resumed child start on their
540
- // own line instead of running into what it already wrote.
541
- for (const view of byAgent.values())
542
- if (view.text)
543
- view.text += '\n';
544
- setSubagents([...byAgent.values()]);
545
- setPendingInputs([]);
546
- void loadUsage(threadId);
547
- // The latest run's clocks. An end this client already saw on the
548
- // wire stands over a snapshot that does not carry it yet.
549
- const run = latestRun(snapshot.runs);
550
- if (run && !run.endedAt) {
551
- const ended = runEndings.current.get(run.id);
552
- if (ended)
553
- run.endedAt = ended;
554
- }
555
- setCurrentRun(run);
556
- setAgentState(snapshot.thread.state);
557
- setActivity(stateActivity(snapshot.thread.state, cfg.labels));
558
- for (const event of snapshot.activeEvents)
559
- applyEvent(event);
560
- stream = cfg.openStream(routeUrl(cfg.routes.stream, { threadId, since: snapshot.lastEventSeq }, cfg.baseUrl), {
561
- onMessage: (raw) => applyEvent(JSON.parse(raw)),
853
+ hydrate(snapshot, threadId);
854
+ stream = cfg.openStream(routeUrl(cfg.routes.stream, {
855
+ threadId,
856
+ since: lastSeqRef.current,
857
+ cursor: cursor(),
858
+ lastMessageId: messagesRef.current.at(-1)?.id,
859
+ }, cfg.baseUrl), {
860
+ onMessage: (raw) => {
861
+ let frame;
862
+ try {
863
+ frame = JSON.parse(raw);
864
+ }
865
+ catch {
866
+ return; // a frame that is not an event: nothing to show
867
+ }
868
+ applyFrame(frame, threadId);
869
+ },
562
870
  onError: () => {
563
- // EventSource reconnects on its own; keep the last meaningful
564
- // activity instead of presenting a transient network failure.
871
+ // The transport retries on its own; keep the last meaningful
872
+ // activity rather than showing a passing network blip.
873
+ if (!cancelled)
874
+ setConnection('reconnecting');
875
+ },
876
+ onOpen: () => {
877
+ if (cancelled)
878
+ return;
879
+ reconnectTries.current = 0;
880
+ setConnection('open');
881
+ },
882
+ onClose: () => {
883
+ // The transport gave up for good (a 401, a 404): read the
884
+ // thread again and open a new stream from where it stands,
885
+ // waiting longer each time it happens in a row.
886
+ if (cancelled)
887
+ return;
888
+ setConnection('closed');
889
+ const wait = Math.min(RECONNECT_BASE_MS * 2 ** reconnectTries.current, RECONNECT_MAX_MS);
890
+ reconnectTries.current += 1;
891
+ retry = setTimeout(() => setReloadKey((k) => k + 1), wait);
565
892
  },
893
+ getCursor: cursor,
566
894
  });
895
+ setConnection('open');
567
896
  }
568
- catch (error) {
897
+ catch (err) {
569
898
  if (cancelled)
570
899
  return;
571
900
  setAgentState('FAILED');
572
901
  setActivity({
573
902
  phase: 'failed',
574
903
  label: cfgRef.current.labels.loadFailed,
575
- detail: error instanceof Error ? error.message : String(error),
904
+ detail: err instanceof Error ? err.message : String(err),
576
905
  });
577
906
  }
578
907
  finally {
@@ -582,12 +911,32 @@ export function useAgentThread(options = {}) {
582
911
  })();
583
912
  return () => {
584
913
  cancelled = true;
914
+ abort.abort();
915
+ if (retry)
916
+ clearTimeout(retry);
585
917
  stream?.close();
918
+ flushDeltas();
586
919
  };
587
- }, [applyEvent, loadUsage, request, threadId]);
920
+ }, [applyFrame, clearThreadState, cursor, flushDeltas, hydrate, request, setActivity, threadId, reloadKey]);
588
921
  const run = useCallback(async (prompt, options = {}) => {
589
922
  const cfg = cfgRef.current;
590
923
  const { model = cfg.defaultModel, editMessageId, attachments, ...rest } = options;
924
+ const shown = shownRef.current;
925
+ // One run at a time: a send while one is going would wipe its approval
926
+ // cards, and two fast sends on a new thread would make two threads.
927
+ if (sending.current || ACTIVE.includes(shown.agentState)) {
928
+ setError(cfg.labels.runBusy);
929
+ return { accepted: false, threadId: threadRef.current, error: cfg.labels.runBusy };
930
+ }
931
+ // A turn the server has not confirmed has no id it knows.
932
+ if (editMessageId?.startsWith('optimistic:')) {
933
+ setError(cfg.labels.editUnconfirmed);
934
+ return { accepted: false, threadId: threadRef.current, error: cfg.labels.editUnconfirmed };
935
+ }
936
+ sending.current = true;
937
+ // Named here, echoed back on the turn's MESSAGE_APPENDED, so the real
938
+ // message replaces exactly this optimistic one.
939
+ const clientMessageId = `cm-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
591
940
  setEntries((prev) => {
592
941
  // An edit replaces that turn and everything it led to, mirroring what
593
942
  // the server is about to do to the durable history.
@@ -599,29 +948,35 @@ export function useAgentThread(options = {}) {
599
948
  }
600
949
  return [
601
950
  ...kept,
602
- { id: `optimistic:user:${Date.now()}`, kind: 'text', role: 'user', text: prompt, parts },
951
+ { id: `optimistic:user:${clientMessageId}`, kind: 'text', role: 'user', text: prompt, parts },
603
952
  ];
604
953
  });
605
954
  setSubagents([]);
606
955
  setPendingInputs([]);
607
956
  // A new run is starting: the last one's clocks are no longer the
608
- // thread's. The stream's RUNNING names the run and its start.
957
+ // thread's. The server accepts it as QUEUED; the stream's RUNNING names
958
+ // the run and its start once a worker has it.
609
959
  setCurrentRun(null);
610
- setAgentState('RUNNING');
611
- setActivity({ phase: 'thinking', label: cfg.labels.thinking });
960
+ setAgentState('QUEUED');
961
+ setActivity({ phase: 'queued', label: cfg.labels.queued });
612
962
  try {
613
963
  const response = await postJson(cfg.baseUrl + cfg.routes.run, {
614
964
  threadId: threadRef.current,
615
965
  prompt,
616
- model,
966
+ // Left out when unset: the server then uses the agent's own model.
967
+ ...(model !== undefined ? { model } : {}),
617
968
  editMessageId,
618
969
  attachments,
970
+ clientMessageId,
619
971
  ...rest,
620
972
  });
621
- const data = (await response.json());
622
- if (!response.ok || !data.accepted) {
623
- throw new Error(data.error ?? `Run request failed (${response.status})`);
973
+ // The status first: an error page is not JSON, and its parse error
974
+ // would hide what actually went wrong.
975
+ const data = (await response.json().catch(() => null));
976
+ if (!response.ok || !data?.accepted) {
977
+ throw new Error(data?.error ?? `Run request failed (${response.status})`);
624
978
  }
979
+ setError(null);
625
980
  if (data.threadId)
626
981
  setThreadId(data.threadId);
627
982
  // The stream usually names the run first; a late answer must not
@@ -631,16 +986,23 @@ export function useAgentThread(options = {}) {
631
986
  void loadThreads(); // the sidebar reflects a new thread immediately
632
987
  return data;
633
988
  }
634
- catch (error) {
635
- setAgentState('FAILED');
636
- setActivity({
637
- phase: 'failed',
638
- label: cfg.labels.runFailed,
639
- detail: error instanceof Error ? error.message : String(error),
640
- });
641
- return { accepted: false, threadId: threadRef.current, error: String(error) };
989
+ catch (err) {
990
+ // Nothing was sent: the conversation goes back to exactly what it
991
+ // was, and the reason is shown apart from the thread's own state.
992
+ const why = err instanceof Error ? err.message : String(err);
993
+ setEntries(shown.entries);
994
+ setSubagents(shown.subagents);
995
+ setPendingInputs(shown.pendingInputs);
996
+ setCurrentRun(shown.currentRun);
997
+ setAgentState(shown.agentState);
998
+ setActivity(shown.activity);
999
+ setError(why);
1000
+ return { accepted: false, threadId: threadRef.current, error: why };
1001
+ }
1002
+ finally {
1003
+ sending.current = false;
642
1004
  }
643
- }, [loadThreads, postJson]);
1005
+ }, [loadThreads, postJson, setActivity]);
644
1006
  const stop = useCallback(async () => {
645
1007
  const requestedThread = threadRef.current;
646
1008
  if (!requestedThread)
@@ -651,15 +1013,15 @@ export function useAgentThread(options = {}) {
651
1013
  await checkControlResponse(response, 'accepted', cfg.labels.stopFailed);
652
1014
  return true;
653
1015
  }
654
- catch (error) {
1016
+ catch (err) {
655
1017
  if (threadRef.current === requestedThread)
656
1018
  setActivity({
657
1019
  phase: 'failed', label: cfg.labels.stopFailed,
658
- detail: error instanceof Error ? error.message : String(error),
1020
+ detail: err instanceof Error ? err.message : String(err),
659
1021
  });
660
1022
  return false;
661
1023
  }
662
- }, [postJson]);
1024
+ }, [postJson, setActivity]);
663
1025
  const respondToInput = useCallback(async (toolCallId, approved, payload) => {
664
1026
  const requestedThread = threadRef.current;
665
1027
  if (!requestedThread)
@@ -681,15 +1043,15 @@ export function useAgentThread(options = {}) {
681
1043
  }
682
1044
  return true;
683
1045
  }
684
- catch (error) {
1046
+ catch (err) {
685
1047
  if (threadRef.current === requestedThread)
686
1048
  setActivity({
687
1049
  phase: 'failed', label: cfg.labels.responseFailed,
688
- detail: error instanceof Error ? error.message : String(error),
1050
+ detail: err instanceof Error ? err.message : String(err),
689
1051
  });
690
1052
  return false;
691
1053
  }
692
- }, [postJson]);
1054
+ }, [postJson, setActivity]);
693
1055
  return {
694
1056
  threadId,
695
1057
  entries,
@@ -702,6 +1064,8 @@ export function useAgentThread(options = {}) {
702
1064
  threadsLoading,
703
1065
  usage,
704
1066
  currentRun,
1067
+ connection,
1068
+ error,
705
1069
  loadThreads,
706
1070
  loadUsage,
707
1071
  newThread,