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