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.
- package/dist/config.d.ts +29 -3
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +15 -5
- package/dist/config.js.map +1 -1
- package/dist/frames.d.ts +18 -0
- package/dist/frames.d.ts.map +1 -0
- package/dist/frames.js +61 -0
- package/dist/frames.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +40 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/useAgentThread.d.ts +11 -7
- package/dist/useAgentThread.d.ts.map +1 -1
- package/dist/useAgentThread.js +533 -186
- package/dist/useAgentThread.js.map +1 -1
- package/package.json +1 -1
package/dist/useAgentThread.js
CHANGED
|
@@ -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.
|
|
27
|
-
*
|
|
28
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
258
|
-
// added it to its own state before
|
|
259
|
-
// where every OTHER one learns what
|
|
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 =
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
345
|
-
phase: 'tool-call',
|
|
346
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
//
|
|
580
|
-
// activity
|
|
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 (
|
|
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:
|
|
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
|
-
}, [
|
|
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:${
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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 (
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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 (
|
|
1016
|
+
catch (err) {
|
|
672
1017
|
if (threadRef.current === requestedThread)
|
|
673
1018
|
setActivity({
|
|
674
1019
|
phase: 'failed', label: cfg.labels.stopFailed,
|
|
675
|
-
detail:
|
|
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 (
|
|
1046
|
+
catch (err) {
|
|
702
1047
|
if (threadRef.current === requestedThread)
|
|
703
1048
|
setActivity({
|
|
704
1049
|
phase: 'failed', label: cfg.labels.responseFailed,
|
|
705
|
-
detail:
|
|
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,
|