@artooi/ag-ui-web-component 0.26.0 → 0.27.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.
@@ -6,6 +6,15 @@ import type { Tool } from "@ag-ui/core";
6
6
  * `handler` receives the parsed arguments and returns a result that is
7
7
  * JSON-serialised into the AG-UI tool-result message sent back to the agent.
8
8
  */
9
+ /**
10
+ * Draws one call from its arguments alone.
11
+ *
12
+ * Named separately so the replay path can take *this* and never the tool that
13
+ * owns it: a function of this type cannot reach a `handler`, which is what
14
+ * makes "a reload never re-runs a tool's effect" a property of the code rather
15
+ * than a note asking maintainers to be careful.
16
+ */
17
+ export type ChartRenderer = (args: Record<string, unknown>) => Node | null;
9
18
  export interface ClientTool {
10
19
  name: string;
11
20
  description: string;
@@ -33,7 +42,7 @@ export interface ClientTool {
33
42
  *
34
43
  * Return `null` for arguments that say nothing worth drawing.
35
44
  */
36
- render?: (args: Record<string, unknown>) => Node | null;
45
+ render?: ChartRenderer;
37
46
  }
38
47
  /**
39
48
  * Holds the frontend tools a host has declared on an `<ag-ui-chat>` element.
@@ -1 +1 @@
1
- {"version":3,"file":"client_tool_registry.d.ts","sourceRoot":"","sources":["../../src/tools/client_tool_registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAExC;;;;;;GAMG;AACH,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC;;;;OAIG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxF;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,GAAG,IAAI,CAAC;CACzD;AAED;;;;;;GAMG;AACH,qBAAa,kBAAkB;;IAG7B;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAE/B;IAED,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzB;IAED,yCAAyC;IACzC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAM5B;IAED,wDAAwD;IACxD,KAAK,IAAI,IAAI,EAAE,CAMd;CACF"}
1
+ {"version":3,"file":"client_tool_registry.d.ts","sourceRoot":"","sources":["../../src/tools/client_tool_registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AAExC;;;;;;GAMG;AACH;;;;;;;GAOG;AACH,MAAM,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,GAAG,IAAI,CAAC;AAE3E,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACpC;;;;OAIG;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,CAAC,EAAE,MAAM,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IACxF;;;;;;;;;;;;;;;;OAgBG;IACH,MAAM,CAAC,EAAE,aAAa,CAAC;CACxB;AAED;;;;;;GAMG;AACH,qBAAa,kBAAkB;;IAG7B;;;;;OAKG;IACH,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,IAAI,CAE/B;IAED,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEzB;IAED,yCAAyC;IACzC,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,CAM5B;IAED,wDAAwD;IACxD,KAAK,IAAI,IAAI,EAAE,CAMd;CACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"chart_block.d.ts","sourceRoot":"","sources":["../../src/ui/chart_block.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,mCAAmC;AACnC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,2BAA2B;AAC3B,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,CAAC;AAEvE,wBAAwB;AACxB,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAoBD,uFAAuF;AACvF,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjD;AAwOD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,cAAc,GAAG,IAAI,CAoDlE"}
1
+ {"version":3,"file":"chart_block.d.ts","sourceRoot":"","sources":["../../src/ui/chart_block.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAIH,mCAAmC;AACnC,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CACpC;AAED,2BAA2B;AAC3B,MAAM,MAAM,SAAS,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,GAAG,SAAS,CAAC;AAEvE,wBAAwB;AACxB,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,QAAQ,CAAC,MAAM,EAAE,SAAS,WAAW,EAAE,CAAC;CACzC;AAoBD,uFAAuF;AACvF,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAIjD;AA6OD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,SAAS,GAAG,cAAc,GAAG,IAAI,CAoDlE"}
@@ -1 +1 @@
1
- {"version":3,"file":"chart_spec_from.d.ts","sourceRoot":"","sources":["../../src/ui/chart_spec_from.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,kBAAkB,CAAC;AA0D7D,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI,CAqC9D"}
1
+ {"version":3,"file":"chart_spec_from.d.ts","sourceRoot":"","sources":["../../src/ui/chart_spec_from.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAa,SAAS,EAAE,MAAM,kBAAkB,CAAC;AA6E7D,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,GAAG,IAAI,CAqC9D"}
@@ -1 +1 @@
1
- {"version":3,"file":"chart_tool.d.ts","sourceRoot":"","sources":["../../src/ui/chart_tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kCAAkC,CAAC;AAInE,wDAAwD;AACxD,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAU9C,+BAA+B;AAC/B,wBAAgB,eAAe,IAAI,UAAU,CAqC5C"}
1
+ {"version":3,"file":"chart_tool.d.ts","sourceRoot":"","sources":["../../src/ui/chart_tool.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kCAAkC,CAAC;AAInE,wDAAwD;AACxD,eAAO,MAAM,eAAe,iBAAiB,CAAC;AAgB9C,+BAA+B;AAC/B,wBAAgB,eAAe,IAAI,UAAU,CAuC5C"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artooi/ag-ui-web-component",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Framework-free <ag-ui-chat> Web Component over the AG-UI protocol. Drop-in chat sidebar with a pluggable client-side tool registry, DOM driver primitives, animations, and destructive-action confirmation modal.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -24,6 +24,7 @@ import {
24
24
  import { fillTemplate } from "../skills/fill_template.js";
25
25
  import { parseSkills } from "../skills/parse_skills.js";
26
26
  import type { Skill } from "../skills/skill.js";
27
+ import type { ChartRenderer } from "../tools/client_tool_registry.js";
27
28
  import { type ClientTool, ClientToolRegistry } from "../tools/client_tool_registry.js";
28
29
  import { isDestructive } from "../tools/is_destructive.js";
29
30
  import { isNavigates } from "../tools/is_navigates.js";
@@ -42,7 +43,7 @@ import { renderAttachmentChips } from "../ui/attachment_chips.js";
42
43
  import { AttachmentTray } from "../ui/attachment_tray.js";
43
44
  import { renderChart } from "../ui/chart_block.js";
44
45
  import { chartSpecFrom } from "../ui/chart_spec_from.js";
45
- import { createChartTool } from "../ui/chart_tool.js";
46
+ import { CHART_TOOL_NAME, createChartTool } from "../ui/chart_tool.js";
46
47
  import { CheckpointMenu, type CheckpointVerb } from "../ui/checkpoint_menu.js";
47
48
  import { type ConfirmationRequest, requestConfirmation } from "../ui/confirmation_card.js";
48
49
  import { prettifyToolName } from "../ui/prettify_tool_name.js";
@@ -85,7 +86,7 @@ import { RemoteConversationStore } from "./remote_conversation_store.js";
85
86
  import { RunIndex } from "./run_index.js";
86
87
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
87
88
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
88
- import { withCredentials } from "./utils.js";
89
+ import { mintThread, withCredentials } from "./utils.js";
89
90
 
90
91
  /** The role a rendered chat message takes. */
91
92
  export type MessageRole = (typeof MESSAGE_ROLE)[keyof typeof MESSAGE_ROLE];
@@ -1652,16 +1653,25 @@ export class AgUiChat extends HTMLElement {
1652
1653
  }
1653
1654
 
1654
1655
  /**
1655
- * Start a fresh conversation: forget the persisted history, drop the
1656
- * in-memory run state, clear the transcript, and mint a new thread id.
1656
+ * Start a fresh conversation: drop the in-memory run state, clear the
1657
+ * transcript, and mint a new thread id.
1658
+ *
1659
+ * The conversation being left is kept, and stays in the history drawer to
1660
+ * return to. Deleting one is the drawer row's own action; a button that
1661
+ * starts something new must not be the button that destroys what was there.
1657
1662
  */
1658
1663
  newChat(): void {
1659
1664
  // Stop any in-flight run first — discarding the client mid-run would
1660
1665
  // leave the old agent streaming into a cleared transcript.
1661
1666
  this.#cancelRun();
1662
- this.conversationStore.clear(this.#threadId);
1667
+ // A thread nothing was ever sent in has nothing to come back to, and the
1668
+ // drawer never listed it — so reap it here rather than strand one record
1669
+ // per press of a button whose whole use is being pressed again.
1670
+ if (this.conversationStore.isUnsent?.(this.#threadId) === true) {
1671
+ this.conversationStore.clear(this.#threadId);
1672
+ }
1663
1673
  this.#resetState();
1664
- this.#threadId = this.conversationStore.threadId();
1674
+ this.#threadId = mintThread(this.conversationStore);
1665
1675
  this.#setRunning(false);
1666
1676
  this.#setUnread(0);
1667
1677
  }
@@ -1840,9 +1850,15 @@ export class AgUiChat extends HTMLElement {
1840
1850
  this.#cardElements.set(restored.id, this.#cardFor(restored).element);
1841
1851
  // Only `render` is replayed, never `handler`. A restored transcript
1842
1852
  // redraws what the call drew; it must not re-run what the call *did*.
1843
- const tool = this.#resolveTool(restored.name);
1844
- if (tool !== null) {
1845
- this.#renderToolOutput(tool, restored);
1853
+ // Only the renderer is handed over, never the tool. The guarantee that
1854
+ // a reload cannot re-run a tool's *effect* is worth more than a comment
1855
+ // saying so: this signature cannot reach `handler`, so a later
1856
+ // maintainer adding a "no render? fall back to the handler" convenience
1857
+ // here has to change the type first, which is exactly the moment the
1858
+ // question should be asked.
1859
+ const render = this.#resolveTool(restored.name)?.render;
1860
+ if (render !== undefined) {
1861
+ this.#renderToolOutput(render, restored);
1846
1862
  }
1847
1863
  }
1848
1864
  return;
@@ -2653,7 +2669,9 @@ export class AgUiChat extends HTMLElement {
2653
2669
  const result = await tool.handler(call.args, call.id);
2654
2670
  // Drawn from the arguments rather than the result, so the live path and
2655
2671
  // the replay path render the same thing from the same input.
2656
- this.#renderToolOutput(tool, call);
2672
+ if (tool.render !== undefined) {
2673
+ this.#renderToolOutput(tool.render, call);
2674
+ }
2657
2675
  if (navigates) {
2658
2676
  card.settle(TOOL_CALL_STATUS.DONE, this.#strings.navigating);
2659
2677
  return { content: "", halt: true };
@@ -3053,12 +3071,21 @@ export class AgUiChat extends HTMLElement {
3053
3071
  * arrives is not something to switch on for everybody.
3054
3072
  */
3055
3073
  enableCharts(routes: readonly ("tool" | "activity")[] = ["tool", "activity"]): void {
3074
+ const first = !this.#chartActivity && !this.#toolRegistry.has(CHART_TOOL_NAME);
3056
3075
  if (routes.includes("activity")) {
3057
3076
  this.#chartActivity = true;
3058
3077
  }
3059
3078
  if (routes.includes("tool")) {
3060
3079
  this.registerTool(createChartTool());
3061
3080
  }
3081
+ // Called after the element is connected, the history has already replayed
3082
+ // and every chart in it was skipped -- charts were off at the time. That is
3083
+ // the ordinary way to call this (you have to query the element to call
3084
+ // anything on it), so redrawing rather than documenting an ordering rule is
3085
+ // the only answer that does not make the obvious usage wrong.
3086
+ if (first && this.isConnected) {
3087
+ this.reload();
3088
+ }
3062
3089
  }
3063
3090
 
3064
3091
  /**
@@ -3071,13 +3098,10 @@ export class AgUiChat extends HTMLElement {
3071
3098
  * created inline, in the right place, so anchoring makes *when* the handler
3072
3099
  * runs stop mattering.
3073
3100
  */
3074
- #renderToolOutput(tool: ClientTool, call: AgUiToolCall): void {
3075
- if (tool.render === undefined) {
3076
- return;
3077
- }
3101
+ #renderToolOutput(render: ChartRenderer, call: AgUiToolCall): void {
3078
3102
  let node: Node | null;
3079
3103
  try {
3080
- node = tool.render(call.args);
3104
+ node = render(call.args);
3081
3105
  } catch (error) {
3082
3106
  // `render` is consumer code and this runs inside the history replay, where
3083
3107
  // a throw abandons the loop and takes every later turn of the transcript
@@ -3100,11 +3124,16 @@ export class AgUiChat extends HTMLElement {
3100
3124
  /** Draw, or redraw in place, the chart for one activity message. */
3101
3125
  #drawActivityChart(messageId: string, content: unknown): void {
3102
3126
  const spec = chartSpecFrom(content);
3103
- if (spec === null) {
3104
- return;
3105
- }
3106
- const block = renderChart(spec);
3127
+ const block = spec === null ? null : renderChart(spec);
3107
3128
  if (block === null) {
3129
+ // The server superseded this chart with something undrawable. Leaving the
3130
+ // old one up is the worst available answer: it shows numbers that have
3131
+ // been retracted, reading as current, and a reload then drops the chart
3132
+ // entirely because the *stored* content is the version we could not draw.
3133
+ // Live and reload should agree, and both should say "gone" rather than
3134
+ // one of them lying.
3135
+ this.#activityBlocks.get(messageId)?.remove();
3136
+ this.#activityBlocks.delete(messageId);
3108
3137
  return;
3109
3138
  }
3110
3139
  const existing = this.#activityBlocks.get(messageId);
@@ -408,6 +408,9 @@ export class AgUiClient {
408
408
  #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
409
409
  const h = this.#handlers;
410
410
  const closed = this.#closedMessageIds;
411
+ // Read at event time, not captured now: the flag flips mid-run, and the
412
+ // subscriber is built before the run that a later `cancel()` stops.
413
+ const cancelled = (): boolean => this.#cancelled;
411
414
  // Charts whose patch has been dispatched but not yet applied. Scoped to the
412
415
  // subscriber, so it cannot outlive the run that created it.
413
416
  const pendingDeltas = new Set<string>();
@@ -510,6 +513,15 @@ export class AgUiClient {
510
513
  onRunErrorEvent({ event }) {
511
514
  runState.terminal = true;
512
515
  runState.errored = true;
516
+ // Cancelling aborts the response mid-read, and the browser's own words
517
+ // for that can arrive here as a RUN_ERROR — Chrome's is
518
+ // "BodyStreamBuffer was aborted". The run is over either way, but a
519
+ // deliberate stop is not a failure, and reporting it would raise a
520
+ // warning bubble above the stopped note saying the same thing twice.
521
+ // The promise route in `#run` reports the cancellation.
522
+ if (cancelled()) {
523
+ return;
524
+ }
513
525
  h.onError(event.message);
514
526
  },
515
527
  onRunFinalized() {
@@ -532,7 +544,19 @@ interface RunState {
532
544
  * Whether a rejection came from aborting the run's fetch. Belt-and-suspenders
533
545
  * with the `#cancelled` flag: some `@ag-ui/client` versions re-throw the
534
546
  * `AbortError` instead of filtering it.
547
+ *
548
+ * Aborting a fetch whose body is mid-read does not always surface as an
549
+ * `AbortError`. Chrome raises `TypeError: BodyStreamBuffer was aborted`, which
550
+ * is the same event wearing a different name, so a message naming the abort is
551
+ * read as one too. Narrow on purpose: only a `TypeError`, and only when it says
552
+ * so — a genuine type error carries no such word, and misreading one as a
553
+ * cancellation would hide a real failure behind a stopped note.
535
554
  */
536
555
  function isAbortError(error: unknown): boolean {
537
- return error instanceof Error && error.name === "AbortError";
556
+ if (!(error instanceof Error)) {
557
+ return false;
558
+ }
559
+ return (
560
+ error.name === "AbortError" || (error instanceof TypeError && /abort/i.test(error.message))
561
+ );
538
562
  }
@@ -36,12 +36,24 @@ export interface ThreadMeta {
36
36
  * is a small local hint a server store can derive from history and no-op.
37
37
  *
38
38
  * Thread enumeration backs the chat-history drawer; deleting a thread reuses
39
- * {@link clear} and "new chat" reuses {@link threadId} after clearing the
40
- * active thread.
39
+ * {@link clear}, and "new chat" is {@link newThread} which leaves the
40
+ * conversation it moves off of intact, for the drawer to offer back.
41
41
  */
42
42
  export interface ClientConversationStore {
43
43
  /** The active conversation id, generated and persisted on first read. */
44
44
  threadId(): string;
45
+ /**
46
+ * Start a fresh conversation, make it active, and return its id.
47
+ *
48
+ * Existing threads are left where they are: "new chat" adds one, and
49
+ * {@link clear} is the only method that takes one away.
50
+ *
51
+ * Optional, so a store written before this method existed still works. The
52
+ * caller then mints the id itself and hands it to {@link setActiveThread},
53
+ * which loses only the store's own record that the thread is new (see
54
+ * {@link isUnsent}).
55
+ */
56
+ newThread?(): string;
45
57
  /** Load the persisted message history, or `null` when none exists. */
46
58
  loadMessages(threadId: string): Promise<readonly Message[] | null>;
47
59
  /** Persist the message history (and refresh the thread's drawer metadata). */
@@ -123,13 +135,12 @@ export class SessionStorageStore implements ClientConversationStore {
123
135
  }
124
136
 
125
137
  threadId(): string {
126
- const key = this.#key(THREAD_SUFFIX);
127
- const existing = sessionStorage.getItem(key);
128
- if (existing !== null) {
129
- return existing;
130
- }
138
+ return sessionStorage.getItem(this.#key(THREAD_SUFFIX)) ?? this.newThread();
139
+ }
140
+
141
+ newThread(): string {
131
142
  const id = randomUUID();
132
- sessionStorage.setItem(key, id);
143
+ sessionStorage.setItem(this.#key(THREAD_SUFFIX), id);
133
144
  sessionStorage.setItem(this.#key(MINTED_SUFFIX + id), "1");
134
145
  return id;
135
146
  }
@@ -5,7 +5,7 @@ import {
5
5
  SessionStorageStore,
6
6
  type ThreadMeta,
7
7
  } from "./conversation_store.js";
8
- import { withCredentials } from "./utils.js";
8
+ import { mintThread, withCredentials } from "./utils.js";
9
9
 
10
10
  /** One row of the server thread index (django-ag-ui's `ThreadsView` wire shape). */
11
11
  interface ServerThreadRow {
@@ -69,6 +69,15 @@ export class RemoteConversationStore implements ClientConversationStore {
69
69
  this.#local.setActiveThread(threadId);
70
70
  }
71
71
 
72
+ /**
73
+ * Delegated to the local store, which owns the active id — and deliberately
74
+ * silent on the wire: the server learns of a thread when its first message is
75
+ * persisted, so an abandoned new chat costs no round-trip and leaves no row.
76
+ */
77
+ newThread(): string {
78
+ return mintThread(this.#local);
79
+ }
80
+
72
81
  /** Delegated, so wrapping a store does not lose what it knows about its own ids. */
73
82
  isUnsent(threadId: string): boolean {
74
83
  return this.#local.isUnsent?.(threadId) === true;
package/src/core/utils.ts CHANGED
@@ -1,4 +1,7 @@
1
- // Non-exported-from-index helpers shared by the core transport modules.
1
+ // Non-exported-from-index helpers shared by the core modules.
2
+
3
+ import { randomUUID } from "@ag-ui/client";
4
+ import type { ClientConversationStore } from "./conversation_store.js";
2
5
 
3
6
  /**
4
7
  * Overlay a `credentials` mode onto a fetch `init`, or hand the `init` back
@@ -14,3 +17,24 @@ export function withCredentials(
14
17
  ): RequestInit | undefined {
15
18
  return credentials === undefined ? init : { ...init, credentials };
16
19
  }
20
+
21
+ /**
22
+ * Start a new conversation in `store` and return its id.
23
+ *
24
+ * `newThread` is optional on the interface, so a store that predates it is
25
+ * driven the only other way the interface allows: mint an id here and make it
26
+ * active. That path loses the store's own note that the thread is new, so a
27
+ * remote store would go on to ask the server for a conversation that cannot
28
+ * exist yet — which is why every store in this package implements the method.
29
+ *
30
+ * What neither path does is clear the thread being left behind. Starting a
31
+ * conversation is not a reason to destroy the previous one.
32
+ */
33
+ export function mintThread(store: ClientConversationStore): string {
34
+ if (store.newThread !== undefined) {
35
+ return store.newThread();
36
+ }
37
+ const id = randomUUID();
38
+ store.setActiveThread(id);
39
+ return id;
40
+ }
@@ -7,6 +7,16 @@ import type { Tool } from "@ag-ui/core";
7
7
  * `handler` receives the parsed arguments and returns a result that is
8
8
  * JSON-serialised into the AG-UI tool-result message sent back to the agent.
9
9
  */
10
+ /**
11
+ * Draws one call from its arguments alone.
12
+ *
13
+ * Named separately so the replay path can take *this* and never the tool that
14
+ * owns it: a function of this type cannot reach a `handler`, which is what
15
+ * makes "a reload never re-runs a tool's effect" a property of the code rather
16
+ * than a note asking maintainers to be careful.
17
+ */
18
+ export type ChartRenderer = (args: Record<string, unknown>) => Node | null;
19
+
10
20
  export interface ClientTool {
11
21
  name: string;
12
22
  description: string;
@@ -34,7 +44,7 @@ export interface ClientTool {
34
44
  *
35
45
  * Return `null` for arguments that say nothing worth drawing.
36
46
  */
37
- render?: (args: Record<string, unknown>) => Node | null;
47
+ render?: ChartRenderer;
38
48
  }
39
49
 
40
50
  /**
@@ -174,7 +174,12 @@ function drawStacked(svg: SVGSVGElement, spec: ChartSpec, min: number, max: numb
174
174
  const running = spec.labels.map(() => 0);
175
175
  spec.series.forEach((series, s) => {
176
176
  series.points.forEach((value, i) => {
177
- const from = running[i] as number;
177
+ // `?? 0` rather than a cast: `renderChart` is exported, so a caller can
178
+ // hand it a series carrying more points than there are labels, which
179
+ // `chartSpecFrom` would have refused. The cast that used to be here
180
+ // claimed that could not happen and wrote `y="NaN"` into the DOM when it
181
+ // did.
182
+ const from = running[i] ?? 0;
178
183
  const to = from + value;
179
184
  running[i] = to;
180
185
  const y = scaleY(to, min, max);
@@ -22,6 +22,17 @@ const KINDS: readonly ChartKind[] = ["bar", "line", "pie", "scatter", "stacked"]
22
22
  */
23
23
  const MAX_POINTS = 20_000;
24
24
 
25
+ /**
26
+ * Whether a spec's *labels* are cheap enough to draw.
27
+ *
28
+ * `MAX_POINTS` bounds the data; this bounds the DOM. Every label produces an
29
+ * axis text node whatever the series count, so a spec well inside the point
30
+ * budget can still emit tens of thousands of nodes and block the main thread —
31
+ * again on every reload, since it is in the transcript. Kept separate because
32
+ * the two limits answer different questions and a single number cannot.
33
+ */
34
+ const MAX_LABELS = 2_000;
35
+
25
36
  /**
26
37
  * Largest magnitude a point may carry.
27
38
  *
@@ -59,9 +70,17 @@ function asNumbers(value: unknown): number[] | null {
59
70
  }
60
71
 
61
72
  function asStrings(value: unknown): string[] | null {
62
- if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
73
+ if (!Array.isArray(value)) {
63
74
  return null;
64
75
  }
76
+ // `every` over an index range rather than `Array.prototype.some`, which skips
77
+ // holes: a sparse array passed the old check and drew a chart with blank axis
78
+ // labels, which reads as a rendering bug rather than bad input.
79
+ for (let i = 0; i < value.length; i += 1) {
80
+ if (typeof value[i] !== "string") {
81
+ return null;
82
+ }
83
+ }
65
84
  return value as string[];
66
85
  }
67
86
 
@@ -94,7 +113,7 @@ export function chartSpecFrom(value: unknown): ChartSpec | null {
94
113
  if (series.length === 0) {
95
114
  return null;
96
115
  }
97
- if (series.length * labels.length > MAX_POINTS) {
116
+ if (series.length * labels.length > MAX_POINTS || labels.length > MAX_LABELS) {
98
117
  return null;
99
118
  }
100
119
 
@@ -20,6 +20,12 @@ function draw(args: Record<string, unknown>): HTMLDivElement | null {
20
20
  return spec === null ? null : renderChart(spec);
21
21
  }
22
22
 
23
+ /** Whether these arguments would produce a chart, without producing one. */
24
+ function drawable(args: Record<string, unknown>): boolean {
25
+ const spec = chartSpecFrom(args);
26
+ return spec !== null && spec.labels.length > 0 && spec.series.length > 0;
27
+ }
28
+
23
29
  const REJECTED =
24
30
  "chart not rendered: expected labels (strings) and series, each with one finite number per label";
25
31
 
@@ -54,11 +60,13 @@ export function createChartTool(): ClientTool {
54
60
  // Says what happened and nothing else; the drawing is `render`'s job. Told
55
61
  // plainly when the arguments are unusable, because the model can fix that
56
62
  // and retry — a silent no-op would leave it believing the chart is on screen.
57
- // Answers on what was actually drawn, not on what validated. A spec can
58
- // pass validation and still have nothing to show -- zero labels matches zero
59
- // points -- and reporting success then would leave the model believing a
60
- // chart is on screen.
61
- handler: (args: Record<string, unknown>) => (draw(args) === null ? REJECTED : "chart rendered"),
63
+ // Answers on what will actually be drawn, not on what validated: a spec can
64
+ // pass validation and still have nothing to show, and reporting success
65
+ // then would leave the model believing a chart is on screen. Asks the
66
+ // question without building the chart, because `render` is about to build
67
+ // the same one a moment later and drawing it twice is pure waste on a spec
68
+ // large enough to matter.
69
+ handler: (args: Record<string, unknown>) => (drawable(args) ? "chart rendered" : REJECTED),
62
70
  render: draw,
63
71
  };
64
72
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.26.0";
1
+ export const VERSION: string = "0.27.0";