@assistant-ui/react-ag-ui 0.1.1 → 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.
Files changed (43) hide show
  1. package/README.md +2 -0
  2. package/dist/runtime/AgUiThreadController.d.ts +11 -0
  3. package/dist/runtime/AgUiThreadController.d.ts.map +1 -1
  4. package/dist/runtime/AgUiThreadController.js +242 -25
  5. package/dist/runtime/AgUiThreadController.js.map +1 -1
  6. package/dist/runtime/adapter/conversions.d.ts +9 -1
  7. package/dist/runtime/adapter/conversions.d.ts.map +1 -1
  8. package/dist/runtime/adapter/conversions.js +184 -9
  9. package/dist/runtime/adapter/conversions.js.map +1 -1
  10. package/dist/runtime/adapter/run-aggregator.d.ts +2 -0
  11. package/dist/runtime/adapter/run-aggregator.d.ts.map +1 -1
  12. package/dist/runtime/adapter/run-aggregator.js +42 -12
  13. package/dist/runtime/adapter/run-aggregator.js.map +1 -1
  14. package/dist/runtime/adapter/subscriber.d.ts +0 -1
  15. package/dist/runtime/adapter/subscriber.d.ts.map +1 -1
  16. package/dist/runtime/adapter/subscriber.js +1 -8
  17. package/dist/runtime/adapter/subscriber.js.map +1 -1
  18. package/dist/runtime/event-parser.d.ts.map +1 -1
  19. package/dist/runtime/event-parser.js +1 -0
  20. package/dist/runtime/event-parser.js.map +1 -1
  21. package/dist/runtime/types.d.ts +2 -0
  22. package/dist/runtime/types.d.ts.map +1 -1
  23. package/dist/runtime/types.js +2 -1
  24. package/dist/runtime/types.js.map +1 -1
  25. package/package.json +6 -5
  26. package/src/runtime/AgUiThreadController.disconnect.test.ts +914 -0
  27. package/src/runtime/AgUiThreadController.interrupt-history.test.ts +392 -0
  28. package/src/runtime/AgUiThreadController.replay.test.ts +8 -1
  29. package/src/runtime/AgUiThreadController.snapshot-head-move.test.ts +346 -0
  30. package/src/runtime/AgUiThreadController.subagent-snapshot.test.ts +854 -0
  31. package/src/runtime/AgUiThreadController.test.ts +173 -1
  32. package/src/runtime/AgUiThreadController.ts +528 -33
  33. package/src/runtime/adapter/conversions.content.test.ts +129 -0
  34. package/src/runtime/adapter/conversions.subagent-messages.test.ts +550 -0
  35. package/src/runtime/adapter/conversions.ts +331 -20
  36. package/src/runtime/adapter/run-aggregator.metadata.test.ts +151 -0
  37. package/src/runtime/adapter/run-aggregator.ts +63 -9
  38. package/src/runtime/adapter/subscriber.test.ts +1 -1
  39. package/src/runtime/adapter/subscriber.ts +1 -6
  40. package/src/runtime/event-parser.ts +3 -0
  41. package/src/runtime/types.ts +5 -0
  42. package/src/useAgUiRuntime.cloud-run-leases.test.tsx +384 -0
  43. package/src/useAgUiRuntime.state.test.tsx +87 -0
@@ -22,6 +22,7 @@ import {
22
22
  import jsonpatch, { type Operation } from "fast-json-patch";
23
23
  import { readMcpAppResourceUri, toolContentText } from "../mcp-tool-result";
24
24
  import { projectAgUiToolApprovals } from "./tool-approval";
25
+ import { withAgUiPartMessageMetadata } from "./conversions";
25
26
  import {
26
27
  AG_UI_METADATA_NAMESPACE,
27
28
  A2UI_SURFACE_ACTIVITY_TYPE,
@@ -30,15 +31,12 @@ import {
30
31
  type AgUiEvent,
31
32
  type AgUiInterrupt,
32
33
  type AgUiOpaqueReasoning,
34
+ MAX_SUBAGENT_DEPTH,
33
35
  } from "../types";
34
36
  import type { Logger } from "../logger";
35
37
 
36
38
  const ROOT_SCOPE = "";
37
39
 
38
- // Defensive guard against a malformed or cyclic parentToolCallId /
39
- // parentSubagentRunId chain, not a realistic nesting depth.
40
- const MAX_SUBAGENT_DEPTH = 16;
41
-
42
40
  type SubagentRunState = {
43
41
  subagentRunId: string;
44
42
  name: string;
@@ -93,6 +91,7 @@ type ToolCallState = {
93
91
  mcpAppServerId?: string;
94
92
  artifact?: unknown;
95
93
  modelContent?: ToolModelContentPart[];
94
+ resultMetadata?: Record<string, unknown>;
96
95
  snapshotResultApplied: boolean;
97
96
  subagentRunId?: string;
98
97
  };
@@ -209,6 +208,7 @@ export class RunAggregator {
209
208
  private interrupts: AgUiInterrupt[] | undefined;
210
209
  private pendingToolCallIds: string[] | undefined;
211
210
  private readonly textParts = new Map<string, { buffer: string }>();
211
+ private readonly pendingSnapshotText = new Map<string, string>();
212
212
  private readonly activeTextMessageIdByScope = new Map<string, string>();
213
213
  private readonly reasoningParts = new Map<
214
214
  string,
@@ -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": {
@@ -282,6 +293,20 @@ export class RunAggregator {
282
293
  this.emit();
283
294
  break;
284
295
  }
296
+ case "MESSAGES_SNAPSHOT": {
297
+ this.pendingSnapshotText.clear();
298
+ for (const message of event.messages) {
299
+ if (
300
+ message.role !== "assistant" ||
301
+ typeof message.content !== "string"
302
+ )
303
+ continue;
304
+ const part = this.textParts.get(message.id);
305
+ if (part) part.buffer = message.content;
306
+ else this.pendingSnapshotText.set(message.id, message.content);
307
+ }
308
+ break;
309
+ }
285
310
  case "RUN_FINISHED": {
286
311
  if (event.outcome?.type === "cancelled") {
287
312
  this.interrupts = undefined;
@@ -368,7 +393,7 @@ export class RunAggregator {
368
393
  const incomingId = event.messageId;
369
394
  const scope = this.scopeOf(event);
370
395
  if (scope === ROOT_SCOPE) {
371
- this.beginDistinctTextMessage(incomingId);
396
+ this.beginDistinctTextMessage(incomingId, true);
372
397
  this.reportServerMessageId(incomingId);
373
398
  if (incomingId) this.lastTextMessageId = incomingId;
374
399
  }
@@ -494,6 +519,7 @@ export class RunAggregator {
494
519
  : undefined,
495
520
  event.messageId,
496
521
  event.mcpResult,
522
+ event.metadata,
497
523
  );
498
524
  this.emit();
499
525
  break;
@@ -793,6 +819,7 @@ export class RunAggregator {
793
819
 
794
820
  private resetMessageParts(): void {
795
821
  this.clearTextParts();
822
+ this.pendingSnapshotText.clear();
796
823
  this.reasoningParts.clear();
797
824
  this.reasoningSignatures.clear();
798
825
  this.reasoningSignatureIds.clear();
@@ -837,7 +864,10 @@ export class RunAggregator {
837
864
  return scope === ROOT_SCOPE ? id : `${scope}\u0000${id}`;
838
865
  }
839
866
 
840
- private beginDistinctTextMessage(messageId: string | undefined): void {
867
+ private beginDistinctTextMessage(
868
+ messageId: string | undefined,
869
+ preserveSnapshotText = false,
870
+ ): void {
841
871
  if (
842
872
  this.joinStrategy === "concat-content" ||
843
873
  !messageId ||
@@ -854,7 +884,13 @@ export class RunAggregator {
854
884
  const openToolCallIds = Array.from(this.toolCalls.values())
855
885
  .filter((call) => call.result === undefined)
856
886
  .map((call) => call.toolCallId);
887
+ const snapshotText = preserveSnapshotText
888
+ ? this.pendingSnapshotText.get(messageId)
889
+ : undefined;
857
890
  this.resetMessageParts();
891
+ if (snapshotText !== undefined) {
892
+ this.pendingSnapshotText.set(messageId, snapshotText);
893
+ }
858
894
  this.onTextMessageStart(messageId, openToolCallIds);
859
895
  this.restoreReasoning(carried);
860
896
  }
@@ -946,15 +982,21 @@ export class RunAggregator {
946
982
  private startTextMessage(scope: string, messageId?: string): string {
947
983
  const id = messageId ?? this.generateTextKey();
948
984
  const key = this.ensureTextPart(scope, id);
985
+ if (scope === ROOT_SCOPE && messageId) {
986
+ const snapshotText = this.pendingSnapshotText.get(messageId);
987
+ const part = this.textParts.get(key);
988
+ if (snapshotText !== undefined && part) {
989
+ part.buffer = snapshotText;
990
+ this.pendingSnapshotText.delete(messageId);
991
+ }
992
+ }
949
993
  this.activeTextMessageIdByScope.set(scope, key);
950
994
  return key;
951
995
  }
952
996
 
953
997
  private resolveTextMessageId(scope: string, messageId?: string): string {
954
998
  if (messageId) {
955
- const key = this.ensureTextPart(scope, messageId);
956
- this.activeTextMessageIdByScope.set(scope, key);
957
- return key;
999
+ return this.startTextMessage(scope, messageId);
958
1000
  }
959
1001
 
960
1002
  const active = this.activeTextMessageIdByScope.get(scope);
@@ -1098,6 +1140,7 @@ export class RunAggregator {
1098
1140
  isError?: boolean,
1099
1141
  toolMessageId?: string,
1100
1142
  mcpResult?: Record<string, unknown>,
1143
+ metadata?: Record<string, unknown>,
1101
1144
  ) {
1102
1145
  if (!id) return;
1103
1146
  const modelText = toolContentText(content);
@@ -1149,6 +1192,9 @@ export class RunAggregator {
1149
1192
  if (toolMessageId) {
1150
1193
  entry.toolMessageId = toolMessageId;
1151
1194
  }
1195
+ if (metadata !== undefined) {
1196
+ entry.resultMetadata = { ...entry.resultMetadata, ...metadata };
1197
+ }
1152
1198
  this.lastResolvedToolCallIdByScope.set(scope, id);
1153
1199
  }
1154
1200
 
@@ -1299,6 +1345,14 @@ export class RunAggregator {
1299
1345
  ...(entry.toolMessageId
1300
1346
  ? { unstable_toolMessageId: entry.toolMessageId }
1301
1347
  : {}),
1348
+ ...(entry.resultMetadata !== undefined
1349
+ ? {
1350
+ providerMetadata: withAgUiPartMessageMetadata(
1351
+ undefined,
1352
+ entry.resultMetadata,
1353
+ ),
1354
+ }
1355
+ : {}),
1302
1356
  ...(nestedMessages.length > 0 ? { messages: nestedMessages } : {}),
1303
1357
  } as ToolCallMessagePart & { unstable_toolMessageId?: string };
1304
1358
  snapshot.push(toolPart);
@@ -4,7 +4,7 @@ import { createAgUiSubscriber } from "./subscriber";
4
4
  const createSubscriber = () => {
5
5
  const dispatch = vi.fn();
6
6
  const logger = { debug: vi.fn(), error: vi.fn() };
7
- const subscriber = createAgUiSubscriber({ dispatch, runId: "run-1", logger });
7
+ const subscriber = createAgUiSubscriber({ dispatch, logger });
8
8
  return { subscriber, dispatch, logger };
9
9
  };
10
10
 
@@ -60,7 +60,6 @@ const ensureEvent = (
60
60
 
61
61
  type SubscriberOptions = {
62
62
  dispatch: Dispatch;
63
- runId: string;
64
63
  logger?: Logger;
65
64
  onRunFailed?: (error: Error) => void;
66
65
  };
@@ -68,7 +67,7 @@ type SubscriberOptions = {
68
67
  export const createAgUiSubscriber = (
69
68
  options: SubscriberOptions,
70
69
  ): Subscriber => {
71
- const { dispatch, runId, logger, onRunFailed } = options;
70
+ const { dispatch, logger, onRunFailed } = options;
72
71
  let runFinishedDispatched = false;
73
72
  const dispatchIfValid = (raw: unknown, type: AgUiEvent["type"]) => {
74
73
  const event = ensureEvent(raw, type, logger);
@@ -140,10 +139,6 @@ export const createAgUiSubscriber = (
140
139
  logger?.debug?.("[agui] step boundary ignored", event),
141
140
  onStepFinishedEvent: ({ event }) =>
142
141
  logger?.debug?.("[agui] step boundary ignored", event),
143
- onRunFinalized: () => {
144
- if (runFinishedDispatched) return;
145
- dispatch({ type: "RUN_FINISHED", runId });
146
- },
147
142
  onRunFailed: ({ error }) => {
148
143
  if (runFinishedDispatched) return;
149
144
  runFinishedDispatched = true;
@@ -344,6 +344,9 @@ export const parseAgUiEvent = (
344
344
  messageId: getString("messageId"),
345
345
  role: payload.role === "tool" ? "tool" : undefined,
346
346
  mcpResult: parseMcpToolCallResult(payload, content),
347
+ metadata: isPlainObject(payload.metadata)
348
+ ? payload.metadata
349
+ : undefined,
347
350
  subagentRunId: getString("subagentRunId"),
348
351
  },
349
352
  );
@@ -26,6 +26,10 @@ export const AG_UI_METADATA_NAMESPACE = "agui";
26
26
  export const MCP_APPS_ACTIVITY_TYPE = "mcp-apps";
27
27
  export const A2UI_SURFACE_ACTIVITY_TYPE = "a2ui-surface";
28
28
 
29
+ // Defensive guard against a malformed or cyclic parentToolCallId /
30
+ // parentSubagentRunId chain, not a realistic nesting depth.
31
+ export const MAX_SUBAGENT_DEPTH = 16;
32
+
29
33
  export type AgUiOpaqueReasoning = {
30
34
  id: string;
31
35
  encryptedValue: string;
@@ -278,6 +282,7 @@ export type AgUiEvent =
278
282
  content: string | ContentPart[];
279
283
  role?: "tool";
280
284
  mcpResult?: McpToolCallResult;
285
+ metadata?: Record<string, unknown>;
281
286
  subagentRunId?: string;
282
287
  }
283
288
  | {
@@ -0,0 +1,384 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, cleanup, render, waitFor } from "@testing-library/react";
4
+ import { HttpAgent, type RunAgentInput } from "@ag-ui/client";
5
+ import {
6
+ AssistantRuntimeProvider,
7
+ useCloudThreadListAdapter,
8
+ useRemoteThreadListRuntime,
9
+ } from "@assistant-ui/core/react";
10
+ import type { AssistantRuntime } from "@assistant-ui/core";
11
+ import { useAui, type AssistantClient } from "@assistant-ui/store";
12
+ import { AssistantCloud } from "assistant-cloud";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { useAgUiRuntime } from "./useAgUiRuntime";
15
+
16
+ // The real AssistantCloud client runs against this in-memory server.
17
+ type Row = {
18
+ id: string;
19
+ parent_id: string | null;
20
+ format: string;
21
+ content: unknown;
22
+ external_id: string | null;
23
+ run?: { status: "running" | "ended"; last_heartbeat_ms_ago: number };
24
+ };
25
+
26
+ type Call = { method: string; path: string; body: any };
27
+
28
+ const createServer = (
29
+ initialRows: Row[] = [],
30
+ heartbeatIntervalMs = 20_000,
31
+ ) => {
32
+ const rows = [...initialRows];
33
+ const calls: Call[] = [];
34
+ let created = 0;
35
+ let leases = 0;
36
+ const at = new Date(0).toISOString();
37
+ const json = (value: unknown, status = 200) =>
38
+ new Response(JSON.stringify(value), {
39
+ status,
40
+ headers: { "content-type": "application/json" },
41
+ });
42
+ const fetchMock = vi.fn(
43
+ async (input: RequestInfo | URL, init?: RequestInit) => {
44
+ const url = new URL(String(input));
45
+ const method = init?.method ?? "GET";
46
+ const path = url.pathname.replace(/^\/v1/, "");
47
+ const body = init?.body ? JSON.parse(String(init.body)) : undefined;
48
+ calls.push({ method, path, body });
49
+ if (method === "GET" && path === "/threads") {
50
+ return json({
51
+ threads: rows.length
52
+ ? [
53
+ {
54
+ id: "remote-1",
55
+ title: "t",
56
+ is_archived: false,
57
+ external_id: null,
58
+ metadata: null,
59
+ project_id: "p",
60
+ workspace_id: "w",
61
+ created_at: at,
62
+ updated_at: at,
63
+ last_message_at: at,
64
+ },
65
+ ]
66
+ : [],
67
+ });
68
+ }
69
+ if (method === "POST" && path === "/threads")
70
+ return json({ thread_id: "remote-1" });
71
+ if (method === "GET" && path === "/threads/remote-1/messages") {
72
+ return json({
73
+ messages: [...rows].reverse().map((row, height) => ({
74
+ ...row,
75
+ height,
76
+ created_at: at,
77
+ updated_at: at,
78
+ run: row.run ?? null,
79
+ })),
80
+ });
81
+ }
82
+ if (method === "POST" && path === "/threads/remote-1/messages") {
83
+ const id = `msg_${++created}`;
84
+ rows.push({
85
+ id,
86
+ parent_id: body.parent_id,
87
+ format: body.format,
88
+ content: body.content,
89
+ external_id: body.external_id ?? null,
90
+ ...(body.open_run
91
+ ? { run: { status: "running", last_heartbeat_ms_ago: 0 } }
92
+ : undefined),
93
+ });
94
+ return json({
95
+ message_id: id,
96
+ ...(body.open_run
97
+ ? {
98
+ lease_id: `lease_${++leases}`,
99
+ heartbeat_interval_ms: heartbeatIntervalMs,
100
+ }
101
+ : undefined),
102
+ });
103
+ }
104
+ const write = path.match(/^\/threads\/remote-1\/messages\/([^/]+)$/);
105
+ if (method === "PUT" && write) {
106
+ const row = rows.find((r) => r.id === write[1]);
107
+ if (row) row.content = body.content;
108
+ return json({});
109
+ }
110
+ if (/^\/threads\/remote-1\/runs\/[^/]+\/heartbeat$/.test(path))
111
+ return json({ heartbeat_interval_ms: heartbeatIntervalMs });
112
+ if (/^\/threads\/remote-1\/runs\/[^/]+\/end$/.test(path)) return json({});
113
+ return json({});
114
+ },
115
+ );
116
+ return { rows, calls, fetchMock };
117
+ };
118
+
119
+ const createsWith = (calls: Call[], openRun: boolean) =>
120
+ calls.filter(
121
+ (c) =>
122
+ c.method === "POST" &&
123
+ c.path === "/threads/remote-1/messages" &&
124
+ !!c.body.open_run === openRun,
125
+ );
126
+
127
+ type Subscriber = Record<
128
+ string,
129
+ ((payload: { event: any }) => void) | undefined
130
+ >;
131
+
132
+ // An AG-UI agent whose runs are scripted: it streams one text message, under an id the backend picks, and finishes when the test says so.
133
+ class ScriptedAgent extends HttpAgent {
134
+ runs: { finish: () => void; input: RunAgentInput }[] = [];
135
+ constructor() {
136
+ super({ url: "http://agent.test" });
137
+ }
138
+ override async runAgent(
139
+ parameters?: Partial<RunAgentInput>,
140
+ subscriber?: Subscriber,
141
+ ): Promise<any> {
142
+ const input = parameters as RunAgentInput;
143
+ const id = `agent-msg-${this.runs.length + 1}`;
144
+ const emit = (name: string, event: object) =>
145
+ subscriber?.[name]?.({ event });
146
+ emit("onRunStartedEvent", {
147
+ type: "RUN_STARTED",
148
+ threadId: input.threadId,
149
+ runId: input.runId,
150
+ });
151
+ emit("onTextMessageStartEvent", {
152
+ type: "TEXT_MESSAGE_START",
153
+ messageId: id,
154
+ role: "assistant",
155
+ });
156
+ emit("onTextMessageContentEvent", {
157
+ type: "TEXT_MESSAGE_CONTENT",
158
+ messageId: id,
159
+ delta: "partial",
160
+ });
161
+ await new Promise<void>((resolve) =>
162
+ this.runs.push({ finish: resolve, input }),
163
+ );
164
+ emit("onTextMessageContentEvent", {
165
+ type: "TEXT_MESSAGE_CONTENT",
166
+ messageId: id,
167
+ delta: " answer",
168
+ });
169
+ emit("onTextMessageEndEvent", { type: "TEXT_MESSAGE_END", messageId: id });
170
+ emit("onRunFinishedEvent", {
171
+ type: "RUN_FINISHED",
172
+ threadId: input.threadId,
173
+ runId: input.runId,
174
+ });
175
+ return { result: undefined, newMessages: [] };
176
+ }
177
+ override abortRun() {}
178
+ }
179
+
180
+ const renderApp = async (staleAfter?: number) => {
181
+ const cloud = new AssistantCloud({
182
+ baseUrl: "http://cloud.test",
183
+ apiKey: "key",
184
+ userId: "user",
185
+ workspaceId: "workspace",
186
+ telemetry: false,
187
+ });
188
+ const agent = new ScriptedAgent();
189
+ let aui!: AssistantClient;
190
+ let runtime!: AssistantRuntime;
191
+ const Capture = () => {
192
+ aui = useAui();
193
+ return null;
194
+ };
195
+ const App = () => {
196
+ const adapter = useCloudThreadListAdapter({
197
+ cloud,
198
+ ...(staleAfter !== undefined ? { staleAfter } : undefined),
199
+ });
200
+ runtime = useRemoteThreadListRuntime({
201
+ adapter,
202
+ runtimeHook: function RuntimeHook() {
203
+ return useAgUiRuntime({ agent });
204
+ },
205
+ });
206
+ return (
207
+ <AssistantRuntimeProvider runtime={runtime}>
208
+ <Capture />
209
+ </AssistantRuntimeProvider>
210
+ );
211
+ };
212
+ render(<App />);
213
+ await act(async () => {
214
+ await aui.threads.getLoadThreadsPromise();
215
+ });
216
+ return { agent, getAui: () => aui, getRuntime: () => runtime };
217
+ };
218
+
219
+ const assistantRow = (
220
+ last_heartbeat_ms_ago: number,
221
+ status: "running" | "ended" = "running",
222
+ ): Row => ({
223
+ id: "a1",
224
+ parent_id: "u1",
225
+ format: "aui/v0",
226
+ content: {
227
+ role: "assistant",
228
+ content: [{ type: "text", text: "half an answer" }],
229
+ status: { type: "incomplete", reason: "cancelled" },
230
+ },
231
+ external_id: "a1",
232
+ run: { status, last_heartbeat_ms_ago },
233
+ });
234
+ const userRow: Row = {
235
+ id: "u1",
236
+ parent_id: null,
237
+ format: "aui/v0",
238
+ content: { role: "user", content: [{ type: "text", text: "hello" }] },
239
+ external_id: "u1",
240
+ };
241
+
242
+ let server: ReturnType<typeof createServer>;
243
+ const useServer = (rows?: Row[], heartbeatIntervalMs?: number) => {
244
+ server = createServer(rows, heartbeatIntervalMs);
245
+ vi.stubGlobal("fetch", server.fetchMock);
246
+ };
247
+
248
+ beforeEach(() => {
249
+ vi.spyOn(console, "warn").mockImplementation(() => {});
250
+ });
251
+ afterEach(() => {
252
+ cleanup();
253
+ vi.unstubAllGlobals();
254
+ vi.restoreAllMocks();
255
+ });
256
+
257
+ describe("useAgUiRuntime with Assistant Cloud run leases", () => {
258
+ it("opens a lease when the run starts, heartbeats on the server's interval, writes as its holder and ends it when the run finishes", async () => {
259
+ useServer([], 30);
260
+ const { agent, getAui } = await renderApp();
261
+
262
+ await act(async () => {
263
+ getAui().thread.append("hello");
264
+ });
265
+ await waitFor(() => expect(agent.runs).toHaveLength(1));
266
+ await waitFor(() =>
267
+ expect(createsWith(server.calls, true)).toHaveLength(1),
268
+ );
269
+ const open = createsWith(server.calls, true)[0]!;
270
+ expect(open.body).toMatchObject({
271
+ format: "aui/v0",
272
+ parent_id: "msg_1",
273
+ content: { role: "assistant" },
274
+ });
275
+
276
+ await waitFor(() =>
277
+ expect(
278
+ server.calls.filter((c) => c.path.endsWith("/runs/lease_1/heartbeat"))
279
+ .length,
280
+ ).toBeGreaterThanOrEqual(2),
281
+ );
282
+
283
+ await act(async () => {
284
+ agent.runs[0]!.finish();
285
+ });
286
+ await waitFor(() =>
287
+ expect(
288
+ server.calls.some(
289
+ (c) => c.method === "POST" && c.path.endsWith("/runs/lease_1/end"),
290
+ ),
291
+ ).toBe(true),
292
+ );
293
+ const end = server.calls.find((c) => c.path.endsWith("/runs/lease_1/end"))!;
294
+ expect(end.body).toEqual({ reason: "done" });
295
+ const final = server.calls.filter(
296
+ (c) => c.method === "PUT" && c.body.lease_id === "lease_1",
297
+ );
298
+ expect(final.length).toBeGreaterThan(0);
299
+ expect(JSON.stringify(final.at(-1)!.body.content)).toContain(
300
+ "partial answer",
301
+ );
302
+ expect(createsWith(server.calls, true)).toHaveLength(1);
303
+ const reply = getAui().thread.getState().messages[1]!;
304
+ expect(reply.id).toBe("agent-msg-1");
305
+ });
306
+
307
+ it("shows a reloaded run quiet for longer than staleAfter as interrupted; Resume posts a sibling with its own lease and runs the agent again", async () => {
308
+ useServer([userRow, assistantRow(45_000)]);
309
+ const { agent, getAui, getRuntime } = await renderApp();
310
+ await act(async () => {
311
+ await getRuntime().threads.switchToThread("remote-1");
312
+ });
313
+ await waitFor(() =>
314
+ expect(getAui().thread.getState().messages).toHaveLength(2),
315
+ );
316
+ const stale = getAui().thread.message({ index: 1 });
317
+ await waitFor(() => expect(stale.getState().runState).toBe("stale"));
318
+
319
+ await act(async () => {
320
+ stale.reload();
321
+ });
322
+ await waitFor(() => expect(agent.runs).toHaveLength(1));
323
+ await waitFor(() =>
324
+ expect(createsWith(server.calls, true)).toHaveLength(1),
325
+ );
326
+ expect(createsWith(server.calls, true)[0]!.body).toMatchObject({
327
+ parent_id: "u1",
328
+ open_run: true,
329
+ });
330
+ await act(async () => {
331
+ agent.runs[0]!.finish();
332
+ });
333
+ await waitFor(() =>
334
+ expect(
335
+ server.calls.some((c) => c.path.endsWith("/runs/lease_1/end")),
336
+ ).toBe(true),
337
+ );
338
+ expect(
339
+ server.calls.some((c) => c.method === "PUT" && c.path.endsWith("/a1")),
340
+ ).toBe(false);
341
+ const messages = getAui().thread.getState().messages;
342
+ expect(messages.map((m) => m.role)).toEqual(["user", "assistant"]);
343
+ expect(messages[1]!.id).not.toBe("a1");
344
+ });
345
+
346
+ it("shows a reloaded run with a recent beat as in progress, and staleAfter moves the line", async () => {
347
+ useServer([userRow, assistantRow(5_000)]);
348
+ const { getAui, getRuntime } = await renderApp();
349
+ await act(async () => {
350
+ await getRuntime().threads.switchToThread("remote-1");
351
+ });
352
+ await waitFor(() =>
353
+ expect(getAui().thread.message({ index: 1 }).getState().runState).toBe(
354
+ "in-progress",
355
+ ),
356
+ );
357
+ cleanup();
358
+
359
+ useServer([userRow, assistantRow(5_000)]);
360
+ const short = await renderApp(2_000);
361
+ await act(async () => {
362
+ await short.getRuntime().threads.switchToThread("remote-1");
363
+ });
364
+ await waitFor(() =>
365
+ expect(
366
+ short.getAui().thread.message({ index: 1 }).getState().runState,
367
+ ).toBe("stale"),
368
+ );
369
+ });
370
+
371
+ it("loads an ended run as a plain message", async () => {
372
+ useServer([userRow, assistantRow(90_000, "ended")]);
373
+ const { getAui, getRuntime } = await renderApp();
374
+ await act(async () => {
375
+ await getRuntime().threads.switchToThread("remote-1");
376
+ });
377
+ await waitFor(() =>
378
+ expect(getAui().thread.getState().messages).toHaveLength(2),
379
+ );
380
+ expect(
381
+ getAui().thread.message({ index: 1 }).getState().runState,
382
+ ).toBeUndefined();
383
+ });
384
+ });