@dudousxd/nestjs-agent-react 0.4.2 → 0.5.1

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/dist/index.d.cts CHANGED
@@ -43,6 +43,14 @@ interface AgentChatTransportOptions {
43
43
  getResumeRunId?: () => string | undefined;
44
44
  /** Fires whenever a stream emits its `meta` frame. */
45
45
  onMeta?: (meta: AgentStreamMeta) => void;
46
+ /**
47
+ * Fires synchronously at the very start of `sendMessages`/`reconnectToStream`, before any network
48
+ * call — i.e. strictly before that attempt's own `onMeta` (if any) can fire. Lets a caller reset
49
+ * "which run is THIS attempt settling" state without racing React's render/effect timing: a
50
+ * consumer that also read `onMeta` to learn the current run id would otherwise risk misattributing
51
+ * an early failure (no meta ever arrives) to a PRIOR attempt's id.
52
+ */
53
+ onAttemptStart?: () => void;
46
54
  /** Injectable for tests / non-browser runtimes. Defaults to global fetch. */
47
55
  fetch?: typeof fetch;
48
56
  }
@@ -190,6 +198,66 @@ declare class AgentClient {
190
198
  */
191
199
  declare function storedMessageToUiMessage(message: StoredMessage): UIMessage;
192
200
 
201
+ /**
202
+ * Summed usage/cost across the stored-message rows merged into one turn (see
203
+ * {@link storedThreadToUiMessages}). Mirrors `MessageUsage`, but `costUsd` is always present
204
+ * (never omitted) since it's a computed aggregate, not a single provider report.
205
+ */
206
+ interface AggregatedTurnUsage {
207
+ inputTokens: number;
208
+ outputTokens: number;
209
+ /**
210
+ * `null` only when EVERY merged row reports a null/absent cost (no pricing store bound, or an
211
+ * unpriced model) — same "unknown, not a real $0" distinction `MessageUsage.costUsd` makes for a
212
+ * single row. A null cost on SOME rows (mixed with priced ones) contributes `0` to the sum
213
+ * instead of poisoning the whole turn's total to `null` — the tokens are still real and summed
214
+ * either way, so only the cost figure would be understated, never wrong in a "$0 turn" sense.
215
+ */
216
+ costUsd: number | null;
217
+ }
218
+ /** Metadata the grouped `UIMessage` carries on `.metadata.usage` — see {@link AggregatedTurnUsage}. */
219
+ interface StoredTurnMetadata {
220
+ usage: AggregatedTurnUsage;
221
+ }
222
+ /**
223
+ * Convert a thread's full `StoredMessage[]` history into `UIMessage[]`, merging consecutive
224
+ * assistant rows (no user message between them) into ONE `UIMessage` per turn.
225
+ *
226
+ * Why: the agent loop persists one row per model iteration (see `agent-loop.ts`'s per-step
227
+ * `persist:assistant:${i}`) — a turn with tool calls appends "I'll check that..." + tool calls,
228
+ * then a separate final-answer row. `storedMessageToUiMessage` maps 1:1, so a reloaded thread
229
+ * renders 2-3 assistant bubbles (each with its own cost/tokens footer) for what the LIVE stream
230
+ * renders as one continuous response (one `UIMessage`, stepped internally — see
231
+ * `AgentChatTransport.toChunkStream`'s `start-step`/`finish-step` bracketing). This closes that gap
232
+ * for replayed history.
233
+ *
234
+ * Merge shape: each row is mapped via `storedMessageToUiMessage` (unchanged — same text-then-tool
235
+ * part ordering per row) and the parts are concatenated in row order, so the grouped message's parts
236
+ * read as `[row1 text?, row1 tools?, row2 text?, row2 tools?, ...]` — exactly the sequence the live
237
+ * transport streams for the same turn. The group's `id` is the LAST row's id (the final-answer row —
238
+ * it's the one carrying `followUps`, and what a host keys turn-level actions like "fork" or
239
+ * "regenerate" on). Non-assistant rows (user/system) pass through `storedMessageToUiMessage`
240
+ * unchanged, one row → one message, same as today.
241
+ *
242
+ * A lone assistant row between two user rows (the common case: no tool calls, or already a single
243
+ * iteration) has nothing to merge with, so it comes back byte-for-byte identical to calling
244
+ * `storedMessageToUiMessage` directly — no `metadata` is added for that case either, preserving
245
+ * exact backward compatibility for hosts that read stored usage some other way.
246
+ *
247
+ * Only an ACTUAL merge (2+ rows) attaches `metadata.usage` — the {@link AggregatedTurnUsage} summed
248
+ * across the merged rows' `usage` (rows without a `usage` at all, e.g. an older/test fixture, are
249
+ * skipped rather than treated as zero-and-priced).
250
+ *
251
+ * Deliberately NOT reproduced: the live transport's `{type: 'step-start'}` UI parts (one per model
252
+ * call, emitted by the AI SDK for every `start-step` chunk — see `repro-real-frames.spec.ts` for a
253
+ * captured wire dump). Those are structural brackets `MessageItem`'s `renderParts` never renders
254
+ * (it only special-cases text and tool parts), and `storedMessageToUiMessage` has never emitted them
255
+ * for a single row either — inventing them here would make a merged turn diverge from a same-shaped
256
+ * unmerged one instead of converging on it. "Indistinguishable from the live stream" is scoped to
257
+ * what actually renders: the text/tool-call content in step order, not the step brackets around it.
258
+ */
259
+ declare function storedThreadToUiMessages(messages: StoredMessage[]): UIMessage[];
260
+
193
261
  interface UseAgentChatOptions {
194
262
  /** Origin + base path, e.g. `https://api.example.com`. Defaults to `''`. */
195
263
  baseUrl?: string;
@@ -237,6 +305,27 @@ interface UseAgentChatOptions {
237
305
  * even before the consumer navigates.
238
306
  */
239
307
  onThreadCreated?: (threadId: string) => void;
308
+ /**
309
+ * Fired exactly once per run when its stream settles — normally (`'completed'`) or via a thrown
310
+ * failure / the backend's `event: error` frame (`'failed'`) — including a RESUMED stream's own
311
+ * completion. By the time this fires, the server has already derived and persisted the thread's
312
+ * title (and the run's terminal state), so it's the signal `onFinish` doesn't give you: `onFinish`
313
+ * only says "a turn rendered," never "the server is done writing" — without this, a host has no
314
+ * reason to refetch the thread/list queries, and the header/sidebar title is stuck on "Untitled".
315
+ *
316
+ * Skipped when the attempt never got far enough to learn a run id (e.g. the initial POST itself
317
+ * failed before any response headers/meta frame arrived) — nothing was created server-side, so
318
+ * there's nothing to refetch.
319
+ *
320
+ * A user-initiated `cancel()`/`stop()` reports `'completed'` here (the AI SDK's own abort signal
321
+ * doesn't set its `isError` flag) — the server-side cancel already recorded its own terminal run
322
+ * state; this callback only reflects whether the CLIENT's stream loop exited with or without an
323
+ * error, not why it stopped.
324
+ */
325
+ onRunSettled?: (outcome: {
326
+ runId: string;
327
+ status: 'completed' | 'failed';
328
+ }) => void;
240
329
  }
241
330
  interface AddToolResultArgs {
242
331
  tool: string;
@@ -490,4 +579,4 @@ interface SpeechRecognitionHook {
490
579
  */
491
580
  declare function useSpeechRecognition({ lang, onFinalTranscript, onInterimTranscript, }: UseSpeechRecognitionOptions): SpeechRecognitionHook;
492
581
 
493
- export { AgentChatTransport, type AgentChatTransportOptions, AgentClient, type AgentClientOptions, AgentHttpError, type AgentStreamMeta, type AnyToolUIPart, type CancelResult, ChatInput, type ChatInputProps, type ChatStatus, DEFAULT_SPEECH_LANG, MessageItem, type MessageItemClassNames, type MessageItemProps, MessageList, type MessageListClassNames, type MessageListProps, type MessageUsageInfo, type QuotaToday, type RenderTextFn, type RenderToolGroupFn, type RenderToolPartFn, SPEECH_LANGUAGES, type SpeechLanguage, type SpeechRecognitionHook, type ThreadPatch, type UseAgentChatOptions, type UseSpeechRecognitionOptions, formatRelativeTime, loadStoredSpeechLang, persistSpeechLang, storedMessageToUiMessage, useAgentChat, useSpeechRecognition };
582
+ export { AgentChatTransport, type AgentChatTransportOptions, AgentClient, type AgentClientOptions, AgentHttpError, type AgentStreamMeta, type AggregatedTurnUsage, type AnyToolUIPart, type CancelResult, ChatInput, type ChatInputProps, type ChatStatus, DEFAULT_SPEECH_LANG, MessageItem, type MessageItemClassNames, type MessageItemProps, MessageList, type MessageListClassNames, type MessageListProps, type MessageUsageInfo, type QuotaToday, type RenderTextFn, type RenderToolGroupFn, type RenderToolPartFn, SPEECH_LANGUAGES, type SpeechLanguage, type SpeechRecognitionHook, type StoredTurnMetadata, type ThreadPatch, type UseAgentChatOptions, type UseSpeechRecognitionOptions, formatRelativeTime, loadStoredSpeechLang, persistSpeechLang, storedMessageToUiMessage, storedThreadToUiMessages, useAgentChat, useSpeechRecognition };
package/dist/index.d.ts CHANGED
@@ -43,6 +43,14 @@ interface AgentChatTransportOptions {
43
43
  getResumeRunId?: () => string | undefined;
44
44
  /** Fires whenever a stream emits its `meta` frame. */
45
45
  onMeta?: (meta: AgentStreamMeta) => void;
46
+ /**
47
+ * Fires synchronously at the very start of `sendMessages`/`reconnectToStream`, before any network
48
+ * call — i.e. strictly before that attempt's own `onMeta` (if any) can fire. Lets a caller reset
49
+ * "which run is THIS attempt settling" state without racing React's render/effect timing: a
50
+ * consumer that also read `onMeta` to learn the current run id would otherwise risk misattributing
51
+ * an early failure (no meta ever arrives) to a PRIOR attempt's id.
52
+ */
53
+ onAttemptStart?: () => void;
46
54
  /** Injectable for tests / non-browser runtimes. Defaults to global fetch. */
47
55
  fetch?: typeof fetch;
48
56
  }
@@ -190,6 +198,66 @@ declare class AgentClient {
190
198
  */
191
199
  declare function storedMessageToUiMessage(message: StoredMessage): UIMessage;
192
200
 
201
+ /**
202
+ * Summed usage/cost across the stored-message rows merged into one turn (see
203
+ * {@link storedThreadToUiMessages}). Mirrors `MessageUsage`, but `costUsd` is always present
204
+ * (never omitted) since it's a computed aggregate, not a single provider report.
205
+ */
206
+ interface AggregatedTurnUsage {
207
+ inputTokens: number;
208
+ outputTokens: number;
209
+ /**
210
+ * `null` only when EVERY merged row reports a null/absent cost (no pricing store bound, or an
211
+ * unpriced model) — same "unknown, not a real $0" distinction `MessageUsage.costUsd` makes for a
212
+ * single row. A null cost on SOME rows (mixed with priced ones) contributes `0` to the sum
213
+ * instead of poisoning the whole turn's total to `null` — the tokens are still real and summed
214
+ * either way, so only the cost figure would be understated, never wrong in a "$0 turn" sense.
215
+ */
216
+ costUsd: number | null;
217
+ }
218
+ /** Metadata the grouped `UIMessage` carries on `.metadata.usage` — see {@link AggregatedTurnUsage}. */
219
+ interface StoredTurnMetadata {
220
+ usage: AggregatedTurnUsage;
221
+ }
222
+ /**
223
+ * Convert a thread's full `StoredMessage[]` history into `UIMessage[]`, merging consecutive
224
+ * assistant rows (no user message between them) into ONE `UIMessage` per turn.
225
+ *
226
+ * Why: the agent loop persists one row per model iteration (see `agent-loop.ts`'s per-step
227
+ * `persist:assistant:${i}`) — a turn with tool calls appends "I'll check that..." + tool calls,
228
+ * then a separate final-answer row. `storedMessageToUiMessage` maps 1:1, so a reloaded thread
229
+ * renders 2-3 assistant bubbles (each with its own cost/tokens footer) for what the LIVE stream
230
+ * renders as one continuous response (one `UIMessage`, stepped internally — see
231
+ * `AgentChatTransport.toChunkStream`'s `start-step`/`finish-step` bracketing). This closes that gap
232
+ * for replayed history.
233
+ *
234
+ * Merge shape: each row is mapped via `storedMessageToUiMessage` (unchanged — same text-then-tool
235
+ * part ordering per row) and the parts are concatenated in row order, so the grouped message's parts
236
+ * read as `[row1 text?, row1 tools?, row2 text?, row2 tools?, ...]` — exactly the sequence the live
237
+ * transport streams for the same turn. The group's `id` is the LAST row's id (the final-answer row —
238
+ * it's the one carrying `followUps`, and what a host keys turn-level actions like "fork" or
239
+ * "regenerate" on). Non-assistant rows (user/system) pass through `storedMessageToUiMessage`
240
+ * unchanged, one row → one message, same as today.
241
+ *
242
+ * A lone assistant row between two user rows (the common case: no tool calls, or already a single
243
+ * iteration) has nothing to merge with, so it comes back byte-for-byte identical to calling
244
+ * `storedMessageToUiMessage` directly — no `metadata` is added for that case either, preserving
245
+ * exact backward compatibility for hosts that read stored usage some other way.
246
+ *
247
+ * Only an ACTUAL merge (2+ rows) attaches `metadata.usage` — the {@link AggregatedTurnUsage} summed
248
+ * across the merged rows' `usage` (rows without a `usage` at all, e.g. an older/test fixture, are
249
+ * skipped rather than treated as zero-and-priced).
250
+ *
251
+ * Deliberately NOT reproduced: the live transport's `{type: 'step-start'}` UI parts (one per model
252
+ * call, emitted by the AI SDK for every `start-step` chunk — see `repro-real-frames.spec.ts` for a
253
+ * captured wire dump). Those are structural brackets `MessageItem`'s `renderParts` never renders
254
+ * (it only special-cases text and tool parts), and `storedMessageToUiMessage` has never emitted them
255
+ * for a single row either — inventing them here would make a merged turn diverge from a same-shaped
256
+ * unmerged one instead of converging on it. "Indistinguishable from the live stream" is scoped to
257
+ * what actually renders: the text/tool-call content in step order, not the step brackets around it.
258
+ */
259
+ declare function storedThreadToUiMessages(messages: StoredMessage[]): UIMessage[];
260
+
193
261
  interface UseAgentChatOptions {
194
262
  /** Origin + base path, e.g. `https://api.example.com`. Defaults to `''`. */
195
263
  baseUrl?: string;
@@ -237,6 +305,27 @@ interface UseAgentChatOptions {
237
305
  * even before the consumer navigates.
238
306
  */
239
307
  onThreadCreated?: (threadId: string) => void;
308
+ /**
309
+ * Fired exactly once per run when its stream settles — normally (`'completed'`) or via a thrown
310
+ * failure / the backend's `event: error` frame (`'failed'`) — including a RESUMED stream's own
311
+ * completion. By the time this fires, the server has already derived and persisted the thread's
312
+ * title (and the run's terminal state), so it's the signal `onFinish` doesn't give you: `onFinish`
313
+ * only says "a turn rendered," never "the server is done writing" — without this, a host has no
314
+ * reason to refetch the thread/list queries, and the header/sidebar title is stuck on "Untitled".
315
+ *
316
+ * Skipped when the attempt never got far enough to learn a run id (e.g. the initial POST itself
317
+ * failed before any response headers/meta frame arrived) — nothing was created server-side, so
318
+ * there's nothing to refetch.
319
+ *
320
+ * A user-initiated `cancel()`/`stop()` reports `'completed'` here (the AI SDK's own abort signal
321
+ * doesn't set its `isError` flag) — the server-side cancel already recorded its own terminal run
322
+ * state; this callback only reflects whether the CLIENT's stream loop exited with or without an
323
+ * error, not why it stopped.
324
+ */
325
+ onRunSettled?: (outcome: {
326
+ runId: string;
327
+ status: 'completed' | 'failed';
328
+ }) => void;
240
329
  }
241
330
  interface AddToolResultArgs {
242
331
  tool: string;
@@ -490,4 +579,4 @@ interface SpeechRecognitionHook {
490
579
  */
491
580
  declare function useSpeechRecognition({ lang, onFinalTranscript, onInterimTranscript, }: UseSpeechRecognitionOptions): SpeechRecognitionHook;
492
581
 
493
- export { AgentChatTransport, type AgentChatTransportOptions, AgentClient, type AgentClientOptions, AgentHttpError, type AgentStreamMeta, type AnyToolUIPart, type CancelResult, ChatInput, type ChatInputProps, type ChatStatus, DEFAULT_SPEECH_LANG, MessageItem, type MessageItemClassNames, type MessageItemProps, MessageList, type MessageListClassNames, type MessageListProps, type MessageUsageInfo, type QuotaToday, type RenderTextFn, type RenderToolGroupFn, type RenderToolPartFn, SPEECH_LANGUAGES, type SpeechLanguage, type SpeechRecognitionHook, type ThreadPatch, type UseAgentChatOptions, type UseSpeechRecognitionOptions, formatRelativeTime, loadStoredSpeechLang, persistSpeechLang, storedMessageToUiMessage, useAgentChat, useSpeechRecognition };
582
+ export { AgentChatTransport, type AgentChatTransportOptions, AgentClient, type AgentClientOptions, AgentHttpError, type AgentStreamMeta, type AggregatedTurnUsage, type AnyToolUIPart, type CancelResult, ChatInput, type ChatInputProps, type ChatStatus, DEFAULT_SPEECH_LANG, MessageItem, type MessageItemClassNames, type MessageItemProps, MessageList, type MessageListClassNames, type MessageListProps, type MessageUsageInfo, type QuotaToday, type RenderTextFn, type RenderToolGroupFn, type RenderToolPartFn, SPEECH_LANGUAGES, type SpeechLanguage, type SpeechRecognitionHook, type StoredTurnMetadata, type ThreadPatch, type UseAgentChatOptions, type UseSpeechRecognitionOptions, formatRelativeTime, loadStoredSpeechLang, persistSpeechLang, storedMessageToUiMessage, storedThreadToUiMessages, useAgentChat, useSpeechRecognition };
package/dist/index.js CHANGED
@@ -23,6 +23,7 @@ var AgentChatTransport = class {
23
23
  return this.currentThreadId;
24
24
  }
25
25
  async sendMessages(options) {
26
+ this.options.onAttemptStart?.();
26
27
  const lastMessage = options.messages.at(-1);
27
28
  const message = lastMessage ? extractText(lastMessage) : "";
28
29
  const body = {
@@ -55,6 +56,7 @@ var AgentChatTransport = class {
55
56
  return this.toChunkStream(response.body);
56
57
  }
57
58
  async reconnectToStream(options) {
59
+ this.options.onAttemptStart?.();
58
60
  const runId = this.options.getResumeRunId?.();
59
61
  if (runId === void 0) return null;
60
62
  const response = await this.fetchImpl()(`${this.baseUrl()}/agent/chat/${encodeURIComponent(runId)}/stream`, {
@@ -564,6 +566,59 @@ function storedMessageToUiMessage(message) {
564
566
  }
565
567
  __name(storedMessageToUiMessage, "storedMessageToUiMessage");
566
568
 
569
+ // src/stored-thread-to-ui-messages.ts
570
+ function storedThreadToUiMessages(messages) {
571
+ const turns = [];
572
+ for (const message of messages) {
573
+ const currentTurn = turns.at(-1);
574
+ const previousRow = currentTurn?.at(-1);
575
+ if (message.role === "assistant" && previousRow?.role === "assistant" && currentTurn) {
576
+ currentTurn.push(message);
577
+ } else {
578
+ turns.push([
579
+ message
580
+ ]);
581
+ }
582
+ }
583
+ return turns.map(mergeTurn);
584
+ }
585
+ __name(storedThreadToUiMessages, "storedThreadToUiMessages");
586
+ function mergeTurn(rows) {
587
+ const lastRow = rows.at(-1);
588
+ if (lastRow === void 0) {
589
+ throw new Error("storedThreadToUiMessages: encountered an empty turn");
590
+ }
591
+ if (rows.length === 1) {
592
+ return storedMessageToUiMessage(lastRow);
593
+ }
594
+ const parts = rows.flatMap((row) => storedMessageToUiMessage(row).parts);
595
+ const usage = sumUsage(rows);
596
+ return {
597
+ id: lastRow.id,
598
+ role: lastRow.role,
599
+ parts,
600
+ ...usage !== void 0 ? {
601
+ metadata: {
602
+ usage
603
+ }
604
+ } : {}
605
+ };
606
+ }
607
+ __name(mergeTurn, "mergeTurn");
608
+ function sumUsage(rows) {
609
+ const usages = rows.map((row) => row.usage).filter((usage) => usage !== void 0);
610
+ if (usages.length === 0) {
611
+ return void 0;
612
+ }
613
+ const allCostsUnknown = usages.every((usage) => usage.costUsd === null || usage.costUsd === void 0);
614
+ return {
615
+ inputTokens: usages.reduce((sum, usage) => sum + usage.inputTokens, 0),
616
+ outputTokens: usages.reduce((sum, usage) => sum + usage.outputTokens, 0),
617
+ costUsd: allCostsUnknown ? null : usages.reduce((sum, usage) => sum + (usage.costUsd ?? 0), 0)
618
+ };
619
+ }
620
+ __name(sumUsage, "sumUsage");
621
+
567
622
  // src/use-agent-chat.ts
568
623
  import { useChat } from "@ai-sdk/react";
569
624
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -573,6 +628,7 @@ function useAgentChat(options) {
573
628
  const [runId, setRunId] = useState(options.resumeRunId);
574
629
  const runIdRef = useRef(runId);
575
630
  runIdRef.current = runId;
631
+ const settlingRunIdRef = useRef(void 0);
576
632
  const autoResumeRunIdRef = useRef(void 0);
577
633
  const [autoResume, setAutoResume] = useState(false);
578
634
  const [activeRunId, setActiveRunId] = useState(null);
@@ -615,6 +671,7 @@ function useAgentChat(options) {
615
671
  ]);
616
672
  const transport = useMemo(() => {
617
673
  function onMeta(meta) {
674
+ settlingRunIdRef.current = meta.runId;
618
675
  setRunId(meta.runId);
619
676
  if (latest.current.threadId === void 0 && meta.threadId && createdThreadId.current !== meta.threadId) {
620
677
  createdThreadId.current = meta.threadId;
@@ -655,7 +712,10 @@ function useAgentChat(options) {
655
712
  };
656
713
  }, "getBody"),
657
714
  getResumeRunId: /* @__PURE__ */ __name(() => latest.current.resumeRunId ?? autoResumeRunIdRef.current, "getResumeRunId"),
658
- onMeta
715
+ onMeta,
716
+ onAttemptStart: /* @__PURE__ */ __name(() => {
717
+ settlingRunIdRef.current = void 0;
718
+ }, "onAttemptStart")
659
719
  });
660
720
  }, []);
661
721
  const chat = useChat({
@@ -667,8 +727,15 @@ function useAgentChat(options) {
667
727
  ...options.initialMessages !== void 0 ? {
668
728
  messages: options.initialMessages
669
729
  } : {},
670
- onFinish: /* @__PURE__ */ __name(() => {
730
+ onFinish: /* @__PURE__ */ __name(({ isError }) => {
671
731
  latest.current.onFinish?.();
732
+ const settledRunId = settlingRunIdRef.current;
733
+ if (settledRunId !== void 0) {
734
+ latest.current.onRunSettled?.({
735
+ runId: settledRunId,
736
+ status: isError ? "failed" : "completed"
737
+ });
738
+ }
672
739
  }, "onFinish")
673
740
  });
674
741
  const chatRef = useRef(chat);
@@ -1373,6 +1440,7 @@ export {
1373
1440
  loadStoredSpeechLang,
1374
1441
  persistSpeechLang,
1375
1442
  storedMessageToUiMessage,
1443
+ storedThreadToUiMessages,
1376
1444
  useAgentChat,
1377
1445
  useSpeechRecognition
1378
1446
  };