@assistant-ui/react-ag-ui 0.1.2 → 0.1.4
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/runtime/AgUiThreadController.d.ts +3 -0
- package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
- package/dist/runtime/AgUiThreadController.js +124 -14
- package/dist/runtime/AgUiThreadController.js.map +1 -1
- package/dist/runtime/adapter/run-aggregator.d.ts +1 -0
- package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
- package/dist/runtime/adapter/run-aggregator.js +8 -0
- package/dist/runtime/adapter/run-aggregator.js.map +1 -1
- package/package.json +5 -4
- package/src/runtime/AgUiThreadController.snapshot-head-history.test.ts +226 -0
- package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +180 -1
- package/src/runtime/AgUiThreadController.snapshot-nested-settle.test.ts +210 -0
- package/src/runtime/AgUiThreadController.test.ts +133 -1
- package/src/runtime/AgUiThreadController.ts +207 -22
- package/src/runtime/adapter/run-aggregator.ts +11 -0
- package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
- package/src/useAgUiRuntime.state.test.tsx +87 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import type
|
|
2
|
+
import { HttpAgent, type AbstractAgent } from "@ag-ui/client";
|
|
3
3
|
import type {
|
|
4
4
|
AppendMessage,
|
|
5
5
|
ThreadAssistantMessage,
|
|
@@ -540,6 +540,25 @@ describe("AgUiThreadController activity deltas", () => {
|
|
|
540
540
|
});
|
|
541
541
|
|
|
542
542
|
describe("AgUiThreadController run state", () => {
|
|
543
|
+
const createStateAgent = (sentStates: unknown[]) =>
|
|
544
|
+
new HttpAgent({
|
|
545
|
+
url: "http://agent.test/agent",
|
|
546
|
+
initialState: { fromInitialState: true },
|
|
547
|
+
fetch: async (_url, init) => {
|
|
548
|
+
const body = JSON.parse(String(init?.body));
|
|
549
|
+
sentStates.push(body.state);
|
|
550
|
+
const events = [
|
|
551
|
+
{ type: "RUN_STARTED", threadId: body.threadId, runId: body.runId },
|
|
552
|
+
{ type: "STATE_SNAPSHOT", snapshot: { a: 1 } },
|
|
553
|
+
{ type: "RUN_FINISHED", threadId: body.threadId, runId: body.runId },
|
|
554
|
+
];
|
|
555
|
+
return new Response(
|
|
556
|
+
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(""),
|
|
557
|
+
{ headers: { "content-type": "text/event-stream" } },
|
|
558
|
+
);
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
|
|
543
562
|
type Subscriber = {
|
|
544
563
|
onStateSnapshotEvent?: (payload: {
|
|
545
564
|
event: { type: "STATE_SNAPSHOT"; snapshot: { count: number } };
|
|
@@ -577,6 +596,119 @@ describe("AgUiThreadController run state", () => {
|
|
|
577
596
|
expect(runAgent.mock.calls[2]?.[0].state).toBeNull();
|
|
578
597
|
expect(agent.state).toBeNull();
|
|
579
598
|
});
|
|
599
|
+
|
|
600
|
+
it("isolates mounted threads sharing one agent through runs and setState", async () => {
|
|
601
|
+
const sentStates: unknown[] = [];
|
|
602
|
+
const agent = createStateAgent(sentStates);
|
|
603
|
+
const { core: threadA } = createCore(undefined, agent);
|
|
604
|
+
const { core: threadB } = createCore(undefined, agent);
|
|
605
|
+
|
|
606
|
+
await threadA.append({ ...userMessage("A"), startRun: true });
|
|
607
|
+
expect(threadA.getState()).toEqual({ a: 1 });
|
|
608
|
+
expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
|
|
609
|
+
|
|
610
|
+
threadA.setState({ x: 1 });
|
|
611
|
+
await flush();
|
|
612
|
+
expect(threadA.getState()).toEqual({ x: 1 });
|
|
613
|
+
expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
|
|
614
|
+
|
|
615
|
+
await threadB.append({ ...userMessage("B"), startRun: true });
|
|
616
|
+
expect
|
|
617
|
+
.soft(sentStates)
|
|
618
|
+
.toEqual([{ fromInitialState: true }, { fromInitialState: true }]);
|
|
619
|
+
expect.soft(threadA.getState()).toEqual({ x: 1 });
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("starts a later thread from the shared agent's initial state after a run", async () => {
|
|
623
|
+
const sentStates: unknown[] = [];
|
|
624
|
+
const agent = createStateAgent(sentStates);
|
|
625
|
+
const { core: threadA } = createCore(undefined, agent);
|
|
626
|
+
|
|
627
|
+
await threadA.append({ ...userMessage("A"), startRun: true });
|
|
628
|
+
expect(agent.state).toEqual({ a: 1 });
|
|
629
|
+
|
|
630
|
+
const { core: threadB } = createCore(undefined, agent);
|
|
631
|
+
expect.soft(threadB.getState()).toEqual({ fromInitialState: true });
|
|
632
|
+
await threadB.append({ ...userMessage("B"), startRun: true });
|
|
633
|
+
expect(sentStates).toEqual([
|
|
634
|
+
{ fromInitialState: true },
|
|
635
|
+
{ fromInitialState: true },
|
|
636
|
+
]);
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
it("copies the initial state before the agent state is mutated", () => {
|
|
640
|
+
const agent = new HttpAgent({
|
|
641
|
+
url: "http://agent.test/agent",
|
|
642
|
+
initialState: { nested: { count: 0 } },
|
|
643
|
+
});
|
|
644
|
+
const { core } = createCore(undefined, agent);
|
|
645
|
+
|
|
646
|
+
agent.state.nested.count = 1;
|
|
647
|
+
expect(core.getState()).toEqual({ nested: { count: 0 } });
|
|
648
|
+
|
|
649
|
+
const { core: laterThread } = createCore(undefined, agent);
|
|
650
|
+
expect(laterThread.getState()).toEqual({ nested: { count: 0 } });
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("records an undefined initial state without adopting later agent state", () => {
|
|
654
|
+
const agent = {} as AbstractAgent;
|
|
655
|
+
createCore(undefined, agent);
|
|
656
|
+
agent.state = { fromPreviousThread: true };
|
|
657
|
+
|
|
658
|
+
const { core } = createCore(undefined, agent);
|
|
659
|
+
expect(core.getState()).toBeUndefined();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("leaves the state undefined for an agent without initialState", () => {
|
|
663
|
+
const agent = new HttpAgent({ url: "http://agent.test/agent" });
|
|
664
|
+
const { core } = createCore(undefined, agent);
|
|
665
|
+
|
|
666
|
+
expect(agent.state).toEqual({});
|
|
667
|
+
expect(core.getState()).toBeUndefined();
|
|
668
|
+
|
|
669
|
+
const swapped = new HttpAgent({ url: "http://agent.test/other" });
|
|
670
|
+
core.updateOptions({
|
|
671
|
+
agent: swapped,
|
|
672
|
+
logger: makeLogger(),
|
|
673
|
+
showThinking: true,
|
|
674
|
+
});
|
|
675
|
+
expect(core.getState()).toBeUndefined();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it("seeds a new agent's initial state on swap when the thread has no snapshot", () => {
|
|
679
|
+
const { core } = createCore();
|
|
680
|
+
const agent = new HttpAgent({
|
|
681
|
+
url: "http://agent.test/agent",
|
|
682
|
+
initialState: { a: 1 },
|
|
683
|
+
});
|
|
684
|
+
const listener = vi.fn();
|
|
685
|
+
core.getSnapshot();
|
|
686
|
+
core.subscribe(listener);
|
|
687
|
+
|
|
688
|
+
core.updateOptions({ agent, logger: makeLogger(), showThinking: true });
|
|
689
|
+
|
|
690
|
+
expect(agent.state).toEqual({ a: 1 });
|
|
691
|
+
expect(core.getState()).toEqual({ a: 1 });
|
|
692
|
+
expect(core.getSnapshot().state).toEqual({ a: 1 });
|
|
693
|
+
expect(listener).toHaveBeenCalledOnce();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
it("preserves the thread snapshot on swap and records the new agent's initial state", async () => {
|
|
697
|
+
const { core } = createCore();
|
|
698
|
+
core.setState({ fromThread: true });
|
|
699
|
+
const sentStates: unknown[] = [];
|
|
700
|
+
const agent = createStateAgent(sentStates);
|
|
701
|
+
|
|
702
|
+
core.updateOptions({ agent, logger: makeLogger(), showThinking: true });
|
|
703
|
+
|
|
704
|
+
expect(agent.state).toEqual({ fromInitialState: true });
|
|
705
|
+
expect(core.getState()).toEqual({ fromThread: true });
|
|
706
|
+
await core.append({ ...userMessage("after swap"), startRun: true });
|
|
707
|
+
expect(sentStates).toEqual([{ fromThread: true }]);
|
|
708
|
+
|
|
709
|
+
const { core: laterThread } = createCore(undefined, agent);
|
|
710
|
+
expect(laterThread.getState()).toEqual({ fromInitialState: true });
|
|
711
|
+
});
|
|
580
712
|
});
|
|
581
713
|
|
|
582
714
|
describe("AgUiThreadController setState", () => {
|
|
@@ -82,6 +82,26 @@ import {
|
|
|
82
82
|
withToolApprovalDecision,
|
|
83
83
|
} from "./adapter/tool-approval";
|
|
84
84
|
|
|
85
|
+
// The SDK does not retain initialState.
|
|
86
|
+
const agentInitialStates = new WeakMap<
|
|
87
|
+
AbstractAgent,
|
|
88
|
+
ReadonlyJSONValue | undefined
|
|
89
|
+
>();
|
|
90
|
+
|
|
91
|
+
const getAgentInitialState = (agent: AbstractAgent) => {
|
|
92
|
+
if (!agentInitialStates.has(agent)) {
|
|
93
|
+
const state = agent.state;
|
|
94
|
+
agentInitialStates.set(
|
|
95
|
+
agent,
|
|
96
|
+
state === undefined ||
|
|
97
|
+
(isPlainObject(state) && Object.keys(state).length === 0)
|
|
98
|
+
? undefined
|
|
99
|
+
: (structuredClone(state) as ReadonlyJSONValue),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return agentInitialStates.get(agent);
|
|
103
|
+
};
|
|
104
|
+
|
|
85
105
|
// AbstractAgent.runAgent declares two parameters. HttpAgent ignores a third and
|
|
86
106
|
// is cancelled through agent.abortRun(); the run options stay for subclasses
|
|
87
107
|
// that inherit the base no-op abortRun and have no other cancellation hook.
|
|
@@ -280,22 +300,35 @@ const replaceTextParts = (
|
|
|
280
300
|
return result;
|
|
281
301
|
};
|
|
282
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Settles nested sub-agent messages with `status`. Without `runSubagentIds`,
|
|
305
|
+
* only running messages settle. With it, only that run's sub-agent messages
|
|
306
|
+
* settle, and an incomplete `status` also settles one still waiting on its own
|
|
307
|
+
* tool call: the run that could answer it has ended.
|
|
308
|
+
*/
|
|
283
309
|
const settleNestedContent = (
|
|
284
310
|
content: ThreadAssistantMessage["content"],
|
|
285
311
|
status: MessageStatus,
|
|
312
|
+
runSubagentIds?: ReadonlySet<string>,
|
|
286
313
|
) =>
|
|
287
|
-
mapToolCallPartsDeep(content, (part) =>
|
|
288
|
-
part.messages
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
314
|
+
mapToolCallPartsDeep(content, (part) => {
|
|
315
|
+
if (!part.messages) return part;
|
|
316
|
+
let changed = false;
|
|
317
|
+
const messages = part.messages.map((message) => {
|
|
318
|
+
if (message.role !== "assistant") return message;
|
|
319
|
+
const settles = !runSubagentIds
|
|
320
|
+
? message.status?.type === "running"
|
|
321
|
+
: runSubagentIds.has(message.id) &&
|
|
322
|
+
(message.status?.type === "running" ||
|
|
323
|
+
(status.type === "incomplete" &&
|
|
324
|
+
message.status?.type === "requires-action" &&
|
|
325
|
+
message.status.reason === "tool-calls"));
|
|
326
|
+
if (!settles) return message;
|
|
327
|
+
changed = true;
|
|
328
|
+
return { ...message, status };
|
|
329
|
+
});
|
|
330
|
+
return changed ? { ...part, messages } : part;
|
|
331
|
+
}).content;
|
|
299
332
|
|
|
300
333
|
type AgUiRuntimeCallbackName = "onError" | "onCancel";
|
|
301
334
|
|
|
@@ -372,6 +405,8 @@ export class AgUiThreadController {
|
|
|
372
405
|
// The agent that started the active run. updateOptions can swap this.agent
|
|
373
406
|
// mid-run, and cancelling has to reach the agent holding the live request.
|
|
374
407
|
private activeRunAgent: AbstractAgent | null = null;
|
|
408
|
+
// Counts runs started, so a finished run can tell a newer one began.
|
|
409
|
+
private runCount = 0;
|
|
375
410
|
private history: ThreadHistoryAdapter | undefined;
|
|
376
411
|
private readonly assistantHistoryParents = new Map<string, string | null>();
|
|
377
412
|
private readonly subagentParentToolCallIds = new Map<
|
|
@@ -380,12 +415,22 @@ export class AgUiThreadController {
|
|
|
380
415
|
>();
|
|
381
416
|
private readonly snapshotHistoryIds = new Set<string>();
|
|
382
417
|
private readonly persistedHistoryIds = new Set<string>();
|
|
418
|
+
// Ids whose append has not succeeded yet. A write queued behind that append
|
|
419
|
+
// runs as an append too, since an update may not move the stored head.
|
|
420
|
+
private readonly unappendedHistoryIds = new Set<string>();
|
|
383
421
|
private readonly historyWrites = createHistoryWriteChain();
|
|
384
422
|
private _historyImportPromise: Promise<void> | undefined;
|
|
385
423
|
private _loadPromise: Promise<void> | undefined;
|
|
386
424
|
|
|
387
425
|
constructor(options: CoreOptions) {
|
|
388
426
|
this.agent = options.agent;
|
|
427
|
+
const initialState = getAgentInitialState(this.agent);
|
|
428
|
+
if (initialState !== undefined) {
|
|
429
|
+
this.dispatch({
|
|
430
|
+
type: "stateSnapshot",
|
|
431
|
+
snapshot: initialState,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
389
434
|
this.logger = options.logger;
|
|
390
435
|
this.showThinking = options.showThinking;
|
|
391
436
|
this.joinStrategy = options.joinStrategy;
|
|
@@ -421,7 +466,18 @@ export class AgUiThreadController {
|
|
|
421
466
|
}
|
|
422
467
|
|
|
423
468
|
updateOptions(options: CoreOptions) {
|
|
469
|
+
const agentChanged = this.agent !== options.agent;
|
|
424
470
|
this.agent = options.agent;
|
|
471
|
+
if (agentChanged) {
|
|
472
|
+
const initialState = getAgentInitialState(this.agent);
|
|
473
|
+
if (
|
|
474
|
+
this.state.stateSnapshot === undefined &&
|
|
475
|
+
initialState !== undefined
|
|
476
|
+
) {
|
|
477
|
+
this.dispatch({ type: "stateSnapshot", snapshot: initialState });
|
|
478
|
+
this.publishUpdate();
|
|
479
|
+
}
|
|
480
|
+
}
|
|
425
481
|
this.logger = options.logger;
|
|
426
482
|
this.showThinking = options.showThinking;
|
|
427
483
|
this.joinStrategy = options.joinStrategy;
|
|
@@ -487,6 +543,7 @@ export class AgUiThreadController {
|
|
|
487
543
|
this.subagentParentToolCallIds.clear();
|
|
488
544
|
this.snapshotHistoryIds.clear();
|
|
489
545
|
this.persistedHistoryIds.clear();
|
|
546
|
+
this.unappendedHistoryIds.clear();
|
|
490
547
|
for (const { message } of repo.messages) {
|
|
491
548
|
this.persistedHistoryIds.add(message.id);
|
|
492
549
|
}
|
|
@@ -1683,6 +1740,8 @@ export class AgUiThreadController {
|
|
|
1683
1740
|
const assistantParentId = parent ? parentId : this.session.headId;
|
|
1684
1741
|
let assistantMessageId: string | undefined;
|
|
1685
1742
|
let snapshotAssistantId: string | undefined;
|
|
1743
|
+
const importedRunAssistants = new Set<string>();
|
|
1744
|
+
const runSubagentIds = new Set<string>();
|
|
1686
1745
|
let replayInterruptMessageId: string | undefined;
|
|
1687
1746
|
// A snapshot the preserve gate declines still evicts the in-flight
|
|
1688
1747
|
// assistant; recreating under the cached id on the next content-bearing
|
|
@@ -2225,9 +2284,38 @@ export class AgUiThreadController {
|
|
|
2225
2284
|
adoptServerMessageId(serverId, true, frameOpenIds);
|
|
2226
2285
|
},
|
|
2227
2286
|
});
|
|
2287
|
+
const settleRunSubagents = (status: MessageStatus) => {
|
|
2288
|
+
if (runSubagentIds.size === 0) return;
|
|
2289
|
+
for (const message of this.session.getMessages()) {
|
|
2290
|
+
if (message.role !== "assistant") continue;
|
|
2291
|
+
const content = settleNestedContent(
|
|
2292
|
+
message.content,
|
|
2293
|
+
status,
|
|
2294
|
+
runSubagentIds,
|
|
2295
|
+
);
|
|
2296
|
+
if (content === message.content) continue;
|
|
2297
|
+
this.updateAssistantMessage(message.id, { content });
|
|
2298
|
+
this.persistSettledAssistant(message.id);
|
|
2299
|
+
}
|
|
2300
|
+
};
|
|
2301
|
+
// Writes this run's other assistants in branch order: before the answer
|
|
2302
|
+
// settles, the ones above it; after, the ones below. An adapter that
|
|
2303
|
+
// moves its head on each first write then ends on the branch's tail.
|
|
2304
|
+
const persistRunAssistants = (aboveAnswer: boolean) => {
|
|
2305
|
+
for (const message of this.session.getMessages()) {
|
|
2306
|
+
if (message.id === assistantMessageId) {
|
|
2307
|
+
if (aboveAnswer) return;
|
|
2308
|
+
continue;
|
|
2309
|
+
}
|
|
2310
|
+
if (message.role === "assistant" && !runStartMessageIds.has(message.id))
|
|
2311
|
+
this.persistAssistantHistory(message.id);
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2228
2314
|
let runFinished = false;
|
|
2229
2315
|
const dispatch = (event: AgUiEvent) => {
|
|
2230
2316
|
if (this.abortController !== abortController) return;
|
|
2317
|
+
if (event.type === "SUBAGENT_STARTED")
|
|
2318
|
+
runSubagentIds.add(event.subagentRunId);
|
|
2231
2319
|
if (event.type === "RUN_FINISHED") {
|
|
2232
2320
|
runFinished = true;
|
|
2233
2321
|
if (event.outcome?.type === "interrupt")
|
|
@@ -2250,6 +2338,7 @@ export class AgUiThreadController {
|
|
|
2250
2338
|
event.type === "RUN_FINISHED" ||
|
|
2251
2339
|
event.type === "RUN_ERROR" ||
|
|
2252
2340
|
event.type === "RUN_CANCELLED";
|
|
2341
|
+
if (settlingRun) persistRunAssistants(true);
|
|
2253
2342
|
const beforeSnapshot =
|
|
2254
2343
|
event.type === "MESSAGES_SNAPSHOT" ? this.session.getMessages() : [];
|
|
2255
2344
|
try {
|
|
@@ -2263,6 +2352,31 @@ export class AgUiThreadController {
|
|
|
2263
2352
|
candidateContexts.size ? reconcileReplaySnapshot : undefined,
|
|
2264
2353
|
);
|
|
2265
2354
|
if (event.type === "MESSAGES_SNAPSHOT") {
|
|
2355
|
+
// Assistants a snapshot adds belong to the run's history even when
|
|
2356
|
+
// the run never streams into them. Every import resets the pending
|
|
2357
|
+
// marks, so each snapshot marks them again. The import also routes
|
|
2358
|
+
// every message to update; a run assistant this client never wrote
|
|
2359
|
+
// gets an append first, which is what moves the stored head.
|
|
2360
|
+
const previousIds = new Set(beforeSnapshot.map(({ id }) => id));
|
|
2361
|
+
for (const message of this.session.getMessages()) {
|
|
2362
|
+
if (
|
|
2363
|
+
message.role !== "assistant" ||
|
|
2364
|
+
runStartMessageIds.has(message.id)
|
|
2365
|
+
)
|
|
2366
|
+
continue;
|
|
2367
|
+
if (!previousIds.has(message.id))
|
|
2368
|
+
importedRunAssistants.add(message.id);
|
|
2369
|
+
if (
|
|
2370
|
+
!this.persistedHistoryIds.has(message.id) &&
|
|
2371
|
+
this.historyWrites.pending(message.id) === undefined
|
|
2372
|
+
)
|
|
2373
|
+
this.snapshotHistoryIds.delete(message.id);
|
|
2374
|
+
}
|
|
2375
|
+
for (const messageId of importedRunAssistants) {
|
|
2376
|
+
const item = this.session.tryGetMessage(messageId);
|
|
2377
|
+
if (item)
|
|
2378
|
+
this.markPendingAssistantHistory(messageId, item.parentId);
|
|
2379
|
+
}
|
|
2266
2380
|
for (const message of beforeSnapshot) {
|
|
2267
2381
|
const context = replayContexts.get(message.id);
|
|
2268
2382
|
const current = this.session.tryGetMessage(message.id)?.message;
|
|
@@ -2294,6 +2408,16 @@ export class AgUiThreadController {
|
|
|
2294
2408
|
tail?.role === "assistant" ? tail.id : undefined;
|
|
2295
2409
|
}
|
|
2296
2410
|
}
|
|
2411
|
+
if (event.type === "RUN_ERROR") {
|
|
2412
|
+
settleRunSubagents({
|
|
2413
|
+
type: "incomplete",
|
|
2414
|
+
reason: "error",
|
|
2415
|
+
...(event.message !== undefined ? { error: event.message } : {}),
|
|
2416
|
+
});
|
|
2417
|
+
} else if (event.type === "RUN_CANCELLED") {
|
|
2418
|
+
settleRunSubagents({ type: "incomplete", reason: "cancelled" });
|
|
2419
|
+
}
|
|
2420
|
+
if (settlingRun) persistRunAssistants(false);
|
|
2297
2421
|
} finally {
|
|
2298
2422
|
settlingRun = false;
|
|
2299
2423
|
}
|
|
@@ -2302,6 +2426,7 @@ export class AgUiThreadController {
|
|
|
2302
2426
|
this.abortController = abortController;
|
|
2303
2427
|
const runAgentInstance = this.agent;
|
|
2304
2428
|
this.activeRunAgent = runAgentInstance;
|
|
2429
|
+
const runNumber = ++this.runCount;
|
|
2305
2430
|
|
|
2306
2431
|
let cancelRun = () => dispatch({ type: "RUN_CANCELLED" });
|
|
2307
2432
|
abortSignal.addEventListener(
|
|
@@ -2385,6 +2510,7 @@ export class AgUiThreadController {
|
|
|
2385
2510
|
current?.role === "assistant" &&
|
|
2386
2511
|
current.status?.type === "running"
|
|
2387
2512
|
) {
|
|
2513
|
+
persistRunAssistants(true);
|
|
2388
2514
|
// Finalization is transport cleanup, not proof of a terminal event.
|
|
2389
2515
|
const status: MessageStatus = {
|
|
2390
2516
|
type: "incomplete",
|
|
@@ -2397,6 +2523,25 @@ export class AgUiThreadController {
|
|
|
2397
2523
|
content: settleNestedContent(current.content, status),
|
|
2398
2524
|
});
|
|
2399
2525
|
}
|
|
2526
|
+
if (this.abortController === abortController && !runFinished) {
|
|
2527
|
+
// The run ended without RUN_FINISHED, so no sub-agent call it left
|
|
2528
|
+
// open can still be answered.
|
|
2529
|
+
const settled = assistantMessageId
|
|
2530
|
+
? this.session.tryGetMessage(assistantMessageId)?.message
|
|
2531
|
+
: undefined;
|
|
2532
|
+
settleRunSubagents(
|
|
2533
|
+
settled?.status?.type === "incomplete"
|
|
2534
|
+
? settled.status
|
|
2535
|
+
: {
|
|
2536
|
+
type: "incomplete",
|
|
2537
|
+
reason: "error",
|
|
2538
|
+
error: "The connection ended before the answer finished.",
|
|
2539
|
+
},
|
|
2540
|
+
);
|
|
2541
|
+
}
|
|
2542
|
+
// Stop may have finished this run already; a newer run owns its own
|
|
2543
|
+
// writes.
|
|
2544
|
+
if (this.runCount === runNumber) persistRunAssistants(false);
|
|
2400
2545
|
this.finishRun(abortController);
|
|
2401
2546
|
}
|
|
2402
2547
|
|
|
@@ -2735,11 +2880,14 @@ export class AgUiThreadController {
|
|
|
2735
2880
|
oldId,
|
|
2736
2881
|
collidesWithExisting ? undefined : newId,
|
|
2737
2882
|
);
|
|
2883
|
+
if (this.unappendedHistoryIds.delete(oldId) && !collidesWithExisting)
|
|
2884
|
+
this.unappendedHistoryIds.add(newId);
|
|
2738
2885
|
if (pendingWrite && !collidesWithExisting) {
|
|
2739
2886
|
void pendingWrite.then(
|
|
2740
2887
|
() => {
|
|
2741
2888
|
this.persistedHistoryIds.delete(oldId);
|
|
2742
2889
|
this.persistedHistoryIds.add(newId);
|
|
2890
|
+
this.unappendedHistoryIds.delete(newId);
|
|
2743
2891
|
},
|
|
2744
2892
|
() => {},
|
|
2745
2893
|
);
|
|
@@ -2775,7 +2923,7 @@ export class AgUiThreadController {
|
|
|
2775
2923
|
update.content as ThreadAssistantMessage["content"],
|
|
2776
2924
|
)
|
|
2777
2925
|
: assistant.content;
|
|
2778
|
-
const calls =
|
|
2926
|
+
const calls = Array.from(iterateToolCallParts(content));
|
|
2779
2927
|
// A retained historical call needs existing or current stream authority.
|
|
2780
2928
|
const pending =
|
|
2781
2929
|
pendingToolCallIds ??
|
|
@@ -2789,7 +2937,11 @@ export class AgUiThreadController {
|
|
|
2789
2937
|
: undefined);
|
|
2790
2938
|
if (replay && latestStatus?.type !== "running" && pending)
|
|
2791
2939
|
replay.admittedToolCallIds = pending;
|
|
2792
|
-
if (
|
|
2940
|
+
if (
|
|
2941
|
+
replay ||
|
|
2942
|
+
(latestStatus?.type === "requires-action" &&
|
|
2943
|
+
latestStatus.reason === "interrupt")
|
|
2944
|
+
) {
|
|
2793
2945
|
// Project gates against the content the user can actually answer.
|
|
2794
2946
|
const interrupts =
|
|
2795
2947
|
latestStatus?.type === "requires-action" &&
|
|
@@ -2805,10 +2957,10 @@ export class AgUiThreadController {
|
|
|
2805
2957
|
new Set(calls.map((part) => part.toolCallId)),
|
|
2806
2958
|
);
|
|
2807
2959
|
const originalCalls = new Map(
|
|
2808
|
-
Array.from(
|
|
2809
|
-
|
|
2810
|
-
part,
|
|
2811
|
-
|
|
2960
|
+
Array.from(
|
|
2961
|
+
iterateToolCallParts(replay?.originalContent ?? assistant.content),
|
|
2962
|
+
(part) => [part.toolCallId, part],
|
|
2963
|
+
),
|
|
2812
2964
|
);
|
|
2813
2965
|
content = mapToolCallPartsDeep(content, (part) => {
|
|
2814
2966
|
const approval = approvals.get(part.toolCallId);
|
|
@@ -3301,6 +3453,9 @@ export class AgUiThreadController {
|
|
|
3301
3453
|
}
|
|
3302
3454
|
}
|
|
3303
3455
|
activeAssistantId = nextActiveAssistantId;
|
|
3456
|
+
if (activeAssistantId && joinStrategy !== "concat-content") {
|
|
3457
|
+
aggregator.rebaseMessage(activeAssistantId);
|
|
3458
|
+
}
|
|
3304
3459
|
if (hasNewPendingToolCallAnswer && head) {
|
|
3305
3460
|
this.updateAssistantMessage(head.id, {
|
|
3306
3461
|
status: { type: "running" },
|
|
@@ -3592,10 +3747,28 @@ export class AgUiThreadController {
|
|
|
3592
3747
|
previous?.status?.type === "running" &&
|
|
3593
3748
|
snapshot.status?.type === "requires-action" &&
|
|
3594
3749
|
snapshot.status.reason === "tool-calls";
|
|
3750
|
+
const previousToolCallIds = new Set(
|
|
3751
|
+
Array.from(
|
|
3752
|
+
iterateToolCallParts(previous?.content ?? []),
|
|
3753
|
+
(call) => call.toolCallId,
|
|
3754
|
+
),
|
|
3755
|
+
);
|
|
3756
|
+
const preservesSettledStatus =
|
|
3757
|
+
convertedMessage.id !== activeAssistantId &&
|
|
3758
|
+
this.isTerminalStatus(previous?.status) &&
|
|
3759
|
+
snapshot.status?.type === "requires-action" &&
|
|
3760
|
+
snapshot.status.reason === "tool-calls" &&
|
|
3761
|
+
Array.from(iterateToolCallParts(snapshot.content)).every(
|
|
3762
|
+
(call) =>
|
|
3763
|
+
isResolvedToolCall(call) ||
|
|
3764
|
+
previousToolCallIds.has(call.toolCallId),
|
|
3765
|
+
);
|
|
3595
3766
|
converted[index] = previous
|
|
3596
3767
|
? {
|
|
3597
3768
|
...snapshot,
|
|
3598
|
-
...(preservesRunningStatus
|
|
3769
|
+
...(preservesRunningStatus || preservesSettledStatus
|
|
3770
|
+
? { status: previous.status }
|
|
3771
|
+
: {}),
|
|
3599
3772
|
content: this.mergeSnapshotAssistantContent(
|
|
3600
3773
|
previous.content,
|
|
3601
3774
|
snapshot.content,
|
|
@@ -3796,7 +3969,9 @@ export class AgUiThreadController {
|
|
|
3796
3969
|
|
|
3797
3970
|
if (shouldUpdate) {
|
|
3798
3971
|
const write = this.historyWrites.chain(messageId, () =>
|
|
3799
|
-
|
|
3972
|
+
this.unappendedHistoryIds.has(messageId)
|
|
3973
|
+
? this.appendOnce(history, messageId, { parentId, message })
|
|
3974
|
+
: update.call(history, { parentId, message }),
|
|
3800
3975
|
);
|
|
3801
3976
|
this.assistantHistoryParents.delete(messageId);
|
|
3802
3977
|
void write.then(
|
|
@@ -3834,6 +4009,15 @@ export class AgUiThreadController {
|
|
|
3834
4009
|
);
|
|
3835
4010
|
}
|
|
3836
4011
|
|
|
4012
|
+
private async appendOnce(
|
|
4013
|
+
history: ThreadHistoryAdapter,
|
|
4014
|
+
messageId: string,
|
|
4015
|
+
item: { parentId: string | null; message: ThreadMessage },
|
|
4016
|
+
): Promise<void> {
|
|
4017
|
+
await history.append(item);
|
|
4018
|
+
this.unappendedHistoryIds.delete(messageId);
|
|
4019
|
+
}
|
|
4020
|
+
|
|
3837
4021
|
private appendHistoryItem(
|
|
3838
4022
|
parentId: string | null,
|
|
3839
4023
|
message: ThreadMessage,
|
|
@@ -3842,9 +4026,10 @@ export class AgUiThreadController {
|
|
|
3842
4026
|
const pending = this.historyWrites.pending(message.id);
|
|
3843
4027
|
if (pending) return pending;
|
|
3844
4028
|
|
|
3845
|
-
const
|
|
4029
|
+
const history = this.history;
|
|
4030
|
+
this.unappendedHistoryIds.add(message.id);
|
|
3846
4031
|
const write = this.historyWrites.chain(message.id, () =>
|
|
3847
|
-
|
|
4032
|
+
this.appendOnce(history, message.id, { parentId, message }),
|
|
3848
4033
|
);
|
|
3849
4034
|
void write.then(
|
|
3850
4035
|
() => {
|
|
@@ -267,6 +267,17 @@ export class RunAggregator {
|
|
|
267
267
|
return this.toolCalls.has(toolCallId);
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
rebaseMessage(messageId: string): void {
|
|
271
|
+
const snapshotText = this.pendingSnapshotText.get(messageId);
|
|
272
|
+
this.resetMessageParts();
|
|
273
|
+
this.lastTextMessageId = messageId;
|
|
274
|
+
this.reportedServerMessageId = messageId;
|
|
275
|
+
if (snapshotText !== undefined) {
|
|
276
|
+
this.pendingSnapshotText.set(messageId, snapshotText);
|
|
277
|
+
}
|
|
278
|
+
this.startTextMessage(ROOT_SCOPE, messageId);
|
|
279
|
+
}
|
|
280
|
+
|
|
270
281
|
handle(event: AgUiEvent): void {
|
|
271
282
|
switch (event.type) {
|
|
272
283
|
case "RUN_STARTED": {
|