@assistant-ui/react-ag-ui 0.1.2 → 0.1.3

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.
@@ -58,9 +58,12 @@ const answerRecord = {
58
58
  ],
59
59
  };
60
60
  const otherRecord = { id: "other", role: "assistant", content: "Next step." };
61
- const taskAndSubagentEvents = [
61
+ const taskCallEvents = [
62
62
  { type: "TOOL_CALL_START", toolCallId: "task", toolCallName: "task" },
63
63
  { type: "TOOL_CALL_END", toolCallId: "task" },
64
+ ];
65
+ const taskAndSubagentEvents = [
66
+ ...taskCallEvents,
64
67
  {
65
68
  type: "SUBAGENT_STARTED",
66
69
  subagentRunId: "sub",
@@ -106,7 +109,53 @@ function statuses(core: AgUiThreadController) {
106
109
  return { answer: answer.status?.type, subagent: nested?.status?.type };
107
110
  }
108
111
 
112
+ function taskPartFor(core: AgUiThreadController, messageId: string) {
113
+ const message = core.getMessages().find(({ id }) => id === messageId);
114
+ if (message?.role !== "assistant") return undefined;
115
+ return message.content.find(
116
+ (part) => part.type === "tool-call" && part.toolCallId === "task",
117
+ );
118
+ }
119
+
109
120
  describe("AgUiThreadController snapshot head moves", () => {
121
+ it("reopens a settled snapshot-only assistant when a later snapshot adds a tool call", async () => {
122
+ const answer = {
123
+ id: "m1",
124
+ role: "assistant",
125
+ content: "Let me ask.",
126
+ };
127
+ const core = createController((wire) => [
128
+ start,
129
+ { type: "MESSAGES_SNAPSHOT", messages: [...wire.messages, answer] },
130
+ {
131
+ type: "MESSAGES_SNAPSHOT",
132
+ messages: [
133
+ ...wire.messages,
134
+ {
135
+ ...answer,
136
+ toolCalls: [
137
+ {
138
+ id: "ask",
139
+ type: "function",
140
+ function: { name: "ask_user", arguments: "{}" },
141
+ },
142
+ ],
143
+ },
144
+ ],
145
+ },
146
+ finished,
147
+ ]);
148
+
149
+ await core.append(userMessage);
150
+
151
+ expect(
152
+ core.getMessages().find(({ id }) => id === "m1")?.status,
153
+ ).toMatchObject({
154
+ type: "requires-action",
155
+ reason: "tool-calls",
156
+ });
157
+ });
158
+
110
159
  it("settles the moved-off answer when the snapshot has no tool call", async () => {
111
160
  const core = createController((wire) => [
112
161
  start,
@@ -143,6 +192,136 @@ describe("AgUiThreadController snapshot head moves", () => {
143
192
  });
144
193
  });
145
194
 
195
+ it("keeps a moved-off answer settled and owns the result after repeated snapshots", async () => {
196
+ const core = createController((wire) => [
197
+ start,
198
+ ...answerText,
199
+ ...taskCallEvents,
200
+ snapshot(wire, otherRecord),
201
+ snapshot(wire, otherRecord),
202
+ taskResult,
203
+ finished,
204
+ ]);
205
+ await core.append(userMessage);
206
+
207
+ expect(core.isRunning()).toBe(false);
208
+ expect(statuses(core).answer).toBe("complete");
209
+ expect(
210
+ core.getMessages().find(({ id }) => id === "other")?.status?.type,
211
+ ).toBe("complete");
212
+ expect(taskPartFor(core, "answer")).toMatchObject({ result: "done" });
213
+ expect(taskPartFor(core, "other")).toBeUndefined();
214
+ expect(
215
+ core.getMessages().find(({ id }) => id === "answer")?.content,
216
+ ).toEqual([
217
+ { type: "text", text: "Let me check." },
218
+ {
219
+ type: "tool-call",
220
+ toolCallId: "task",
221
+ toolName: "task",
222
+ args: {},
223
+ argsText: "{}",
224
+ result: "done",
225
+ unstable_toolMessageId: "tool-task",
226
+ },
227
+ ]);
228
+ expect(
229
+ core.getMessages().find(({ id }) => id === "other")?.content,
230
+ ).toEqual([{ type: "text", text: "Next step." }]);
231
+ });
232
+
233
+ it("preserves full content and settled statuses with a sub-agent after repeated snapshots", async () => {
234
+ const core = createController((wire) => [
235
+ start,
236
+ ...answerText,
237
+ ...taskAndSubagentEvents,
238
+ snapshot(wire, otherRecord),
239
+ snapshot(wire, otherRecord),
240
+ subFinished,
241
+ taskResult,
242
+ finished,
243
+ ]);
244
+ await core.append(userMessage);
245
+
246
+ expect(core.isRunning()).toBe(false);
247
+ expect(statuses(core)).toEqual({
248
+ answer: "complete",
249
+ subagent: "complete",
250
+ });
251
+ expect(
252
+ core.getMessages().find(({ id }) => id === "other")?.status?.type,
253
+ ).toBe("complete");
254
+ expect(
255
+ core.getMessages().find(({ id }) => id === "answer")?.content,
256
+ ).toEqual([
257
+ { type: "text", text: "Let me check." },
258
+ {
259
+ type: "tool-call",
260
+ toolCallId: "task",
261
+ toolName: "task",
262
+ args: {},
263
+ argsText: "{}",
264
+ result: "done",
265
+ unstable_toolMessageId: "tool-task",
266
+ messages: [
267
+ expect.objectContaining({
268
+ id: "sub",
269
+ role: "assistant",
270
+ status: { type: "complete", reason: "unknown" },
271
+ content: [{ type: "text", text: "working" }],
272
+ }),
273
+ ],
274
+ },
275
+ ]);
276
+ expect(
277
+ core.getMessages().find(({ id }) => id === "other")?.content,
278
+ ).toEqual([{ type: "text", text: "Next step." }]);
279
+ });
280
+
281
+ it.each([false, true])(
282
+ "preserves current text when the moved result arrives during streaming: %s",
283
+ async (duringStreaming) => {
284
+ const core = createController((wire) => [
285
+ start,
286
+ ...answerText,
287
+ ...taskCallEvents,
288
+ snapshot(wire, otherRecord),
289
+ snapshot(wire, otherRecord),
290
+ ...(!duringStreaming ? [taskResult] : []),
291
+ { type: "TEXT_MESSAGE_START", messageId: "other", role: "assistant" },
292
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "other", delta: " Before." },
293
+ ...(duringStreaming ? [taskResult] : []),
294
+ { type: "TEXT_MESSAGE_CONTENT", messageId: "other", delta: " After." },
295
+ { type: "TEXT_MESSAGE_END", messageId: "other" },
296
+ finished,
297
+ ]);
298
+ await core.append(userMessage);
299
+
300
+ expect(core.isRunning()).toBe(false);
301
+ expect(statuses(core).answer).toBe("complete");
302
+ expect(
303
+ core.getMessages().find(({ id }) => id === "answer")?.content,
304
+ ).toEqual([
305
+ { type: "text", text: "Let me check." },
306
+ {
307
+ type: "tool-call",
308
+ toolCallId: "task",
309
+ toolName: "task",
310
+ args: {},
311
+ argsText: "{}",
312
+ result: "done",
313
+ unstable_toolMessageId: "tool-task",
314
+ },
315
+ ]);
316
+ expect(core.getMessages().find(({ id }) => id === "other")).toMatchObject(
317
+ {
318
+ status: { type: "complete" },
319
+ content: [{ type: "text", text: "Next step. Before. After." }],
320
+ },
321
+ );
322
+ },
323
+ );
324
+
146
325
  it("does not show a complete answer with a running sub-agent after a result snapshot", async () => {
147
326
  const core = createController((wire) => [
148
327
  start,
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi } from "vitest";
2
- import type { AbstractAgent } from "@ag-ui/client";
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.
@@ -386,6 +406,13 @@ export class AgUiThreadController {
386
406
 
387
407
  constructor(options: CoreOptions) {
388
408
  this.agent = options.agent;
409
+ const initialState = getAgentInitialState(this.agent);
410
+ if (initialState !== undefined) {
411
+ this.dispatch({
412
+ type: "stateSnapshot",
413
+ snapshot: initialState,
414
+ });
415
+ }
389
416
  this.logger = options.logger;
390
417
  this.showThinking = options.showThinking;
391
418
  this.joinStrategy = options.joinStrategy;
@@ -421,7 +448,18 @@ export class AgUiThreadController {
421
448
  }
422
449
 
423
450
  updateOptions(options: CoreOptions) {
451
+ const agentChanged = this.agent !== options.agent;
424
452
  this.agent = options.agent;
453
+ if (agentChanged) {
454
+ const initialState = getAgentInitialState(this.agent);
455
+ if (
456
+ this.state.stateSnapshot === undefined &&
457
+ initialState !== undefined
458
+ ) {
459
+ this.dispatch({ type: "stateSnapshot", snapshot: initialState });
460
+ this.publishUpdate();
461
+ }
462
+ }
425
463
  this.logger = options.logger;
426
464
  this.showThinking = options.showThinking;
427
465
  this.joinStrategy = options.joinStrategy;
@@ -2775,7 +2813,7 @@ export class AgUiThreadController {
2775
2813
  update.content as ThreadAssistantMessage["content"],
2776
2814
  )
2777
2815
  : assistant.content;
2778
- const calls = replay ? Array.from(iterateToolCallParts(content)) : [];
2816
+ const calls = Array.from(iterateToolCallParts(content));
2779
2817
  // A retained historical call needs existing or current stream authority.
2780
2818
  const pending =
2781
2819
  pendingToolCallIds ??
@@ -2789,7 +2827,11 @@ export class AgUiThreadController {
2789
2827
  : undefined);
2790
2828
  if (replay && latestStatus?.type !== "running" && pending)
2791
2829
  replay.admittedToolCallIds = pending;
2792
- if (replay) {
2830
+ if (
2831
+ replay ||
2832
+ (latestStatus?.type === "requires-action" &&
2833
+ latestStatus.reason === "interrupt")
2834
+ ) {
2793
2835
  // Project gates against the content the user can actually answer.
2794
2836
  const interrupts =
2795
2837
  latestStatus?.type === "requires-action" &&
@@ -2805,10 +2847,10 @@ export class AgUiThreadController {
2805
2847
  new Set(calls.map((part) => part.toolCallId)),
2806
2848
  );
2807
2849
  const originalCalls = new Map(
2808
- Array.from(iterateToolCallParts(replay.originalContent), (part) => [
2809
- part.toolCallId,
2810
- part,
2811
- ]),
2850
+ Array.from(
2851
+ iterateToolCallParts(replay?.originalContent ?? assistant.content),
2852
+ (part) => [part.toolCallId, part],
2853
+ ),
2812
2854
  );
2813
2855
  content = mapToolCallPartsDeep(content, (part) => {
2814
2856
  const approval = approvals.get(part.toolCallId);
@@ -3301,6 +3343,9 @@ export class AgUiThreadController {
3301
3343
  }
3302
3344
  }
3303
3345
  activeAssistantId = nextActiveAssistantId;
3346
+ if (activeAssistantId && joinStrategy !== "concat-content") {
3347
+ aggregator.rebaseMessage(activeAssistantId);
3348
+ }
3304
3349
  if (hasNewPendingToolCallAnswer && head) {
3305
3350
  this.updateAssistantMessage(head.id, {
3306
3351
  status: { type: "running" },
@@ -3592,10 +3637,28 @@ export class AgUiThreadController {
3592
3637
  previous?.status?.type === "running" &&
3593
3638
  snapshot.status?.type === "requires-action" &&
3594
3639
  snapshot.status.reason === "tool-calls";
3640
+ const previousToolCallIds = new Set(
3641
+ Array.from(
3642
+ iterateToolCallParts(previous?.content ?? []),
3643
+ (call) => call.toolCallId,
3644
+ ),
3645
+ );
3646
+ const preservesSettledStatus =
3647
+ convertedMessage.id !== activeAssistantId &&
3648
+ this.isTerminalStatus(previous?.status) &&
3649
+ snapshot.status?.type === "requires-action" &&
3650
+ snapshot.status.reason === "tool-calls" &&
3651
+ Array.from(iterateToolCallParts(snapshot.content)).every(
3652
+ (call) =>
3653
+ isResolvedToolCall(call) ||
3654
+ previousToolCallIds.has(call.toolCallId),
3655
+ );
3595
3656
  converted[index] = previous
3596
3657
  ? {
3597
3658
  ...snapshot,
3598
- ...(preservesRunningStatus ? { status: previous.status } : {}),
3659
+ ...(preservesRunningStatus || preservesSettledStatus
3660
+ ? { status: previous.status }
3661
+ : {}),
3599
3662
  content: this.mergeSnapshotAssistantContent(
3600
3663
  previous.content,
3601
3664
  snapshot.content,
@@ -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": {