@artooi/ag-ui-web-component 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +78 -1
  2. package/README.md +144 -7
  3. package/dist/ag-ui-web-component.bundle.js +237 -40
  4. package/dist/ag-ui-web-component.bundle.js.map +4 -4
  5. package/dist/core/ag_ui_chat.d.ts +29 -0
  6. package/dist/core/ag_ui_chat.d.ts.map +1 -1
  7. package/dist/core/agui_client.d.ts +28 -1
  8. package/dist/core/agui_client.d.ts.map +1 -1
  9. package/dist/core/run_index.d.ts +50 -0
  10. package/dist/core/run_index.d.ts.map +1 -0
  11. package/dist/index.d.ts +5 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +942 -101
  14. package/dist/index.js.map +4 -4
  15. package/dist/ui/approval_card.d.ts +51 -0
  16. package/dist/ui/approval_card.d.ts.map +1 -0
  17. package/dist/ui/attachment_chips.d.ts.map +1 -1
  18. package/dist/ui/attachment_tray.d.ts.map +1 -1
  19. package/dist/ui/checkpoint_menu.d.ts +32 -0
  20. package/dist/ui/checkpoint_menu.d.ts.map +1 -0
  21. package/dist/ui/question_card.d.ts +52 -0
  22. package/dist/ui/question_card.d.ts.map +1 -0
  23. package/dist/ui/skills_menu.d.ts.map +1 -1
  24. package/dist/ui/styles.d.ts +1 -1
  25. package/dist/ui/styles.d.ts.map +1 -1
  26. package/dist/ui/thoughts_block.d.ts.map +1 -1
  27. package/dist/ui/thread_drawer.d.ts.map +1 -1
  28. package/dist/ui/ui_strings.d.ts +26 -0
  29. package/dist/ui/ui_strings.d.ts.map +1 -1
  30. package/package.json +4 -4
  31. package/src/core/ag_ui_chat.ts +263 -4
  32. package/src/core/agui_client.ts +86 -11
  33. package/src/core/run_index.ts +91 -0
  34. package/src/index.ts +16 -0
  35. package/src/ui/approval_card.ts +119 -0
  36. package/src/ui/attachment_chips.ts +5 -0
  37. package/src/ui/attachment_tray.ts +8 -0
  38. package/src/ui/checkpoint_menu.ts +153 -0
  39. package/src/ui/question_card.ts +216 -0
  40. package/src/ui/skills_menu.ts +6 -0
  41. package/src/ui/styles.ts +197 -0
  42. package/src/ui/thoughts_block.ts +1 -0
  43. package/src/ui/thread_drawer.ts +11 -0
  44. package/src/ui/ui_strings.ts +45 -0
  45. package/src/version.ts +1 -1
@@ -1,4 +1,4 @@
1
- import type { Context, Message, Tool } from "@ag-ui/core";
1
+ import type { Context, Interrupt, Message, Tool } from "@ag-ui/core";
2
2
  import {
3
3
  DEFAULT_ATTACHMENT_MAX_BYTES,
4
4
  MESSAGE_ROLE,
@@ -20,10 +20,21 @@ import { createPageMapContext, type PageMap } from "../tools/page_map.js";
20
20
  import { parseToolCatalog } from "../tools/parse_tool_catalog.js";
21
21
  import { createRouteTools, type RouteMap } from "../tools/route_map.js";
22
22
  import { createStateHookTools, type StateHook } from "../tools/state_hook.js";
23
+ import {
24
+ type ApprovalRenderer,
25
+ type ApprovalRequest,
26
+ requestApproval,
27
+ } from "../ui/approval_card.js";
23
28
  import { renderAttachmentChips } from "../ui/attachment_chips.js";
24
29
  import { AttachmentTray } from "../ui/attachment_tray.js";
30
+ import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
25
31
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
26
32
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
33
+ import {
34
+ type QuestionRenderer,
35
+ type QuestionRequest,
36
+ requestQuestion,
37
+ } from "../ui/question_card.js";
27
38
  import { renderMarkdown } from "../ui/render_markdown.js";
28
39
  import { wrapWords } from "../ui/reveal_words.js";
29
40
  import { SkillsMenu } from "../ui/skills_menu.js";
@@ -37,6 +48,7 @@ import {
37
48
  AgUiClient,
38
49
  type AgUiClientHandlers,
39
50
  type AgUiToolCall,
51
+ type InterruptResponse,
40
52
  type ToolExecution,
41
53
  } from "./agui_client.js";
42
54
  import { type AttachmentRef, messageAttachments } from "./attachment.js";
@@ -47,6 +59,7 @@ import {
47
59
  } from "./conversation_store.js";
48
60
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
49
61
  import { RemoteConversationStore } from "./remote_conversation_store.js";
62
+ import { RunIndex } from "./run_index.js";
50
63
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
51
64
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
52
65
 
@@ -101,6 +114,36 @@ export class AgUiChat extends HTMLElement {
101
114
  /** When true, destructive tools execute without a confirmation modal. */
102
115
  autoConfirm = false;
103
116
 
117
+ /**
118
+ * When true, the built-in `ask_user` frontend tool is offered to the agent:
119
+ * calling it renders an inline question card (radio choices and/or a free-text
120
+ * field) and returns the user's answer. Off by default — like the other
121
+ * built-in tool groups (route / page-action), it is opt-in so it doesn't
122
+ * change the advertised catalog until a host asks for it.
123
+ */
124
+ askUser = false;
125
+
126
+ /**
127
+ * Optional full replacement for the `ask_user` question UI. When set, calling
128
+ * `ask_user` invokes this instead of the built-in inline card: the host
129
+ * renders whatever it likes (a native modal, a framework component, …) and
130
+ * resolves with the answer. Unset (default) uses the built-in
131
+ * {@link requestQuestion} card — style that via the `strings` override and the
132
+ * `question*` CSS `::part()`s. Requires {@link askUser} to be enabled.
133
+ */
134
+ askUserRenderer: QuestionRenderer | null = null;
135
+
136
+ /**
137
+ * Optional full replacement for the server-side-tool approval UI. When set, an
138
+ * approval interrupt invokes this instead of the built-in inline approval
139
+ * card: the host renders whatever it likes and resolves `true` to approve /
140
+ * `false` to deny. Unset (default) uses the built-in {@link requestApproval}
141
+ * card — style that via the `strings` override and the `approval*` CSS
142
+ * `::part()`s. The gate itself is enabled server-side; this only changes how
143
+ * the decision is collected.
144
+ */
145
+ approvalRenderer: ApprovalRenderer | null = null;
146
+
104
147
  /**
105
148
  * Optional per-call confirmation predicate. When set, it is authoritative:
106
149
  * given a tool name + args it decides whether *this* call needs confirmation
@@ -254,6 +297,10 @@ export class AgUiChat extends HTMLElement {
254
297
  readonly #title: HTMLSpanElement;
255
298
  readonly #skillsMenu: SkillsMenu;
256
299
  readonly #drawer: ThreadDrawer;
300
+ /** Checkpoint panel; rows load only when `data-runs-url` is set. */
301
+ readonly #checkpoints: CheckpointMenu;
302
+ /** Built lazily from `data-runs-url`; `null` when the host didn't opt in. */
303
+ #runIndex: RunIndex | null = null;
257
304
  readonly #skillHint: HTMLDivElement;
258
305
  /** File-picker button + hidden input + tray slot; the tray mounts on connect. */
259
306
  readonly #attachButton: HTMLButtonElement;
@@ -346,6 +393,73 @@ export class AgUiChat extends HTMLElement {
346
393
  this.#deleteThread(threadId);
347
394
  },
348
395
  });
396
+ this.#checkpoints = new CheckpointMenu((runId, verb) => {
397
+ void this.#continueRun(runId, verb);
398
+ });
399
+ }
400
+
401
+ /** The run index, built once from `data-runs-url`; `null` when unset. */
402
+ #runs(): RunIndex | null {
403
+ const url = this.getAttribute("data-runs-url");
404
+ if (url === null || url === "") {
405
+ return null;
406
+ }
407
+ if (this.#runIndex === null) {
408
+ this.#runIndex = new RunIndex(url, () => this.headers);
409
+ }
410
+ return this.#runIndex;
411
+ }
412
+
413
+ /**
414
+ * Continue `runId` as a **new** run, seeded server-side from its snapshot.
415
+ *
416
+ * Uses a short-lived agent pointed at the resume / fork endpoint and seeded
417
+ * with **no** history, so the request carries only the turn typed here — the
418
+ * contract those endpoints assume, since the server supplies the prior turns
419
+ * from the snapshot and re-sending them would duplicate. Building a separate
420
+ * agent makes that structural: the main agent keeps its own history, and
421
+ * "only the new turn" can't be got wrong by forgetting to clear it. The
422
+ * fresh `run_id` the endpoints also require comes free — a new agent mints
423
+ * one per run.
424
+ *
425
+ * Handlers are the element's own, so the continuation streams into the same
426
+ * transcript the user is looking at.
427
+ */
428
+ async #continueRun(runId: string, verb: CheckpointVerb): Promise<void> {
429
+ const index = this.#runs();
430
+ if (index === null) {
431
+ return;
432
+ }
433
+ const content = this.#input.value.trim();
434
+ if (content === "") {
435
+ return;
436
+ }
437
+ this.#input.value = "";
438
+ const endpoint = verb === "resume" ? index.resumeUrl(runId) : index.forkUrl(runId);
439
+ const agent = this.agentFactory({
440
+ endpoint,
441
+ headers: this.headers,
442
+ getHeaders: () => this.headers,
443
+ threadId: this.#threadId,
444
+ // The seed the endpoints assume: nothing. The snapshot is the history.
445
+ initialMessages: [],
446
+ });
447
+ const client = new AgUiClient({
448
+ agent,
449
+ handlers: this.#handlers(),
450
+ getTools: () => this.getTools(),
451
+ getContext: () => this.getContext(),
452
+ executeTool: (call) => this.#executeTool(call),
453
+ resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
454
+ connectionLostMessage: this.#strings.connectionLost,
455
+ });
456
+ await client.send(content);
457
+ }
458
+
459
+ /** Load the checkpoint panel with the runs that can actually be continued. */
460
+ async #refreshCheckpoints(): Promise<void> {
461
+ const index = this.#runs();
462
+ this.#checkpoints.setRuns(index === null ? [] : await index.continuable());
349
463
  }
350
464
 
351
465
  /** Attributes whose late changes must reflect in already-rendered chrome. */
@@ -430,9 +544,84 @@ export class AgUiChat extends HTMLElement {
430
544
  return createPageActionTools(enabled, (target) => this.resolvePageTarget(target));
431
545
  }
432
546
 
433
- /** All built-in (route + page + page-action) frontend tools. */
547
+ /** All built-in (route + page + page-action + ask_user) frontend tools. */
434
548
  #builtinTools(): ClientTool[] {
435
- return [...this.#routeTools(), ...this.#pageTools(), ...this.#pageActionTools()];
549
+ return [
550
+ ...this.#routeTools(),
551
+ ...this.#pageTools(),
552
+ ...this.#pageActionTools(),
553
+ ...this.#askUserTool(),
554
+ ];
555
+ }
556
+
557
+ /**
558
+ * The built-in `ask_user` frontend tool, or `[]` when {@link askUser} is off.
559
+ *
560
+ * A generic "ask the user a typed question" primitive: the agent calls it, the
561
+ * client executes it locally via the normal frontend-tool path (rendering a
562
+ * {@link requestQuestion} card), and the chosen/typed answer flows back as the
563
+ * tool result — no new protocol, reusing the machinery already in place.
564
+ */
565
+ #askUserTool(): ClientTool[] {
566
+ if (!this.askUser) {
567
+ return [];
568
+ }
569
+ return [
570
+ {
571
+ name: "ask_user",
572
+ description:
573
+ "Ask the user a question and wait for their answer. Provide `options` for a " +
574
+ "multiple-choice prompt; set `allow_custom` to also accept a free-text answer.",
575
+ parameters: {
576
+ type: "object",
577
+ properties: {
578
+ question: { type: "string", description: "The question to ask the user." },
579
+ options: {
580
+ type: "array",
581
+ items: { type: "string" },
582
+ description: "Preset choices offered as radio buttons.",
583
+ },
584
+ allow_custom: {
585
+ type: "boolean",
586
+ description: "Allow a free-text answer in addition to any options.",
587
+ },
588
+ },
589
+ required: ["question"],
590
+ },
591
+ handler: (args) => this.#askUser(args),
592
+ },
593
+ ];
594
+ }
595
+
596
+ /** Render the `ask_user` question card and resolve with the user's answer. */
597
+ async #askUser(args: Record<string, unknown>): Promise<string> {
598
+ const question = typeof args["question"] === "string" ? args["question"] : "";
599
+ const request: QuestionRequest = { question };
600
+ const rawOptions = args["options"];
601
+ if (Array.isArray(rawOptions)) {
602
+ request.options = rawOptions.filter((option): option is string => typeof option === "string");
603
+ }
604
+ if (args["allow_custom"] === true) {
605
+ request.allowCustom = true;
606
+ }
607
+ // The run is suspended on the card; a Stop aborts the controller, resolving
608
+ // it with an empty answer (the run is then cancelled).
609
+ this.#confirmAbort = new AbortController();
610
+ const signal = this.#confirmAbort.signal;
611
+ this.#hidePending();
612
+ // A host-supplied renderer takes full control of the UI; otherwise the
613
+ // built-in inline card renders into the current answer group.
614
+ const answer =
615
+ this.askUserRenderer !== null
616
+ ? await this.askUserRenderer(request, { signal })
617
+ : await requestQuestion(this.#ensureGroup(), request, {
618
+ signal,
619
+ strings: this.#strings,
620
+ });
621
+ this.#confirmAbort = null;
622
+ this.#updateEmptyState();
623
+ this.#messages.scrollTop = this.#messages.scrollHeight;
624
+ return answer;
436
625
  }
437
626
 
438
627
  /** Resolve a tool by name: built-in tools first, then the registry. */
@@ -494,6 +683,7 @@ export class AgUiChat extends HTMLElement {
494
683
  }
495
684
  this.#render();
496
685
  this.#drawer.setStrings(this.#strings);
686
+ this.#checkpoints.setStrings(this.#strings);
497
687
  if (this.#readScopedItem(COLLAPSED_KEY) === "1") {
498
688
  this.setAttribute("collapsed", "");
499
689
  }
@@ -1113,13 +1303,28 @@ export class AgUiChat extends HTMLElement {
1113
1303
  this.#drawer.open();
1114
1304
  });
1115
1305
 
1306
+ const checkpoints = this.#headerButton("checkpoints", this.#strings.checkpoints, "⭯");
1307
+ checkpoints.addEventListener("click", () => {
1308
+ void this.#refreshCheckpoints();
1309
+ this.#checkpoints.open();
1310
+ });
1311
+
1116
1312
  const newChat = this.#headerButton("new", this.#strings.newChat, "✚");
1117
1313
  newChat.addEventListener("click", () => this.newChat());
1118
1314
 
1119
1315
  const collapse = this.#headerButton("collapse", this.#strings.collapse, "—");
1120
1316
  collapse.addEventListener("click", () => this.toggleCollapsed());
1121
1317
 
1122
- controls.append(history, newChat);
1318
+ // Only offered when the server actually indexes runs — without
1319
+ // `data-runs-url` there is nothing to continue and the button would open
1320
+ // a permanently empty panel. Asks `#runs()` rather than re-testing the
1321
+ // attribute, so "configured" means one thing everywhere (an empty value
1322
+ // is unset, not a relative URL to the current page).
1323
+ if (this.#runs() !== null) {
1324
+ controls.append(history, checkpoints, newChat);
1325
+ } else {
1326
+ controls.append(history, newChat);
1327
+ }
1123
1328
  // Optional built-in theme toggle: off unless the host opts in, so
1124
1329
  // it never competes with a host-supplied switch in `slot="header-actions"`.
1125
1330
  if (this.getAttribute("data-theme-toggle") !== null) {
@@ -1180,6 +1385,7 @@ export class AgUiChat extends HTMLElement {
1180
1385
  });
1181
1386
 
1182
1387
  this.#skillHint.className = "skill-hint";
1388
+ this.#skillHint.setAttribute("part", "skill-hint");
1183
1389
  this.#skillHint.hidden = true;
1184
1390
 
1185
1391
  // File-upload affordance: a 📎 button (hidden until `data-attachments-url`
@@ -1222,6 +1428,7 @@ export class AgUiChat extends HTMLElement {
1222
1428
  inputRow,
1223
1429
  footer,
1224
1430
  this.#drawer.element,
1431
+ this.#checkpoints.element,
1225
1432
  );
1226
1433
 
1227
1434
  // The collapsed-sidebar rail: a slim edge strip (the expand affordance),
@@ -1388,6 +1595,7 @@ export class AgUiChat extends HTMLElement {
1388
1595
  getTools: () => this.getTools(),
1389
1596
  getContext: () => this.getContext(),
1390
1597
  executeTool: (call) => this.#executeTool(call),
1598
+ resolveInterrupts: (interrupts) => this.#resolveInterrupts(interrupts),
1391
1599
  onPersist: (messages) => this.conversationStore.saveMessages(this.#threadId, messages),
1392
1600
  connectionLostMessage: this.#strings.connectionLost,
1393
1601
  });
@@ -1478,6 +1686,57 @@ export class AgUiChat extends HTMLElement {
1478
1686
  }
1479
1687
  }
1480
1688
 
1689
+ /**
1690
+ * Render an approval card per server-side-tool interrupt and collect the
1691
+ * user's decisions (approve → run it, deny → decline it).
1692
+ *
1693
+ * The run is suspended on these cards; a Stop while any is open aborts the
1694
+ * shared {@link #confirmAbort} controller, resolving every still-open card as
1695
+ * denied (and the client loop then sees the cancellation and stops). An
1696
+ * approved tool runs on the follow-up (resume) run and streams its result
1697
+ * back into the same pending card; a denied one is settled here, since no
1698
+ * result will ever arrive for it.
1699
+ */
1700
+ async #resolveInterrupts(
1701
+ interrupts: readonly Interrupt[],
1702
+ ): Promise<Record<string, InterruptResponse>> {
1703
+ const responses: Record<string, InterruptResponse> = {};
1704
+ // One controller covers the whole batch: a single Stop denies all of them.
1705
+ this.#confirmAbort = new AbortController();
1706
+ this.#hidePending();
1707
+ for (const interrupt of interrupts) {
1708
+ const request: ApprovalRequest = {};
1709
+ if (interrupt.message !== undefined) {
1710
+ request.message = interrupt.message;
1711
+ }
1712
+ const card =
1713
+ interrupt.toolCallId !== undefined ? this.#toolCards.get(interrupt.toolCallId) : undefined;
1714
+ const toolName = card?.element.getAttribute("data-tool-name");
1715
+ if (toolName !== null && toolName !== undefined) {
1716
+ request.toolName = toolName;
1717
+ }
1718
+ const signal = this.#confirmAbort.signal;
1719
+ // A host-supplied renderer takes full control of the approval UI;
1720
+ // otherwise the built-in inline card renders into the current answer group.
1721
+ const approved =
1722
+ this.approvalRenderer !== null
1723
+ ? await this.approvalRenderer(request, { signal })
1724
+ : await requestApproval(this.#ensureGroup(), request, { signal, strings: this.#strings });
1725
+ this.#updateEmptyState();
1726
+ this.#messages.scrollTop = this.#messages.scrollHeight;
1727
+ if (approved) {
1728
+ responses[interrupt.id] = { status: "resolved", payload: { approved: true } };
1729
+ } else {
1730
+ responses[interrupt.id] = { status: "cancelled" };
1731
+ // No TOOL_CALL_RESULT will stream for a denied tool — settle its pending
1732
+ // card now rather than leaving it hanging until the onSettled sweep.
1733
+ card?.settle(TOOL_CALL_STATUS.DECLINED, this.#strings.declinedAction);
1734
+ }
1735
+ }
1736
+ this.#confirmAbort = null;
1737
+ return responses;
1738
+ }
1739
+
1481
1740
  #handlers(): AgUiClientHandlers {
1482
1741
  return {
1483
1742
  onRunStart: () => {
@@ -1,5 +1,11 @@
1
- import { type AbstractAgent, type AgentSubscriber, randomUUID } from "@ag-ui/client";
2
- import type { Context, Message, Tool } from "@ag-ui/core";
1
+ import {
2
+ type AbstractAgent,
3
+ type AgentSubscriber,
4
+ buildResumeArray,
5
+ type RunAgentParameters,
6
+ randomUUID,
7
+ } from "@ag-ui/client";
8
+ import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
3
9
  import { MAX_TOOL_ROUNDS } from "../constants.js";
4
10
  import type { AttachmentRef } from "./attachment.js";
5
11
 
@@ -33,6 +39,27 @@ export interface ToolExecution {
33
39
  */
34
40
  export type ExecuteTool = (call: AgUiToolCall) => Promise<ToolExecution | null>;
35
41
 
42
+ /**
43
+ * One user decision for a server-side-tool approval interrupt. Structurally
44
+ * matches `@ag-ui/client`'s (non-exported) `ResumeResponse`, the payload
45
+ * {@link buildResumeArray} turns into a `ResumeEntry`: `resolved` approves (with
46
+ * an optional `payload`, e.g. `{ approved: true }`), `cancelled` denies.
47
+ */
48
+ export type InterruptResponse = { status: "resolved"; payload?: unknown } | { status: "cancelled" };
49
+
50
+ /**
51
+ * Resolves the approval interrupts a run finished on, keyed by interrupt id.
52
+ *
53
+ * When a gated **server-side** tool defers instead of executing, the run
54
+ * finishes on an AG-UI interrupt outcome; the host renders an approval card per
55
+ * interrupt and returns each decision here, and the loop resumes the run with
56
+ * the answers. Omit for agents that never gate server-side tools — an
57
+ * unresolved interrupt then simply ends the loop.
58
+ */
59
+ export type ResolveInterrupts = (
60
+ interrupts: readonly Interrupt[],
61
+ ) => Promise<Record<string, InterruptResponse>>;
62
+
36
63
  /**
37
64
  * Callbacks the {@link AgUiClient} invokes as a run progresses. The host
38
65
  * (the `<ag-ui-chat>` element) implements these to render streaming text and
@@ -93,6 +120,11 @@ export interface AgUiClientConfig extends AgUiRunInputs {
93
120
  handlers: AgUiClientHandlers;
94
121
  /** Executes frontend tool calls. Omit for server-only tool sets. */
95
122
  executeTool?: ExecuteTool;
123
+ /**
124
+ * Resolves server-side-tool approval interrupts. Omit when no server-side
125
+ * tool is gated for approval — an interrupt then ends the loop unanswered.
126
+ */
127
+ resolveInterrupts?: ResolveInterrupts;
96
128
  /**
97
129
  * Invoked with the latest history whenever it changes, so the host can
98
130
  * persist it for durability across page reloads. Omit to keep the
@@ -134,6 +166,7 @@ export class AgUiClient {
134
166
  readonly #getTools: () => Tool[];
135
167
  readonly #getContext: () => Context[];
136
168
  readonly #executeTool: ExecuteTool | null;
169
+ readonly #resolveInterrupts: ResolveInterrupts | null;
137
170
  readonly #onPersist: (messages: readonly Message[]) => void;
138
171
  readonly #connectionLostMessage: string;
139
172
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
@@ -146,6 +179,7 @@ export class AgUiClient {
146
179
  this.#getTools = config.getTools ?? (() => []);
147
180
  this.#getContext = config.getContext ?? (() => []);
148
181
  this.#executeTool = config.executeTool ?? null;
182
+ this.#resolveInterrupts = config.resolveInterrupts ?? null;
149
183
  this.#onPersist = config.onPersist ?? (() => {});
150
184
  this.#connectionLostMessage = config.connectionLostMessage ?? "Connection lost";
151
185
  }
@@ -241,6 +275,11 @@ export class AgUiClient {
241
275
  }
242
276
 
243
277
  async #runLoop(): Promise<void> {
278
+ // Carries the resolved approval answers into the *next* run when a round
279
+ // finished on a server-side-tool interrupt. Distinct from the public
280
+ // resume() navigation-reload path (which continues an unfinished
281
+ // frontend-tool round after a page load) — this stays inside one #run().
282
+ let resume: ResumeEntry[] | undefined;
244
283
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
245
284
  // A cancel during the previous round's frontend-tool execution lands
246
285
  // here: the running handler completed, but no further round starts.
@@ -248,11 +287,16 @@ export class AgUiClient {
248
287
  return;
249
288
  }
250
289
  const pending: AgUiToolCall[] = [];
251
- const runState = { terminal: false, errored: false };
252
- await this.#agent.runAgent(
253
- { tools: this.#getTools(), context: this.#getContext() },
254
- this.#buildSubscriber(pending, runState),
255
- );
290
+ const runState: RunState = { terminal: false, errored: false, interrupts: [] };
291
+ const params: RunAgentParameters = {
292
+ tools: this.#getTools(),
293
+ context: this.#getContext(),
294
+ };
295
+ if (resume !== undefined) {
296
+ params.resume = resume;
297
+ }
298
+ await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
299
+ resume = undefined;
256
300
  this.#onPersist(this.#agent.messages);
257
301
  // Cancelled mid-stream: the user said stop — don't execute the tool
258
302
  // calls collected before the abort.
@@ -272,6 +316,22 @@ export class AgUiClient {
272
316
  if (runState.errored) {
273
317
  return;
274
318
  }
319
+ // A gated server-side tool deferred instead of executing: the run finished
320
+ // on an interrupt outcome. Ask the host to resolve each interrupt, then
321
+ // re-enter the loop carrying the answers — the follow-up run runs or denies
322
+ // the tool (its result streams back as TOOL_CALL_RESULT). Takes precedence
323
+ // over the frontend-tool sweep below: a server-side tool isn't ours to run.
324
+ if (runState.interrupts.length > 0) {
325
+ if (this.#resolveInterrupts === null) {
326
+ return;
327
+ }
328
+ const responses = await this.#resolveInterrupts(runState.interrupts);
329
+ if (this.#cancelled) {
330
+ return;
331
+ }
332
+ resume = buildResumeArray(runState.interrupts, responses);
333
+ continue;
334
+ }
275
335
  if (this.#executeTool === null || pending.length === 0) {
276
336
  return;
277
337
  }
@@ -301,10 +361,7 @@ export class AgUiClient {
301
361
  }
302
362
  }
303
363
 
304
- #buildSubscriber(
305
- pending: AgUiToolCall[],
306
- runState: { terminal: boolean; errored: boolean },
307
- ): AgentSubscriber {
364
+ #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
308
365
  const h = this.#handlers;
309
366
  return {
310
367
  onRunInitialized() {
@@ -340,6 +397,16 @@ export class AgUiClient {
340
397
  onReasoningEndEvent() {
341
398
  h.onReasoningEnd();
342
399
  },
400
+ onRunFinishedEvent(params) {
401
+ // RUN_FINISHED is terminal for both a normal finish and an interrupt.
402
+ // Capturing the interrupts here (rather than reading the agent's
403
+ // `pendingInterrupts` field afterwards) keeps the loop self-contained
404
+ // and independent of that field's cross-run clearing semantics.
405
+ runState.terminal = true;
406
+ if (params.outcome === "interrupt") {
407
+ runState.interrupts = params.interrupts;
408
+ }
409
+ },
343
410
  onRunErrorEvent({ event }) {
344
411
  runState.terminal = true;
345
412
  runState.errored = true;
@@ -353,6 +420,14 @@ export class AgUiClient {
353
420
  }
354
421
  }
355
422
 
423
+ /** Per-run mutable flags the subscriber writes and {@link AgUiClient} reads. */
424
+ interface RunState {
425
+ terminal: boolean;
426
+ errored: boolean;
427
+ /** Approval interrupts a run finished on (empty for a normal finish). */
428
+ interrupts: Interrupt[];
429
+ }
430
+
356
431
  /**
357
432
  * Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
358
433
  * with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
@@ -0,0 +1,91 @@
1
+ /** One row of the server run index (django-ag-ui's `RunsView` wire shape). */
2
+ export interface RunRow {
3
+ readonly run_id: string;
4
+ readonly thread_id: string | null;
5
+ readonly parent_run_id: string | null;
6
+ readonly started_at: string | null;
7
+ /** Whether the run has a snapshot to seed from — see {@link RunIndex}. */
8
+ readonly continuable: boolean;
9
+ }
10
+
11
+ /** Live header source, read per request so rotated tokens / CSRF reach the server. */
12
+ type HeadersProvider = () => Record<string, string>;
13
+
14
+ /**
15
+ * Reads the server's run index and derives the resume / fork URLs beside it.
16
+ *
17
+ * Backed by django-ag-ui's owner-scoped `RunsView` — the URL passed to
18
+ * `<ag-ui-chat>` as `data-runs-url`:
19
+ *
20
+ * - `GET <url>` → the user's runs, newest first.
21
+ *
22
+ * **Only `continuable` rows can be resumed.** The server reports whether a run
23
+ * has a saved snapshot to seed from; a run that never reached a provider-valid
24
+ * boundary has none, so resuming it would start from nothing. Callers should
25
+ * offer the action only for those rows and treat the rest as informational — a
26
+ * crashed run worth showing, not worth continuing. {@link continuable} filters
27
+ * for exactly that.
28
+ *
29
+ * **Resume and fork are siblings of the index**, not separate configuration.
30
+ * django-ag-ui mounts all three under one prefix whenever a step store is
31
+ * configured (`runs/`, `resume/<id>/`, `fork/<id>/`), so one URL locates them
32
+ * all and there is no way to configure a half-working set.
33
+ */
34
+ export class RunIndex {
35
+ readonly #url: string;
36
+ readonly #headers: HeadersProvider;
37
+
38
+ constructor(url: string, headers: HeadersProvider = () => ({})) {
39
+ this.#url = url.endsWith("/") ? url : `${url}/`;
40
+ this.#headers = headers;
41
+ }
42
+
43
+ /**
44
+ * The user's runs, or `[]` when the endpoint is unreachable or answers with
45
+ * an error. A history affordance that cannot load is empty, never broken:
46
+ * the caller renders its empty state rather than surfacing a transport fault
47
+ * the user can do nothing about.
48
+ */
49
+ async list(): Promise<readonly RunRow[]> {
50
+ try {
51
+ const response = await fetch(this.#url, {
52
+ method: "GET",
53
+ headers: { Accept: "application/json", ...this.#headers() },
54
+ });
55
+ if (!response.ok) {
56
+ return [];
57
+ }
58
+ const body = (await response.json()) as { runs?: readonly RunRow[] };
59
+ return body.runs ?? [];
60
+ } catch {
61
+ return [];
62
+ }
63
+ }
64
+
65
+ /** The rows a client may actually continue. */
66
+ async continuable(): Promise<readonly RunRow[]> {
67
+ return (await this.list()).filter((run) => run.continuable);
68
+ }
69
+
70
+ /** The endpoint that continues `runId` as a new run. */
71
+ resumeUrl(runId: string): string {
72
+ return this.#sibling("resume", runId);
73
+ }
74
+
75
+ /** The endpoint that branches `runId` into a new run, leaving the source untouched. */
76
+ forkUrl(runId: string): string {
77
+ return this.#sibling("fork", runId);
78
+ }
79
+
80
+ /**
81
+ * `<mount>/<verb>/<runId>/`, derived from the index URL's own prefix.
82
+ *
83
+ * Built by string surgery on the trailing `runs/` rather than with `new URL`,
84
+ * because the configured value may be root-relative (`/agent/runs/`) — the
85
+ * common case in a Django template — and `new URL` needs an absolute base.
86
+ */
87
+ #sibling(verb: string, runId: string): string {
88
+ const prefix = this.#url.slice(0, -"runs/".length);
89
+ return `${prefix}${verb}/${encodeURIComponent(runId)}/`;
90
+ }
91
+ }
package/src/index.ts CHANGED
@@ -27,6 +27,8 @@ export {
27
27
  type AgUiToolCall,
28
28
  ConnectionLostError,
29
29
  type ExecuteTool,
30
+ type InterruptResponse,
31
+ type ResolveInterrupts,
30
32
  type ToolExecution,
31
33
  } from "./core/agui_client.js";
32
34
  export { type AttachmentRef, messageAttachments } from "./core/attachment.js";
@@ -43,6 +45,7 @@ export {
43
45
  } from "./core/create_http_agent.js";
44
46
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
45
47
  export { RemoteConversationStore } from "./core/remote_conversation_store.js";
48
+ export { RunIndex, type RunRow } from "./core/run_index.js";
46
49
  export {
47
50
  type TranscribeHandler,
48
51
  type TranscribeOptions,
@@ -98,12 +101,25 @@ export {
98
101
  type RouteWithParams,
99
102
  } from "./tools/route_map.js";
100
103
  export { createStateHookTools, type StateHook } from "./tools/state_hook.js";
104
+ export {
105
+ type ApprovalOptions,
106
+ type ApprovalRenderer,
107
+ type ApprovalRequest,
108
+ requestApproval,
109
+ } from "./ui/approval_card.js";
110
+ export { CheckpointMenu, type CheckpointVerb } from "./ui/checkpoint_menu.js";
101
111
  export {
102
112
  type ConfirmationOptions,
103
113
  type ConfirmationRequest,
104
114
  requestConfirmation,
105
115
  } from "./ui/confirmation_card.js";
106
116
  export { prettifyToolName } from "./ui/prettify_tool_name.js";
117
+ export {
118
+ type QuestionOptions,
119
+ type QuestionRenderer,
120
+ type QuestionRequest,
121
+ requestQuestion,
122
+ } from "./ui/question_card.js";
107
123
  export { type RenderMarkdownOptions, renderMarkdown } from "./ui/render_markdown.js";
108
124
  export {
109
125
  type SettledStatus,