use-agentenkit 0.3.0 → 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) {
@@ -23,21 +30,23 @@ function settleToolCall(entries, toolCallId, result) {
23
30
  }
24
31
  /** The run a STATE_CHANGE speaks for, with the clock it carries:
25
32
  * `enqueuedAt` while the run waits for a worker, `startedAt` once one has
26
- * it, `endedAt` once it ended. An event without a run id says nothing about
27
- * timing and leaves what is known. */
28
- function runFromStateChange(prev, state, p) {
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
35
+ * without a run id says nothing about timing and leaves what is known. */
36
+ function runFromStateChange(prev, state, p, at) {
29
37
  if (typeof p?.runId !== 'string')
30
38
  return prev;
31
39
  const same = prev?.id === p.runId;
32
40
  const next = { id: p.runId };
33
- const enqueuedAt = typeof p.enqueuedAt === 'string' ? p.enqueuedAt : same ? prev?.enqueuedAt : 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');
34
43
  if (enqueuedAt)
35
44
  next.enqueuedAt = enqueuedAt;
36
- const startedAt = typeof p.startedAt === 'string' ? p.startedAt : same ? prev?.startedAt : undefined;
45
+ const startedAt = clock('startedAt', state === 'RUNNING' || state === 'WAITING_FOR_INPUT');
37
46
  if (startedAt)
38
47
  next.startedAt = startedAt;
39
48
  if (state === 'COMPLETED' || state === 'FAILED' || state === 'CANCELLED') {
40
- const endedAt = typeof p.endedAt === 'string' ? p.endedAt : same ? prev?.endedAt : undefined;
49
+ const endedAt = clock('endedAt', true);
41
50
  if (endedAt)
42
51
  next.endedAt = endedAt;
43
52
  }
@@ -65,12 +74,49 @@ function latestRun(runs) {
65
74
  run.endedAt = latest.endedAt;
66
75
  return run;
67
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
+ }
68
105
  /** Hydrates durable messages first, then resumes the canonical event stream at
69
106
  * the snapshot cursor, so a reload — or a second tab — rebuilds the same
70
107
  * conversation with the server as the only source of truth.
71
108
  *
72
109
  * Every endpoint, label and formatter is replaceable through the options or a
73
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
+ ]);
74
120
  export function useAgentThread(options = {}) {
75
121
  const { initialThreadId, ...config } = options;
76
122
  // Provider first, own options over it. Resolved every render rather than
@@ -85,7 +131,7 @@ export function useAgentThread(options = {}) {
85
131
  const [threadId, setThreadId] = useState(initialThreadId);
86
132
  const [entries, setEntries] = useState([]);
87
133
  const [agentState, setAgentState] = useState('IDLE');
88
- const [activity, setActivity] = useState(() => stateActivity('IDLE', resolved.labels));
134
+ const [activity, setActivityState] = useState(() => stateActivity('IDLE', resolved.labels));
89
135
  const [historyLoading, setHistoryLoading] = useState(false);
90
136
  // A parent step can park several nested runs at once, so the run waits on a
91
137
  // SET of approvals — the thread resumes when the last one is answered.
@@ -95,8 +141,26 @@ export function useAgentThread(options = {}) {
95
141
  const [threadsLoading, setThreadsLoading] = useState(resolved.loadThreadsOnMount);
96
142
  const [usage, setUsage] = useState(null);
97
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);
98
149
  const threadRef = useRef(threadId);
99
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
+ }, []);
100
164
  /** When each run ended, as its terminal STATE_CHANGE said. A stop is
101
165
  * accepted before the worker that held the run has torn down, so a
102
166
  * snapshot taken in between can still lack the end; the event's word
@@ -109,6 +173,80 @@ export function useAgentThread(options = {}) {
109
173
  /** Results already visible from the durable messages, kept apart from the
110
174
  * calls: a live result must still render after its live call did. */
111
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
+ }, []);
112
250
  /** One place where the caller's headers and fetch are applied. */
113
251
  const request = useCallback(async (url, init = {}) => {
114
252
  const cfg = cfgRef.current;
@@ -142,22 +280,26 @@ export function useAgentThread(options = {}) {
142
280
  // usage is a read-only extra — never break the conversation over it
143
281
  }
144
282
  }, [request]);
145
- /** 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. */
146
285
  const loadThreads = useCallback(async () => {
147
286
  const cfg = cfgRef.current;
287
+ const mine = ++threadsRequest.current;
148
288
  try {
149
289
  setThreadsLoading(true);
150
290
  const res = await request(cfg.baseUrl + cfg.routes.threads);
151
- if (!res.ok)
291
+ if (!res.ok || mine !== threadsRequest.current)
152
292
  return;
153
293
  const data = await res.json();
154
- setThreads(data.threads ?? []);
294
+ if (mine === threadsRequest.current)
295
+ setThreads(data.threads ?? []);
155
296
  }
156
297
  catch {
157
298
  // sidebar is best-effort — ignore transport errors
158
299
  }
159
300
  finally {
160
- setThreadsLoading(false);
301
+ if (mine === threadsRequest.current)
302
+ setThreadsLoading(false);
161
303
  }
162
304
  }, [request]);
163
305
  useEffect(() => {
@@ -176,16 +318,13 @@ export function useAgentThread(options = {}) {
176
318
  }, [loadThreads, resolved.threadsRefreshMs]);
177
319
  /** Start a new thread: clear the pointer so the next run creates one. */
178
320
  const newThread = useCallback(() => {
321
+ clearThreadState();
322
+ shownThread.current = undefined;
179
323
  setThreadId(undefined);
180
- setEntries([]);
181
- setSubagents([]);
182
- setPendingInputs([]);
183
- setUsage(null);
184
- setCurrentRun(null);
185
324
  setAgentState('IDLE');
186
325
  setActivity(stateActivity('IDLE', cfgRef.current.labels));
187
326
  cfgRef.current.persistence?.clear();
188
- }, []);
327
+ }, [clearThreadState, setActivity]);
189
328
  /** Select an existing thread — hydration and stream resume run in the
190
329
  * threadId effect below. */
191
330
  const selectThread = useCallback((id) => {
@@ -218,9 +357,34 @@ export function useAgentThread(options = {}) {
218
357
  setThreadId(recovered);
219
358
  }, [initialThreadId]);
220
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;
221
366
  const cfg = cfgRef.current;
222
367
  const { labels, format } = cfg;
223
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();
224
388
  // The app sees every event first, and can claim it.
225
389
  if (cfg.onEvent?.(data) === true)
226
390
  return;
@@ -231,7 +395,7 @@ export function useAgentThread(options = {}) {
231
395
  if (typeof p.runId === 'string' && typeof p.endedAt === 'string') {
232
396
  runEndings.current.set(p.runId, p.endedAt);
233
397
  }
234
- setCurrentRun((prev) => runFromStateChange(prev, nextState, p));
398
+ setCurrentRun((prev) => runFromStateChange(prev, nextState, p, data.createdAt));
235
399
  if (nextState === 'RUNNING') {
236
400
  // The park was resolved: every child that was waiting is re-entered
237
401
  // where it stopped.
@@ -242,7 +406,13 @@ export function useAgentThread(options = {}) {
242
406
  ['thinking', 'responding', 'tool-call', 'tool-result'].includes(current.phase)) {
243
407
  return current;
244
408
  }
245
- 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;
246
416
  });
247
417
  if (nextState !== 'WAITING_FOR_INPUT')
248
418
  setPendingInputs([]);
@@ -254,9 +424,10 @@ export function useAgentThread(options = {}) {
254
424
  }
255
425
  break;
256
426
  }
257
- // Another client sent a message on this thread. The sending client
258
- // added it to its own state before the request went out, so this is
259
- // 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.
260
431
  case 'MESSAGE_APPENDED': {
261
432
  const entry = messageToEntry({
262
433
  id: String(p.id),
@@ -269,10 +440,14 @@ export function useAgentThread(options = {}) {
269
440
  setEntries((prev) => {
270
441
  // Already have it — a replayed event, or our own optimistic copy
271
442
  // now confirmed. Replace the optimistic one so the real id lands
272
- // (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.
273
446
  if (prev.some((e) => e.id === entry.id))
274
447
  return prev;
275
- 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);
276
451
  if (optimistic !== -1) {
277
452
  const next = [...prev];
278
453
  next[optimistic] = entry;
@@ -291,61 +466,51 @@ export function useAgentThread(options = {}) {
291
466
  });
292
467
  break;
293
468
  }
294
- case 'CHUNK': {
295
- if (p?.type === 'text-delta' && typeof p.textDelta === 'string') {
296
- setActivity({ phase: 'responding', label: labels.responding });
297
- setEntries((prev) => {
298
- const last = prev.at(-1);
299
- if (last?.kind === 'text' && last.id.startsWith('live:assistant:') && !last.agentId) {
300
- const text = last.text + p.textDelta;
301
- return [...prev.slice(0, -1), { ...last, text, parts: [{ type: 'text', text }] }];
302
- }
303
- return [
304
- ...prev,
305
- {
306
- id: `live:assistant:${data.seq}`,
307
- kind: 'text',
308
- role: 'assistant',
309
- text: p.textDelta,
310
- parts: [{ type: 'text', text: p.textDelta }],
311
- },
312
- ];
313
- });
314
- }
315
- else if (p?.type === 'reasoning') {
316
- // The model's thinking, streamed like the answer but kept apart so
317
- // a UI can show it live and fold it away after. Providers that do
318
- // not expose reasoning never send these.
319
- setActivity({ phase: 'thinking', label: labels.thinking });
320
- if (typeof p.textDelta === 'string' && p.textDelta) {
321
- setEntries((prev) => {
322
- const last = prev.at(-1);
323
- if (last?.kind === 'reasoning' && last.id.startsWith('live:reasoning:')) {
324
- const text = last.text + p.textDelta;
325
- return [...prev.slice(0, -1), { ...last, text, parts: [{ type: 'reasoning', text }] }];
326
- }
327
- return [
328
- ...prev,
329
- {
330
- id: `live:reasoning:${data.seq}`,
331
- kind: 'reasoning',
332
- role: 'assistant',
333
- text: p.textDelta,
334
- parts: [{ type: 'reasoning', text: p.textDelta }],
335
- },
336
- ];
337
- });
338
- }
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();
339
491
  }
340
- 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') {
341
508
  setActivity({ phase: 'thinking', label: labels.reviewingSources });
342
509
  }
343
510
  else if (p?.type === 'tool-call-streaming-start' || p?.type === 'tool-call-delta') {
344
- setActivity({
345
- phase: 'tool-call',
346
- label: labels.preparingToolCall,
347
- detail: p.toolName,
348
- });
511
+ if (activityRef.current.phase !== 'tool-call' || activityRef.current.detail !== p.toolName) {
512
+ setActivity({ phase: 'tool-call', label: labels.preparingToolCall, detail: p.toolName });
513
+ }
349
514
  }
350
515
  else if (p?.type === 'tool-call') {
351
516
  if (p.toolCallId && seenToolCalls.current.has(p.toolCallId))
@@ -353,10 +518,11 @@ export function useAgentThread(options = {}) {
353
518
  if (p.toolCallId)
354
519
  seenToolCalls.current.add(p.toolCallId);
355
520
  setActivity({ phase: 'tool-call', label: labels.callingTool, detail: p.toolName });
521
+ const id = `live:tool-call:${nextLive()}`;
356
522
  setEntries((prev) => [
357
523
  ...prev,
358
524
  {
359
- id: `live:tool-call:${data.seq}`,
525
+ id,
360
526
  kind: 'tool',
361
527
  role: 'tool',
362
528
  text: format.toolCall(p.toolName, p.args ?? {}),
@@ -382,10 +548,11 @@ export function useAgentThread(options = {}) {
382
548
  if (p.toolCallId)
383
549
  seenToolResults.current.add(p.toolCallId);
384
550
  setActivity({ phase: 'tool-result', label: labels.toolCompleted, detail: p.toolName });
551
+ const id = `live:tool-result:${nextLive()}`;
385
552
  setEntries((prev) => [
386
553
  ...settleToolCall(prev, p.toolCallId, p.result),
387
554
  {
388
- id: `live:tool-result:${data.seq}`,
555
+ id,
389
556
  kind: 'tool',
390
557
  role: 'tool',
391
558
  text: format.toolResult(p.toolName, p.result),
@@ -431,12 +598,13 @@ export function useAgentThread(options = {}) {
431
598
  setPendingInputs((prev) => prev.filter((r) => r.toolCallId !== p.toolCallId));
432
599
  setActivity({ phase: 'failed', label: labels.approvalExpired });
433
600
  break;
434
- case 'SUBAGENT_STARTED':
601
+ case 'SUBAGENT_STARTED': {
435
602
  setActivity({ phase: 'tool-call', label: labels.subagentWorking, detail: p.name });
603
+ const id = `live:subagent:${p.agentId}:${nextLive()}`;
436
604
  setEntries((prev) => [
437
605
  ...prev,
438
606
  {
439
- id: `live:subagent:${p.agentId}:${data.seq}`,
607
+ id,
440
608
  kind: 'tool',
441
609
  role: 'tool',
442
610
  text: format.subagentStarted(p.name),
@@ -457,8 +625,13 @@ export function useAgentThread(options = {}) {
457
625
  },
458
626
  ]);
459
627
  break;
628
+ }
460
629
  case 'SUBAGENT_CHUNK':
461
- 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));
462
635
  break;
463
636
  case 'SUBAGENT_COMPLETED':
464
637
  setActivity({ phase: 'tool-result', label: labels.subagentCompleted, detail: p.name });
@@ -470,25 +643,204 @@ export function useAgentThread(options = {}) {
470
643
  : s));
471
644
  break;
472
645
  }
473
- }, [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]);
474
813
  useEffect(() => {
475
814
  if (!threadId)
476
815
  return;
477
816
  const cfg = cfgRef.current;
478
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;
479
824
  let cancelled = false;
480
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();
481
829
  setHistoryLoading(true);
830
+ setConnection('connecting');
482
831
  setActivity({ phase: 'loading', label: cfg.labels.loading });
483
832
  void (async () => {
484
833
  try {
485
- 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;
486
839
  if (res.status === 404) {
487
840
  cfg.persistence?.clear();
841
+ clearThreadState();
842
+ shownThread.current = undefined;
488
843
  setThreadId(undefined);
489
- setEntries([]);
490
- setUsage(null);
491
- setCurrentRun(null);
492
844
  setAgentState('IDLE');
493
845
  setActivity(stateActivity('IDLE', cfg.labels));
494
846
  return;
@@ -498,97 +850,58 @@ export function useAgentThread(options = {}) {
498
850
  const snapshot = (await res.json());
499
851
  if (cancelled)
500
852
  return;
501
- // A nested run's turns live in the same log under its own agentId.
502
- // They are its transcript, not the main conversation's.
503
- const mainMessages = snapshot.messages.filter((m) => (m.agentId ?? null) === null);
504
- // A durable result settles its call, as done or as failed: a denied
505
- // approval or a stop never streams a result, so this is where a
506
- // reload learns how those calls ended.
507
- const outcomes = toolCallOutcomes(snapshot.messages);
508
- setEntries(mainMessages.flatMap((m) => messageToEntries(m, cfg.format, outcomes)));
509
- // Rebuild each child's card from what it actually wrote, so a reload
510
- // does not lose a subagent's output.
511
- const durableParts = snapshot.messages.flatMap((m) => Array.isArray(m.content) ? m.content : []);
512
- seenToolCalls.current = new Set(durableParts
513
- .filter((part) => part?.type === 'tool-call')
514
- .map((part) => part.toolCallId)
515
- .filter((id) => typeof id === 'string'));
516
- seenToolResults.current = new Set(outcomes.keys());
517
- // Name, depth and final state come from the durable run rows; the
518
- // SUBAGENT_* events only replay while a run is unfinished, so on a
519
- // completed thread they are all a client has.
520
- const byAgent = new Map(
521
- // Nested runs only: depth 0 is this thread's own dispatched run,
522
- // which the transcript already represents.
523
- (snapshot.runs ?? [])
524
- .filter((r) => r.depth > 0)
525
- .map((r) => [
526
- r.id,
527
- // A nested run is never queued: it runs inside its parent's
528
- // segment. The type allows QUEUED for the dispatched run only.
529
- {
530
- agentId: r.id,
531
- name: r.agent,
532
- depth: r.depth,
533
- status: r.state === 'QUEUED' ? 'RUNNING' : r.state,
534
- text: '',
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);
535
869
  },
536
- ]));
537
- for (const m of snapshot.messages) {
538
- const id = m.agentId ?? null;
539
- if (id === null)
540
- continue;
541
- const view = byAgent.get(id) ?? {
542
- agentId: id,
543
- name: id.slice(0, 8),
544
- depth: 1,
545
- status: 'RUNNING',
546
- text: '',
547
- };
548
- if (m.role === 'assistant') {
549
- const text = messageToEntry(m, cfg.format)?.text ?? '';
550
- if (text)
551
- view.text = view.text ? `${view.text}\n${text}` : text;
552
- }
553
- byAgent.set(id, view);
554
- }
555
- // Trailing break so live deltas from a resumed child start on their
556
- // own line instead of running into what it already wrote.
557
- for (const view of byAgent.values())
558
- if (view.text)
559
- view.text += '\n';
560
- setSubagents([...byAgent.values()]);
561
- setPendingInputs([]);
562
- void loadUsage(threadId);
563
- // The latest run's clocks. An end this client already saw on the
564
- // wire stands over a snapshot that does not carry it yet.
565
- const run = latestRun(snapshot.runs);
566
- if (run && !run.endedAt) {
567
- const ended = runEndings.current.get(run.id);
568
- if (ended)
569
- run.endedAt = ended;
570
- }
571
- setCurrentRun(run);
572
- setAgentState(snapshot.thread.state);
573
- setActivity(stateActivity(snapshot.thread.state, cfg.labels));
574
- for (const event of snapshot.activeEvents)
575
- applyEvent(event);
576
- stream = cfg.openStream(routeUrl(cfg.routes.stream, { threadId, since: snapshot.lastEventSeq }, cfg.baseUrl), {
577
- onMessage: (raw) => applyEvent(JSON.parse(raw)),
578
870
  onError: () => {
579
- // EventSource reconnects on its own; keep the last meaningful
580
- // 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);
581
892
  },
893
+ getCursor: cursor,
582
894
  });
895
+ setConnection('open');
583
896
  }
584
- catch (error) {
897
+ catch (err) {
585
898
  if (cancelled)
586
899
  return;
587
900
  setAgentState('FAILED');
588
901
  setActivity({
589
902
  phase: 'failed',
590
903
  label: cfgRef.current.labels.loadFailed,
591
- detail: error instanceof Error ? error.message : String(error),
904
+ detail: err instanceof Error ? err.message : String(err),
592
905
  });
593
906
  }
594
907
  finally {
@@ -598,12 +911,32 @@ export function useAgentThread(options = {}) {
598
911
  })();
599
912
  return () => {
600
913
  cancelled = true;
914
+ abort.abort();
915
+ if (retry)
916
+ clearTimeout(retry);
601
917
  stream?.close();
918
+ flushDeltas();
602
919
  };
603
- }, [applyEvent, loadUsage, request, threadId]);
920
+ }, [applyFrame, clearThreadState, cursor, flushDeltas, hydrate, request, setActivity, threadId, reloadKey]);
604
921
  const run = useCallback(async (prompt, options = {}) => {
605
922
  const cfg = cfgRef.current;
606
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)}`;
607
940
  setEntries((prev) => {
608
941
  // An edit replaces that turn and everything it led to, mirroring what
609
942
  // the server is about to do to the durable history.
@@ -615,7 +948,7 @@ export function useAgentThread(options = {}) {
615
948
  }
616
949
  return [
617
950
  ...kept,
618
- { id: `optimistic:user:${Date.now()}`, kind: 'text', role: 'user', text: prompt, parts },
951
+ { id: `optimistic:user:${clientMessageId}`, kind: 'text', role: 'user', text: prompt, parts },
619
952
  ];
620
953
  });
621
954
  setSubagents([]);
@@ -630,15 +963,20 @@ export function useAgentThread(options = {}) {
630
963
  const response = await postJson(cfg.baseUrl + cfg.routes.run, {
631
964
  threadId: threadRef.current,
632
965
  prompt,
633
- model,
966
+ // Left out when unset: the server then uses the agent's own model.
967
+ ...(model !== undefined ? { model } : {}),
634
968
  editMessageId,
635
969
  attachments,
970
+ clientMessageId,
636
971
  ...rest,
637
972
  });
638
- const data = (await response.json());
639
- if (!response.ok || !data.accepted) {
640
- 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})`);
641
978
  }
979
+ setError(null);
642
980
  if (data.threadId)
643
981
  setThreadId(data.threadId);
644
982
  // The stream usually names the run first; a late answer must not
@@ -648,16 +986,23 @@ export function useAgentThread(options = {}) {
648
986
  void loadThreads(); // the sidebar reflects a new thread immediately
649
987
  return data;
650
988
  }
651
- catch (error) {
652
- setAgentState('FAILED');
653
- setActivity({
654
- phase: 'failed',
655
- label: cfg.labels.runFailed,
656
- detail: error instanceof Error ? error.message : String(error),
657
- });
658
- 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;
659
1004
  }
660
- }, [loadThreads, postJson]);
1005
+ }, [loadThreads, postJson, setActivity]);
661
1006
  const stop = useCallback(async () => {
662
1007
  const requestedThread = threadRef.current;
663
1008
  if (!requestedThread)
@@ -668,15 +1013,15 @@ export function useAgentThread(options = {}) {
668
1013
  await checkControlResponse(response, 'accepted', cfg.labels.stopFailed);
669
1014
  return true;
670
1015
  }
671
- catch (error) {
1016
+ catch (err) {
672
1017
  if (threadRef.current === requestedThread)
673
1018
  setActivity({
674
1019
  phase: 'failed', label: cfg.labels.stopFailed,
675
- detail: error instanceof Error ? error.message : String(error),
1020
+ detail: err instanceof Error ? err.message : String(err),
676
1021
  });
677
1022
  return false;
678
1023
  }
679
- }, [postJson]);
1024
+ }, [postJson, setActivity]);
680
1025
  const respondToInput = useCallback(async (toolCallId, approved, payload) => {
681
1026
  const requestedThread = threadRef.current;
682
1027
  if (!requestedThread)
@@ -698,15 +1043,15 @@ export function useAgentThread(options = {}) {
698
1043
  }
699
1044
  return true;
700
1045
  }
701
- catch (error) {
1046
+ catch (err) {
702
1047
  if (threadRef.current === requestedThread)
703
1048
  setActivity({
704
1049
  phase: 'failed', label: cfg.labels.responseFailed,
705
- detail: error instanceof Error ? error.message : String(error),
1050
+ detail: err instanceof Error ? err.message : String(err),
706
1051
  });
707
1052
  return false;
708
1053
  }
709
- }, [postJson]);
1054
+ }, [postJson, setActivity]);
710
1055
  return {
711
1056
  threadId,
712
1057
  entries,
@@ -719,6 +1064,8 @@ export function useAgentThread(options = {}) {
719
1064
  threadsLoading,
720
1065
  usage,
721
1066
  currentRun,
1067
+ connection,
1068
+ error,
722
1069
  loadThreads,
723
1070
  loadUsage,
724
1071
  newThread,