@artooi/ag-ui-web-component 0.35.1 → 0.36.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@artooi/ag-ui-web-component",
3
- "version": "0.35.1",
3
+ "version": "0.36.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",
package/src/constants.ts CHANGED
@@ -263,6 +263,32 @@ export const TOOL_CALL_STATUS = {
263
263
  DECLINED: "declined",
264
264
  } as const;
265
265
 
266
+ /**
267
+ * How a tool call ended, as the server states it on `TOOL_CALL_RESULT`.
268
+ *
269
+ * The vocabulary is pydantic-ai's own `ToolReturnPart.outcome`, carried whole
270
+ * rather than re-spelled, so the four repos that pass a refusal along agree on
271
+ * one word for it.
272
+ *
273
+ * **Absent means {@link TOOL_OUTCOME.SUCCESS}, and that is load-bearing.** Every
274
+ * server written before the field existed omits it, so a missing field has to
275
+ * render exactly as a plain result did — which is why this is an optional
276
+ * annotation on the event and not a required one. `FAILED` is a call that ran
277
+ * and failed; `DENIED` is one a person or a guard refused, so it never ran at
278
+ * all. The two are worth distinguishing on screen because only the second is
279
+ * something the user did.
280
+ *
281
+ * Anything *else* on the wire — a value from a later protocol version, or
282
+ * pydantic-ai's own `interrupted` — is read as a success rather than rejected.
283
+ * A card is a claim about what happened, and "I do not know this word" is not
284
+ * grounds for claiming failure. `toolStatusFromOutcome` is where that is done.
285
+ */
286
+ export const TOOL_OUTCOME = {
287
+ SUCCESS: "success",
288
+ FAILED: "failed",
289
+ DENIED: "denied",
290
+ } as const;
291
+
266
292
  /**
267
293
  * Lifecycle status of a pending-attachment chip in the composer tray. A chip
268
294
  * opens as `UPLOADING` (with a progress bar), then settles to `READY` (a durable
@@ -35,6 +35,7 @@ import {
35
35
  TOGGLE_EVENT,
36
36
  TOOL_CALL_STATUS,
37
37
  TOOL_DISPLAY,
38
+ TOOL_OUTCOME,
38
39
  UNREAD_EVENT,
39
40
  X_CONFIRM_KEY,
40
41
  X_SUMMARY_KEY,
@@ -144,6 +145,7 @@ import {
144
145
  import { type AgentFactory, createHttpAgent } from "./create_http_agent.js";
145
146
  import { RemoteConversationStore } from "./remote_conversation_store.js";
146
147
  import { RunIndex } from "./run_index.js";
148
+ import { toolStatusFromOutcome } from "./tool_outcome.js";
147
149
  import { type TranscribeHandler, transcribeAudio } from "./transcribe_audio.js";
148
150
  import { type UploadHandler, uploadAttachment } from "./upload_attachment.js";
149
151
  import { mintThread, warnOnCrossOriginCredentials, withCredentials } from "./utils.js";
@@ -3995,7 +3997,17 @@ export class AgUiChat extends HTMLElement {
3995
3997
  if (message.role === "tool") {
3996
3998
  const card = this.#toolCards.get(message.toolCallId);
3997
3999
  if (card !== undefined) {
3998
- card.settle(TOOL_CALL_STATUS.DONE, message.content);
4000
+ // The outcome `AgUiClient` annotated onto the persisted message, read
4001
+ // back through the same mapping the live path uses -- so a card that
4002
+ // said "declined" before the reload still says it after. Narrowed off
4003
+ // `unknown` rather than trusted, like every other field read out of the
4004
+ // store: `Message` does not declare it, a host store may not round-trip
4005
+ // it, and history written before this shipped has none. All three land
4006
+ // on DONE, which is what this line did unconditionally.
4007
+ card.settle(
4008
+ toolStatusFromOutcome((message as { outcome?: unknown }).outcome),
4009
+ message.content,
4010
+ );
3999
4011
  }
4000
4012
  }
4001
4013
  }
@@ -5175,7 +5187,9 @@ export class AgUiChat extends HTMLElement {
5175
5187
  const message = this.#strings.pageMoved;
5176
5188
  card.settle(TOOL_CALL_STATUS.ERROR, message);
5177
5189
  this.#showPending();
5178
- return { content: `Error: ${message}`, error: message };
5190
+ // Stated so a reload settles this card the same way. The card's own status
5191
+ // lives only in the DOM, and the DOM is what a reload throws away.
5192
+ return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
5179
5193
  }
5180
5194
  const rule = await this.#confirmationRule(call, tool);
5181
5195
  if (rule !== null) {
@@ -5208,7 +5222,11 @@ export class AgUiChat extends HTMLElement {
5208
5222
  const message = this.#strings.declinedAction;
5209
5223
  card.settle(TOOL_CALL_STATUS.DECLINED, message);
5210
5224
  this.#showPending();
5211
- return { content: message };
5225
+ // The one outcome with no error text and no server involvement at all:
5226
+ // a person said no in this browser. Nothing else records that, so
5227
+ // without the annotation the reload showed a green card for an action
5228
+ // the user had explicitly refused.
5229
+ return { content: message, outcome: TOOL_OUTCOME.DENIED };
5212
5230
  }
5213
5231
  }
5214
5232
  // A navigating tool reloads only without a client-side router; with a
@@ -5251,7 +5269,7 @@ export class AgUiChat extends HTMLElement {
5251
5269
  const message = error instanceof Error ? error.message : String(error);
5252
5270
  card.settle(TOOL_CALL_STATUS.ERROR, message);
5253
5271
  this.#showPending();
5254
- return { content: `Error: ${message}`, error: message };
5272
+ return { content: `Error: ${message}`, error: message, outcome: TOOL_OUTCOME.FAILED };
5255
5273
  }
5256
5274
  }
5257
5275
 
@@ -5516,12 +5534,18 @@ export class AgUiChat extends HTMLElement {
5516
5534
  // got for compaction, one handler up.
5517
5535
  this.#appendNotice("\u{1F504}", this.#strings.historyReplaced, "history-replaced");
5518
5536
  },
5519
- onToolResult: (toolCallId, content) => {
5537
+ onToolResult: (toolCallId, content, outcome) => {
5520
5538
  const card = this.#toolCards.get(toolCallId);
5521
5539
  if (card === undefined) {
5522
5540
  return;
5523
5541
  }
5524
- card.settle(TOOL_CALL_STATUS.DONE, content);
5542
+ // Settled as the server says it ended, not as "it ended". This path used
5543
+ // to pass DONE unconditionally, so a refusal arrived as a green card
5544
+ // with the reason folded inside it -- a booking the server declined
5545
+ // read, at a glance, as a booking that was made. An absent or
5546
+ // unrecognised outcome still means DONE, so every server written before
5547
+ // the field existed renders exactly as it did.
5548
+ card.settle(toolStatusFromOutcome(outcome), content);
5525
5549
  this.#serverSettled.add(toolCallId);
5526
5550
  // The card stops being the live thing the moment it settles, and the
5527
5551
  // server goes straight back to the model with the result -- a wait with
@@ -8,6 +8,7 @@ import {
8
8
  import type { Context, Interrupt, Message, ResumeEntry, Tool } from "@ag-ui/core";
9
9
  import { MAX_TOOL_ROUNDS } from "../constants.js";
10
10
  import type { AttachmentRef } from "./attachment.js";
11
+ import type { ToolOutcome } from "./tool_outcome.js";
11
12
 
12
13
  /** A tool call surfaced to the host by {@link AgUiClient}. */
13
14
  export interface AgUiToolCall {
@@ -22,6 +23,18 @@ export interface ToolExecution {
22
23
  content: string;
23
24
  /** Present when the handler failed; surfaced for logging. */
24
25
  error?: string;
26
+ /**
27
+ * How the call ended, in the same vocabulary a server states on
28
+ * `TOOL_CALL_RESULT`. Omit for a success -- absent *is* success, here for the
29
+ * same reason it is on the wire.
30
+ *
31
+ * Distinct from {@link error}, which is a *message* and only exists for the
32
+ * one failure shape that produces one. A refusal has no error and is still
33
+ * not a success, and that is the case this field exists for: without it a
34
+ * declined call was persisted as an ordinary result and came back from a
35
+ * reload as a green card.
36
+ */
37
+ outcome?: ToolOutcome;
25
38
  /**
26
39
  * When `true`, a navigating tool triggered a page reload. The loop stops
27
40
  * without appending a result — the result is supplied after the next mount
@@ -76,8 +89,19 @@ export interface AgUiClientHandlers {
76
89
  * Fired when a server-side tool's result streams back (AG-UI's
77
90
  * `TOOL_CALL_RESULT`). Frontend tools don't emit this — the client supplies
78
91
  * their result itself — so this is the channel for server-executed output.
92
+ *
93
+ * `outcome` is the event's optional `outcome` field, forwarded raw. It is
94
+ * `unknown` rather than {@link ToolOutcome} because it comes off a
95
+ * `passthrough` zod schema: the protocol does not validate it, so neither can
96
+ * this signature honestly claim to. Read it with `toolStatusFromOutcome`,
97
+ * which treats `undefined` and anything unrecognised as a success.
98
+ *
99
+ * Added as a third parameter rather than as a new callback, so an
100
+ * implementation written against the two-parameter form still satisfies this
101
+ * interface and still behaves exactly as it did — which is the same
102
+ * backwards-compatibility promise the wire field makes.
79
103
  */
80
- onToolResult(toolCallId: string, content: string): void;
104
+ onToolResult(toolCallId: string, content: string, outcome?: unknown): void;
81
105
  /**
82
106
  * Fired for AG-UI activity events — ambient notices about what the *run* did,
83
107
  * as opposed to work the agent asked for. `django-ag-ui` emits one with
@@ -259,6 +283,25 @@ export class AgUiClient {
259
283
  * set would miss entirely.
260
284
  */
261
285
  readonly #closedMessageIds = new Set<string>();
286
+ /**
287
+ * How each tool call ended, keyed by call id, for the transcript this client
288
+ * persists.
289
+ *
290
+ * A side table rather than a field written onto the message, because neither
291
+ * producer of a tool message will carry it. `@ag-ui/client` builds the
292
+ * server-side one by destructuring five named fields off the event, so an
293
+ * `outcome` beside them is dropped before the message exists; and writing it
294
+ * back onto that message afterwards would put it in `agent.messages`, which is
295
+ * what the *next* request sends to the server. This keeps the annotation on
296
+ * the copy handed to the store and off the wire.
297
+ *
298
+ * Per client, not per run: `saveMessages` rewrites the whole transcript on
299
+ * every persist, so an outcome recorded in round one has to still be here in
300
+ * round five or the earlier card silently reverts to a green one. Bounded by
301
+ * the number of tool calls in the conversation, which the transcript beside it
302
+ * already is.
303
+ */
304
+ readonly #outcomes = new Map<string, string>();
262
305
  readonly #connectionLostMessage: string;
263
306
  readonly #maxToolRounds: number;
264
307
  // Set by cancel(); reset at the top of each #run(). Checked by the loop so
@@ -334,7 +377,7 @@ export class AgUiClient {
334
377
  (message as { attachments?: readonly AttachmentRef[] }).attachments = attachments;
335
378
  }
336
379
  this.#agent.addMessage(message);
337
- this.#onPersist(this.#agent.messages);
380
+ this.#persist();
338
381
  await this.#run();
339
382
  }
340
383
 
@@ -373,7 +416,7 @@ export class AgUiClient {
373
416
  }
374
417
  const kept = messages.slice(0, lastUser + 1);
375
418
  this.#agent.setMessages(kept);
376
- this.#onPersist(this.#agent.messages);
419
+ this.#persist();
377
420
  return kept;
378
421
  }
379
422
 
@@ -389,7 +432,7 @@ export class AgUiClient {
389
432
  /** Append a frontend tool result to history (used by the resume path). */
390
433
  addToolResult(toolCallId: string, content: string): void {
391
434
  this.#agent.addMessage({ id: randomUUID(), role: "tool", content, toolCallId });
392
- this.#onPersist(this.#agent.messages);
435
+ this.#persist();
393
436
  }
394
437
 
395
438
  /**
@@ -428,10 +471,42 @@ export class AgUiClient {
428
471
  #onCancelled(): void {
429
472
  // Persist so the truncated exchange, partial assistant text included,
430
473
  // survives a reload.
431
- this.#onPersist(this.#agent.messages);
474
+ this.#persist();
432
475
  this.#handlers.onCancelled();
433
476
  }
434
477
 
478
+ /**
479
+ * Hand the transcript to the host's store, annotated with what {@link #outcomes}
480
+ * knows about how each tool call ended.
481
+ *
482
+ * Every persist in this class goes through here, because the store keeps only
483
+ * the most recent list: annotating one call site would mean the next
484
+ * unannotated save quietly threw the annotations away.
485
+ */
486
+ #persist(): void {
487
+ const messages = this.#agent.messages;
488
+ if (this.#outcomes.size === 0) {
489
+ this.#onPersist(messages);
490
+ return;
491
+ }
492
+ // A copy, and only of the messages that gain something. `agent.messages` is
493
+ // the list the next `runAgent` sends back to the server, so writing an extra
494
+ // field into it would put a client-side annotation on the wire; a store is
495
+ // allowed to hold more than the protocol does.
496
+ this.#onPersist(
497
+ messages.map((message) => {
498
+ if (message.role !== "tool") {
499
+ return message;
500
+ }
501
+ const outcome = this.#outcomes.get(message.toolCallId);
502
+ // Cast at the AG-UI boundary, as the `attachments` augmentation on a
503
+ // user message already does: `Message` does not declare the field, and
504
+ // the default store round-trips it through `JSON.stringify` verbatim.
505
+ return outcome === undefined ? message : ({ ...message, outcome } as Message);
506
+ }),
507
+ );
508
+ }
509
+
435
510
  async #runLoop(): Promise<void> {
436
511
  // Carries resolved approval answers into the next run when a round finished
437
512
  // on a server-side-tool interrupt. Distinct from the public resume(), which
@@ -455,7 +530,7 @@ export class AgUiClient {
455
530
  }
456
531
  await this.#agent.runAgent(params, this.#buildSubscriber(pending, runState));
457
532
  resume = undefined;
458
- this.#onPersist(this.#agent.messages);
533
+ this.#persist();
459
534
  // Cancelled mid-stream: don't execute the tool calls collected before the
460
535
  // abort.
461
536
  if (this.#cancelled) {
@@ -503,13 +578,21 @@ export class AgUiClient {
503
578
  // next mount. Stop here rather than re-running into a dead context.
504
579
  return;
505
580
  }
581
+ // Recorded before the persist below, so the very first save of this
582
+ // message already carries how it ended. A frontend tool's refusal or
583
+ // failure never touches the wire's `outcome` field -- no server states
584
+ // it, because no server ran the call -- so this side table is the only
585
+ // record there is, and a reload reads a card off it.
586
+ if (result.outcome !== undefined) {
587
+ this.#outcomes.set(call.id, result.outcome);
588
+ }
506
589
  this.#agent.addMessage({
507
590
  id: randomUUID(),
508
591
  role: "tool",
509
592
  content: result.content,
510
593
  toolCallId: call.id,
511
594
  });
512
- this.#onPersist(this.#agent.messages);
595
+ this.#persist();
513
596
  executed = true;
514
597
  }
515
598
  if (!executed) {
@@ -521,6 +604,7 @@ export class AgUiClient {
521
604
  #buildSubscriber(pending: AgUiToolCall[], runState: RunState): AgentSubscriber {
522
605
  const h = this.#handlers;
523
606
  const closed = this.#closedMessageIds;
607
+ const outcomes = this.#outcomes;
524
608
  // Read at event time, not captured now: the flag flips mid-run, and the
525
609
  // subscriber is built before the run that a later `cancel()` stops.
526
610
  const cancelled = (): boolean => this.#cancelled;
@@ -562,7 +646,20 @@ export class AgUiClient {
562
646
  h.onToolCall(call);
563
647
  },
564
648
  onToolCallResultEvent({ event }) {
565
- h.onToolResult(event.toolCallId, event.content);
649
+ // Bracket access because the field is not declared: `TOOL_CALL_RESULT`
650
+ // extends a `passthrough` schema, so an unknown key survives parsing and
651
+ // arrives here typed only by the catch-all index signature. That is the
652
+ // whole mechanism the outcome rides -- no schema change in `@ag-ui/core`
653
+ // is needed for a server to state one.
654
+ const outcome = event["outcome"];
655
+ // Recorded even when it is a word this client does not recognise, and
656
+ // even when it says "success": the store is a record of what the server
657
+ // said, and re-reading it through the same mapping as the live path is
658
+ // what keeps a reload agreeing with what the user watched happen.
659
+ if (typeof outcome === "string") {
660
+ outcomes.set(event.toolCallId, outcome);
661
+ }
662
+ h.onToolResult(event.toolCallId, event.content, outcome);
566
663
  },
567
664
  onActivitySnapshotEvent({ event, messages }) {
568
665
  // A snapshot for an id already in the list is a replacement, not a new
@@ -0,0 +1,36 @@
1
+ import { TOOL_CALL_STATUS, TOOL_OUTCOME } from "../constants.js";
2
+ import type { SettledStatus } from "../ui/tool_call_card.js";
3
+
4
+ /**
5
+ * How a tool call ended, in the wire's own words. See {@link TOOL_OUTCOME}.
6
+ *
7
+ * Absent is the fourth case and the common one: a server that has never heard
8
+ * of the field says nothing, and nothing means success.
9
+ */
10
+ export type ToolOutcome = (typeof TOOL_OUTCOME)[keyof typeof TOOL_OUTCOME];
11
+
12
+ /**
13
+ * The card status a wire outcome settles into.
14
+ *
15
+ * Takes `unknown` rather than {@link ToolOutcome} on purpose: both callers read
16
+ * this off a boundary the type system does not police -- a `passthrough` field
17
+ * on an AG-UI event, and a JSON blob out of the conversation store -- so the
18
+ * narrowing belongs here, once, instead of at each of them.
19
+ *
20
+ * **Everything unrecognised maps to `DONE`.** Not because unknown values are
21
+ * expected to be successes, but because the alternative is worse in the
22
+ * direction that matters: a card claiming a call failed when it did not is a
23
+ * lie the user acts on, while a card claiming success has at least the result
24
+ * text under it for them to read. `interrupted` is the concrete case today --
25
+ * pydantic-ai emits it, this vocabulary does not carry it, and a future release
26
+ * may add more. Forward compatibility is the point of the open field.
27
+ */
28
+ export function toolStatusFromOutcome(outcome: unknown): SettledStatus {
29
+ if (outcome === TOOL_OUTCOME.FAILED) {
30
+ return TOOL_CALL_STATUS.ERROR;
31
+ }
32
+ if (outcome === TOOL_OUTCOME.DENIED) {
33
+ return TOOL_CALL_STATUS.DECLINED;
34
+ }
35
+ return TOOL_CALL_STATUS.DONE;
36
+ }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export {
20
20
  TOGGLE_EVENT,
21
21
  TOOL_CALL_STATUS,
22
22
  TOOL_DISPLAY,
23
+ TOOL_OUTCOME,
23
24
  UNREAD_EVENT,
24
25
  X_CONFIRM_KEY,
25
26
  X_DESTRUCTIVE_KEY,
@@ -69,6 +70,7 @@ export {
69
70
  export { defineAgUiChat } from "./core/define_ag_ui_chat.js";
70
71
  export { RemoteConversationStore } from "./core/remote_conversation_store.js";
71
72
  export { RunIndex, type RunRow } from "./core/run_index.js";
73
+ export { type ToolOutcome, toolStatusFromOutcome } from "./core/tool_outcome.js";
72
74
  export {
73
75
  type TranscribeHandler,
74
76
  type TranscribeOptions,
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION: string = "0.35.1";
1
+ export const VERSION: string = "0.36.0";