@kenkaiiii/ggcoder 4.2.39 → 4.2.40
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/ui/App.d.ts +7 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/App.js +76 -25
- package/dist/ui/App.js.map +1 -1
- package/dist/ui/components/InputArea.js +2 -3
- package/dist/ui/components/InputArea.js.map +1 -1
- package/dist/ui/hooks/useAgentLoop.d.ts +8 -0
- package/dist/ui/hooks/useAgentLoop.d.ts.map +1 -1
- package/dist/ui/hooks/useAgentLoop.js +312 -235
- package/dist/ui/hooks/useAgentLoop.js.map +1 -1
- package/package.json +3 -3
|
@@ -22,6 +22,31 @@ function estimateTokens(msgs) {
|
|
|
22
22
|
}
|
|
23
23
|
return Math.round(chars / 4);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Merge multiple UserContent items into a single one.
|
|
27
|
+
* Text-only items are joined with newlines. Mixed content (text + images)
|
|
28
|
+
* is flattened into a content array preserving all parts.
|
|
29
|
+
*/
|
|
30
|
+
function mergeUserContent(items) {
|
|
31
|
+
if (items.length === 1)
|
|
32
|
+
return items[0];
|
|
33
|
+
const hasArrayContent = items.some((c) => Array.isArray(c));
|
|
34
|
+
if (!hasArrayContent) {
|
|
35
|
+
// All items are strings — join with newlines
|
|
36
|
+
return items.join("\n");
|
|
37
|
+
}
|
|
38
|
+
// Flatten into a single content array
|
|
39
|
+
const parts = [];
|
|
40
|
+
for (const item of items) {
|
|
41
|
+
if (typeof item === "string") {
|
|
42
|
+
parts.push({ type: "text", text: item });
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
parts.push(...item);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return parts;
|
|
49
|
+
}
|
|
25
50
|
export function useAgentLoop(messages, options, callbacks) {
|
|
26
51
|
const onComplete = callbacks?.onComplete;
|
|
27
52
|
const onTurnText = callbacks?.onTurnText;
|
|
@@ -33,6 +58,7 @@ export function useAgentLoop(messages, options, callbacks) {
|
|
|
33
58
|
const onTurnEnd = callbacks?.onTurnEnd;
|
|
34
59
|
const onDone = callbacks?.onDone;
|
|
35
60
|
const onAborted = callbacks?.onAborted;
|
|
61
|
+
const onQueuedStart = callbacks?.onQueuedStart;
|
|
36
62
|
const [isRunning, setIsRunning] = useState(false);
|
|
37
63
|
const [streamingText, setStreamingText] = useState("");
|
|
38
64
|
const [streamingThinking, setStreamingThinking] = useState("");
|
|
@@ -46,6 +72,8 @@ export function useAgentLoop(messages, options, callbacks) {
|
|
|
46
72
|
const [isThinking, setIsThinking] = useState(false);
|
|
47
73
|
const [streamedTokenEstimate, setStreamedTokenEstimate] = useState(0);
|
|
48
74
|
const abortRef = useRef(null);
|
|
75
|
+
const queueRef = useRef([]);
|
|
76
|
+
const [queuedCount, setQueuedCount] = useState(0);
|
|
49
77
|
const activeToolCallsRef = useRef([]);
|
|
50
78
|
const textPendingRef = useRef("");
|
|
51
79
|
const textVisibleRef = useRef("");
|
|
@@ -182,254 +210,299 @@ export function useAgentLoop(messages, options, callbacks) {
|
|
|
182
210
|
setThinkingMs(0);
|
|
183
211
|
setIsThinking(false);
|
|
184
212
|
setStreamedTokenEstimate(0);
|
|
213
|
+
queueRef.current = [];
|
|
214
|
+
setQueuedCount(0);
|
|
215
|
+
}, []);
|
|
216
|
+
const queueMessage = useCallback((content) => {
|
|
217
|
+
queueRef.current.push(content);
|
|
218
|
+
setQueuedCount(queueRef.current.length);
|
|
219
|
+
}, []);
|
|
220
|
+
const clearQueue = useCallback(() => {
|
|
221
|
+
queueRef.current = [];
|
|
222
|
+
setQueuedCount(0);
|
|
185
223
|
}, []);
|
|
186
224
|
const run = useCallback(async (userContent) => {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if
|
|
231
|
-
|
|
232
|
-
thinkingStartRef.current
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
225
|
+
/** Run a single user message through the agent loop. Returns true if aborted. */
|
|
226
|
+
const runSingle = async (content) => {
|
|
227
|
+
const ac = new AbortController();
|
|
228
|
+
abortRef.current = ac;
|
|
229
|
+
let wasAborted = false;
|
|
230
|
+
// Reset state
|
|
231
|
+
doneCalledRef.current = false;
|
|
232
|
+
textPendingRef.current = "";
|
|
233
|
+
textVisibleRef.current = "";
|
|
234
|
+
thinkingBufferRef.current = "";
|
|
235
|
+
thinkingPendingRef.current = "";
|
|
236
|
+
thinkingVisibleRef.current = "";
|
|
237
|
+
runStartRef.current = Date.now();
|
|
238
|
+
toolsUsedRef.current = new Set();
|
|
239
|
+
charCountRef.current = 0;
|
|
240
|
+
realTokensAccumRef.current = 0;
|
|
241
|
+
thinkingAccumRef.current = 0;
|
|
242
|
+
thinkingStartRef.current = null;
|
|
243
|
+
phaseRef.current = "waiting";
|
|
244
|
+
setStreamingText("");
|
|
245
|
+
setStreamingThinking("");
|
|
246
|
+
setActiveToolCalls([]);
|
|
247
|
+
setActivityPhase("waiting");
|
|
248
|
+
setElapsedMs(0);
|
|
249
|
+
setThinkingMs(0);
|
|
250
|
+
setIsThinking(false);
|
|
251
|
+
setStreamedTokenEstimate(0);
|
|
252
|
+
setIsRunning(true);
|
|
253
|
+
// Start elapsed timer (ticks every 1000ms — less frequent to reduce
|
|
254
|
+
// Ink re-renders which cause live-area flickering and viewport snapping)
|
|
255
|
+
if (elapsedTimerRef.current)
|
|
256
|
+
clearInterval(elapsedTimerRef.current);
|
|
257
|
+
const timerStart = Date.now();
|
|
258
|
+
elapsedTimerRef.current = setInterval(() => {
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
setElapsedMs(now - timerStart);
|
|
261
|
+
// Update live thinking time if currently thinking
|
|
262
|
+
if (thinkingStartRef.current !== null) {
|
|
263
|
+
setThinkingMs(thinkingAccumRef.current + (now - thinkingStartRef.current));
|
|
264
|
+
}
|
|
265
|
+
// Update token estimate
|
|
266
|
+
setStreamedTokenEstimate(realTokensAccumRef.current + Math.ceil(charCountRef.current / 4));
|
|
267
|
+
}, 1000);
|
|
268
|
+
/** Freeze thinking time if currently in thinking phase */
|
|
269
|
+
const freezeThinking = () => {
|
|
270
|
+
if (thinkingStartRef.current !== null) {
|
|
271
|
+
thinkingAccumRef.current += Date.now() - thinkingStartRef.current;
|
|
272
|
+
thinkingStartRef.current = null;
|
|
273
|
+
setThinkingMs(thinkingAccumRef.current);
|
|
274
|
+
setIsThinking(false);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
// Push user message
|
|
278
|
+
const userMsg = { role: "user", content: content };
|
|
279
|
+
messages.current.push(userMsg);
|
|
280
|
+
const startIndex = messages.current.length;
|
|
281
|
+
try {
|
|
282
|
+
// Resolve fresh credentials (handles OAuth token refresh)
|
|
283
|
+
let apiKey = options.apiKey;
|
|
284
|
+
let accountId = options.accountId;
|
|
285
|
+
if (options.resolveCredentials) {
|
|
286
|
+
const creds = await options.resolveCredentials();
|
|
287
|
+
apiKey = creds.apiKey;
|
|
288
|
+
accountId = creds.accountId;
|
|
289
|
+
}
|
|
290
|
+
const generator = agentLoop(messages.current, {
|
|
291
|
+
provider: options.provider,
|
|
292
|
+
model: options.model,
|
|
293
|
+
tools: options.tools,
|
|
294
|
+
webSearch: options.webSearch,
|
|
295
|
+
maxTokens: options.maxTokens,
|
|
296
|
+
thinking: options.thinking,
|
|
297
|
+
apiKey,
|
|
298
|
+
baseUrl: options.baseUrl,
|
|
299
|
+
accountId,
|
|
300
|
+
signal: ac.signal,
|
|
301
|
+
transformContext: options.transformContext,
|
|
302
|
+
// Drain queued messages as steering — injected between tool calls
|
|
303
|
+
// and before the agent would stop, so the LLM sees user guidance
|
|
304
|
+
// within the same run instead of waiting for a new one.
|
|
305
|
+
getSteeringMessages: () => {
|
|
306
|
+
if (queueRef.current.length === 0)
|
|
307
|
+
return null;
|
|
308
|
+
const batch = queueRef.current.splice(0);
|
|
309
|
+
setQueuedCount(0);
|
|
310
|
+
const merged = mergeUserContent(batch);
|
|
311
|
+
onQueuedStart?.(merged);
|
|
312
|
+
return [{ role: "user", content: merged }];
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
for await (const event of generator) {
|
|
316
|
+
switch (event.type) {
|
|
317
|
+
case "text_delta":
|
|
318
|
+
textPendingRef.current += event.text;
|
|
319
|
+
charCountRef.current += event.text.length;
|
|
320
|
+
startReveal();
|
|
321
|
+
if (phaseRef.current !== "generating") {
|
|
322
|
+
freezeThinking();
|
|
323
|
+
phaseRef.current = "generating";
|
|
324
|
+
setActivityPhase("generating");
|
|
325
|
+
}
|
|
326
|
+
break;
|
|
327
|
+
case "thinking_delta":
|
|
328
|
+
thinkingBufferRef.current += event.text;
|
|
329
|
+
thinkingPendingRef.current += event.text;
|
|
330
|
+
charCountRef.current += event.text.length;
|
|
331
|
+
startThinkingReveal();
|
|
332
|
+
if (phaseRef.current !== "thinking") {
|
|
333
|
+
thinkingStartRef.current = Date.now();
|
|
334
|
+
setIsThinking(true);
|
|
335
|
+
phaseRef.current = "thinking";
|
|
336
|
+
setActivityPhase("thinking");
|
|
337
|
+
}
|
|
338
|
+
break;
|
|
339
|
+
case "tool_call_start": {
|
|
270
340
|
freezeThinking();
|
|
271
|
-
phaseRef.current
|
|
272
|
-
|
|
341
|
+
if (phaseRef.current !== "tools") {
|
|
342
|
+
phaseRef.current = "tools";
|
|
343
|
+
setActivityPhase("tools");
|
|
344
|
+
}
|
|
345
|
+
const newTc = {
|
|
346
|
+
toolCallId: event.toolCallId,
|
|
347
|
+
name: event.name,
|
|
348
|
+
args: event.args,
|
|
349
|
+
startTime: Date.now(),
|
|
350
|
+
updates: [],
|
|
351
|
+
};
|
|
352
|
+
onToolStart?.(event.toolCallId, event.name, event.args);
|
|
353
|
+
toolsUsedRef.current.add(event.name);
|
|
354
|
+
activeToolCallsRef.current = [...activeToolCallsRef.current, newTc];
|
|
355
|
+
setActiveToolCalls(activeToolCallsRef.current);
|
|
356
|
+
break;
|
|
273
357
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
358
|
+
case "tool_call_update": {
|
|
359
|
+
onToolUpdate?.(event.toolCallId, event.update);
|
|
360
|
+
// Mutate the matching tool call in-place to avoid allocating
|
|
361
|
+
// a new array + new objects on every update event. Over a 5h
|
|
362
|
+
// session with thousands of tool calls this prevents significant
|
|
363
|
+
// GC pressure from spread-copy churn.
|
|
364
|
+
const target = activeToolCallsRef.current.find((tc) => tc.toolCallId === event.toolCallId);
|
|
365
|
+
if (target) {
|
|
366
|
+
if (target.updates.length >= 20) {
|
|
367
|
+
target.updates.shift();
|
|
368
|
+
}
|
|
369
|
+
target.updates.push(event.update);
|
|
370
|
+
}
|
|
371
|
+
// Spread once to create a new array reference for React state
|
|
372
|
+
setActiveToolCalls([...activeToolCallsRef.current]);
|
|
373
|
+
break;
|
|
285
374
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
375
|
+
case "tool_call_end": {
|
|
376
|
+
const tc = activeToolCallsRef.current.find((t) => t.toolCallId === event.toolCallId);
|
|
377
|
+
const toolName = tc?.name ?? "unknown";
|
|
378
|
+
const durationMs = tc ? Date.now() - tc.startTime : 0;
|
|
379
|
+
onToolEnd?.(event.toolCallId, toolName, event.result, event.isError, durationMs, event.details);
|
|
380
|
+
activeToolCallsRef.current = activeToolCallsRef.current.filter((t) => t.toolCallId !== event.toolCallId);
|
|
381
|
+
setActiveToolCalls(activeToolCallsRef.current);
|
|
382
|
+
break;
|
|
292
383
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
384
|
+
case "server_tool_call":
|
|
385
|
+
onServerToolCall?.(event.id, event.name, event.input);
|
|
386
|
+
break;
|
|
387
|
+
case "server_tool_result":
|
|
388
|
+
onServerToolResult?.(event.toolUseId, event.resultType, event.data);
|
|
389
|
+
break;
|
|
390
|
+
case "steering_message":
|
|
391
|
+
// Steering message was injected — UI already notified via
|
|
392
|
+
// onQueuedStart inside getSteeringMessages callback.
|
|
393
|
+
break;
|
|
394
|
+
case "turn_end":
|
|
395
|
+
onTurnEnd?.(event.turn, event.stopReason, event.usage);
|
|
396
|
+
setCurrentTurn(event.turn);
|
|
397
|
+
setTotalTokens((prev) => ({
|
|
398
|
+
input: prev.input + event.usage.inputTokens,
|
|
399
|
+
output: prev.output + event.usage.outputTokens,
|
|
400
|
+
}));
|
|
401
|
+
// Latest turn's input tokens = current context window fill
|
|
402
|
+
// With prompt caching, input_tokens only counts non-cached tokens.
|
|
403
|
+
// Total context = input + cache_read + cache_write.
|
|
404
|
+
setContextUsed(event.usage.inputTokens +
|
|
405
|
+
(event.usage.cacheRead ?? 0) +
|
|
406
|
+
(event.usage.cacheWrite ?? 0));
|
|
407
|
+
// Replace char-based estimate with real output tokens
|
|
408
|
+
realTokensAccumRef.current += event.usage.outputTokens;
|
|
409
|
+
charCountRef.current = 0;
|
|
410
|
+
setStreamedTokenEstimate(realTokensAccumRef.current);
|
|
411
|
+
// Reset phase for next turn
|
|
412
|
+
phaseRef.current = "waiting";
|
|
413
|
+
setActivityPhase("waiting");
|
|
414
|
+
// Flush all pending text before completing turn
|
|
415
|
+
flushAllText();
|
|
416
|
+
if (textVisibleRef.current) {
|
|
417
|
+
onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
|
|
316
418
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
419
|
+
// Reset streaming buffers for next turn
|
|
420
|
+
textPendingRef.current = "";
|
|
421
|
+
textVisibleRef.current = "";
|
|
422
|
+
thinkingBufferRef.current = "";
|
|
423
|
+
thinkingPendingRef.current = "";
|
|
424
|
+
thinkingVisibleRef.current = "";
|
|
425
|
+
setStreamingText("");
|
|
426
|
+
setStreamingThinking("");
|
|
427
|
+
break;
|
|
428
|
+
case "agent_done":
|
|
429
|
+
flushAllText();
|
|
430
|
+
// Batch ALL completion state into a single render so Ink
|
|
431
|
+
// processes the live-area change atomically. Previously
|
|
432
|
+
// isRunning, activityPhase, and onDone landed in separate
|
|
433
|
+
// render batches, causing multiple live-area height changes
|
|
434
|
+
// that confused Ink's cursor math and clipped content.
|
|
435
|
+
setIsRunning(false);
|
|
436
|
+
phaseRef.current = "idle";
|
|
437
|
+
setActivityPhase("idle");
|
|
438
|
+
// Call onDone HERE (not in finally) so its state updates
|
|
439
|
+
// (doneStatus, flushing items to Static) are batched too.
|
|
440
|
+
onDone?.(Date.now() - runStartRef.current, [...toolsUsedRef.current]);
|
|
441
|
+
doneCalledRef.current = true;
|
|
442
|
+
break;
|
|
322
443
|
}
|
|
323
|
-
case "tool_call_end": {
|
|
324
|
-
const tc = activeToolCallsRef.current.find((t) => t.toolCallId === event.toolCallId);
|
|
325
|
-
const toolName = tc?.name ?? "unknown";
|
|
326
|
-
const durationMs = tc ? Date.now() - tc.startTime : 0;
|
|
327
|
-
onToolEnd?.(event.toolCallId, toolName, event.result, event.isError, durationMs, event.details);
|
|
328
|
-
activeToolCallsRef.current = activeToolCallsRef.current.filter((t) => t.toolCallId !== event.toolCallId);
|
|
329
|
-
setActiveToolCalls(activeToolCallsRef.current);
|
|
330
|
-
break;
|
|
331
|
-
}
|
|
332
|
-
case "server_tool_call":
|
|
333
|
-
onServerToolCall?.(event.id, event.name, event.input);
|
|
334
|
-
break;
|
|
335
|
-
case "server_tool_result":
|
|
336
|
-
onServerToolResult?.(event.toolUseId, event.resultType, event.data);
|
|
337
|
-
break;
|
|
338
|
-
case "turn_end":
|
|
339
|
-
onTurnEnd?.(event.turn, event.stopReason, event.usage);
|
|
340
|
-
setCurrentTurn(event.turn);
|
|
341
|
-
setTotalTokens((prev) => ({
|
|
342
|
-
input: prev.input + event.usage.inputTokens,
|
|
343
|
-
output: prev.output + event.usage.outputTokens,
|
|
344
|
-
}));
|
|
345
|
-
// Latest turn's input tokens = current context window fill
|
|
346
|
-
// With prompt caching, input_tokens only counts non-cached tokens.
|
|
347
|
-
// Total context = input + cache_read + cache_write.
|
|
348
|
-
setContextUsed(event.usage.inputTokens +
|
|
349
|
-
(event.usage.cacheRead ?? 0) +
|
|
350
|
-
(event.usage.cacheWrite ?? 0));
|
|
351
|
-
// Replace char-based estimate with real output tokens
|
|
352
|
-
realTokensAccumRef.current += event.usage.outputTokens;
|
|
353
|
-
charCountRef.current = 0;
|
|
354
|
-
setStreamedTokenEstimate(realTokensAccumRef.current);
|
|
355
|
-
// Reset phase for next turn
|
|
356
|
-
phaseRef.current = "waiting";
|
|
357
|
-
setActivityPhase("waiting");
|
|
358
|
-
// Flush all pending text before completing turn
|
|
359
|
-
flushAllText();
|
|
360
|
-
if (textVisibleRef.current) {
|
|
361
|
-
onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
|
|
362
|
-
}
|
|
363
|
-
// Reset streaming buffers for next turn
|
|
364
|
-
textPendingRef.current = "";
|
|
365
|
-
textVisibleRef.current = "";
|
|
366
|
-
thinkingBufferRef.current = "";
|
|
367
|
-
thinkingPendingRef.current = "";
|
|
368
|
-
thinkingVisibleRef.current = "";
|
|
369
|
-
setStreamingText("");
|
|
370
|
-
setStreamingThinking("");
|
|
371
|
-
break;
|
|
372
|
-
case "agent_done":
|
|
373
|
-
flushAllText();
|
|
374
|
-
// Batch ALL completion state into a single render so Ink
|
|
375
|
-
// processes the live-area change atomically. Previously
|
|
376
|
-
// isRunning, activityPhase, and onDone landed in separate
|
|
377
|
-
// render batches, causing multiple live-area height changes
|
|
378
|
-
// that confused Ink's cursor math and clipped content.
|
|
379
|
-
setIsRunning(false);
|
|
380
|
-
phaseRef.current = "idle";
|
|
381
|
-
setActivityPhase("idle");
|
|
382
|
-
// Call onDone HERE (not in finally) so its state updates
|
|
383
|
-
// (doneStatus, flushing items to Static) are batched too.
|
|
384
|
-
onDone?.(Date.now() - runStartRef.current, [...toolsUsedRef.current]);
|
|
385
|
-
doneCalledRef.current = true;
|
|
386
|
-
break;
|
|
387
444
|
}
|
|
388
445
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
throw err;
|
|
394
|
-
}
|
|
395
|
-
wasAborted = true;
|
|
396
|
-
}
|
|
397
|
-
finally {
|
|
398
|
-
setIsRunning(false);
|
|
399
|
-
abortRef.current = null;
|
|
400
|
-
stopReveal();
|
|
401
|
-
stopThinkingReveal();
|
|
402
|
-
if (elapsedTimerRef.current) {
|
|
403
|
-
clearInterval(elapsedTimerRef.current);
|
|
404
|
-
elapsedTimerRef.current = null;
|
|
405
|
-
}
|
|
406
|
-
phaseRef.current = "idle";
|
|
407
|
-
setActivityPhase("idle");
|
|
408
|
-
if (wasAborted) {
|
|
409
|
-
// Flush any visible streaming text so onAborted (which adds
|
|
410
|
-
// "Request was stopped.") lands AFTER the agent's partial text
|
|
411
|
-
// in liveItems — not above it.
|
|
412
|
-
flushAllText();
|
|
413
|
-
if (textVisibleRef.current) {
|
|
414
|
-
onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
|
|
446
|
+
catch (err) {
|
|
447
|
+
const isAbort = err instanceof Error && (err.name === "AbortError" || err.message.includes("aborted"));
|
|
448
|
+
if (!isAbort) {
|
|
449
|
+
throw err;
|
|
415
450
|
}
|
|
416
|
-
|
|
417
|
-
textVisibleRef.current = "";
|
|
418
|
-
thinkingBufferRef.current = "";
|
|
419
|
-
thinkingPendingRef.current = "";
|
|
420
|
-
thinkingVisibleRef.current = "";
|
|
421
|
-
setStreamingText("");
|
|
422
|
-
setStreamingThinking("");
|
|
423
|
-
onAborted?.();
|
|
451
|
+
wasAborted = true;
|
|
424
452
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
453
|
+
finally {
|
|
454
|
+
setIsRunning(false);
|
|
455
|
+
abortRef.current = null;
|
|
456
|
+
stopReveal();
|
|
457
|
+
stopThinkingReveal();
|
|
458
|
+
if (elapsedTimerRef.current) {
|
|
459
|
+
clearInterval(elapsedTimerRef.current);
|
|
460
|
+
elapsedTimerRef.current = null;
|
|
461
|
+
}
|
|
462
|
+
phaseRef.current = "idle";
|
|
463
|
+
setActivityPhase("idle");
|
|
464
|
+
if (wasAborted) {
|
|
465
|
+
// Flush any visible streaming text so onAborted (which adds
|
|
466
|
+
// "Request was stopped.") lands AFTER the agent's partial text
|
|
467
|
+
// in liveItems — not above it.
|
|
468
|
+
flushAllText();
|
|
469
|
+
if (textVisibleRef.current) {
|
|
470
|
+
onTurnText?.(textVisibleRef.current, thinkingBufferRef.current, thinkingAccumRef.current);
|
|
471
|
+
}
|
|
472
|
+
textPendingRef.current = "";
|
|
473
|
+
textVisibleRef.current = "";
|
|
474
|
+
thinkingBufferRef.current = "";
|
|
475
|
+
thinkingPendingRef.current = "";
|
|
476
|
+
thinkingVisibleRef.current = "";
|
|
477
|
+
setStreamingText("");
|
|
478
|
+
setStreamingThinking("");
|
|
479
|
+
onAborted?.();
|
|
480
|
+
}
|
|
481
|
+
else if (!doneCalledRef.current) {
|
|
482
|
+
// Safety fallback — normally agent_done calls onDone in-band
|
|
483
|
+
const durationMs = Date.now() - runStartRef.current;
|
|
484
|
+
onDone?.(durationMs, [...toolsUsedRef.current]);
|
|
485
|
+
}
|
|
486
|
+
// Notify parent of new messages
|
|
487
|
+
const newMsgs = messages.current.slice(startIndex);
|
|
488
|
+
onComplete?.(newMsgs);
|
|
429
489
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
490
|
+
return wasAborted;
|
|
491
|
+
}; // end runSingle
|
|
492
|
+
// Run the initial message
|
|
493
|
+
const aborted = await runSingle(userContent);
|
|
494
|
+
// Drain the queue: process follow-up messages that arrived after agent_done.
|
|
495
|
+
// Most queued messages are consumed mid-run via getSteeringMessages, but
|
|
496
|
+
// messages that arrive after the agent finishes (no more tool calls to
|
|
497
|
+
// trigger steering) land here. Batch all remaining into a single run.
|
|
498
|
+
if (!aborted && queueRef.current.length > 0) {
|
|
499
|
+
const batch = queueRef.current.splice(0);
|
|
500
|
+
setQueuedCount(0);
|
|
501
|
+
const merged = mergeUserContent(batch);
|
|
502
|
+
// Let React process the onDone state updates before starting next run
|
|
503
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
504
|
+
onQueuedStart?.(merged);
|
|
505
|
+
await runSingle(merged);
|
|
433
506
|
}
|
|
434
507
|
}, [
|
|
435
508
|
messages,
|
|
@@ -444,6 +517,7 @@ export function useAgentLoop(messages, options, callbacks) {
|
|
|
444
517
|
onTurnEnd,
|
|
445
518
|
onDone,
|
|
446
519
|
onAborted,
|
|
520
|
+
onQueuedStart,
|
|
447
521
|
startReveal,
|
|
448
522
|
stopReveal,
|
|
449
523
|
startThinkingReveal,
|
|
@@ -466,6 +540,9 @@ export function useAgentLoop(messages, options, callbacks) {
|
|
|
466
540
|
run,
|
|
467
541
|
abort,
|
|
468
542
|
reset,
|
|
543
|
+
queueMessage,
|
|
544
|
+
queuedCount,
|
|
545
|
+
clearQueue,
|
|
469
546
|
isRunning,
|
|
470
547
|
streamingText,
|
|
471
548
|
streamingThinking,
|