@springbrand/chat-client 0.1.3-alpha.41 → 0.1.3-alpha.43

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": "@springbrand/chat-client",
3
- "version": "0.1.3-alpha.41",
3
+ "version": "0.1.3-alpha.43",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src",
@@ -27,7 +27,7 @@
27
27
  "@types/react-dom": "^19.2.5",
28
28
  "react-dom": "^19.2.8",
29
29
  "typescript": "^7.0.2",
30
- "@springbrand/agent-runtime": "0.2.0-alpha.53"
30
+ "@springbrand/agent-runtime": "0.2.0-alpha.56"
31
31
  },
32
32
  "scripts": {
33
33
  "typecheck": "tsc --noEmit"
@@ -34,6 +34,12 @@ type ChatMessageMetadata = Record<string, unknown> & {
34
34
  completedAt?: number;
35
35
  error?: string;
36
36
  interruptedByUser?: boolean;
37
+ // AI SDK keeps one assistant UIMessage for the live stream. This local-only
38
+ // anchor lets presentation place later steps below the steer that caused them.
39
+ optimisticSteer?: {
40
+ assistantMessageId: string;
41
+ afterStep: number;
42
+ };
37
43
  requestedCapabilities?: readonly RequestedCapability[];
38
44
  turnId?: string;
39
45
  turnStatus?: string;
@@ -369,7 +375,35 @@ export function mergeOptimisticMessages<T extends UIMessage>(
369
375
  if (optimistic.length === 0) return messages;
370
376
  const ids = new Set(messages.map(({ id }) => id));
371
377
  const pending = optimistic.filter(({ id }) => !ids.has(id));
372
- return pending.length === 0 ? messages : [...messages, ...pending];
378
+ if (pending.length === 0) return messages;
379
+ const steer = pending.find((message) =>
380
+ (message.metadata as ChatMessageMetadata | undefined)?.optimisticSteer
381
+ );
382
+ const anchor = (steer?.metadata as ChatMessageMetadata | undefined)
383
+ ?.optimisticSteer;
384
+ const assistantIndex = anchor
385
+ ? messages.findIndex(({ id, role }) =>
386
+ id === anchor.assistantMessageId && role === "assistant"
387
+ )
388
+ : -1;
389
+ if (!steer || !anchor || assistantIndex < 0) return [...messages, ...pending];
390
+
391
+ const assistant = messages[assistantIndex]!;
392
+ let step = 0;
393
+ const splitAt = assistant.parts.findIndex((part) => {
394
+ if (part.type !== "step-start") return false;
395
+ step += 1;
396
+ return step > anchor.afterStep;
397
+ });
398
+ if (splitAt < 0) return [...messages, ...pending];
399
+
400
+ return [
401
+ ...messages.slice(0, assistantIndex),
402
+ { ...assistant, id: `${assistant.id}:before:${steer.id}`, parts: assistant.parts.slice(0, splitAt) },
403
+ ...pending,
404
+ { ...assistant, id: `${assistant.id}:after:${steer.id}`, parts: assistant.parts.slice(splitAt) },
405
+ ...messages.slice(assistantIndex + 1),
406
+ ];
373
407
  }
374
408
 
375
409
  function queuedPreview(message: ChatMessage): string {
@@ -460,7 +494,7 @@ export function useUniversalAgentChat(): ChatRuntime {
460
494
  () => mergeOptimisticMessages(messages, optimisticMessages),
461
495
  [messages, optimisticMessages],
462
496
  );
463
- const authoritativeTurn = facetState?.turn;
497
+ const reportedTurn = facetState?.turn;
464
498
  const stoppedByUser = error?.message === USER_STOP_REASON;
465
499
  const latestMessage = visibleMessages.at(-1);
466
500
  const latestTurnStatus = latestMessage?.role === "assistant"
@@ -470,6 +504,18 @@ export function useUniversalAgentChat(): ChatRuntime {
470
504
  latestTurnStatus === "error" ||
471
505
  latestTurnStatus === "aborted" ||
472
506
  latestTurnStatus === "skipped";
507
+ // Messages and Agent state arrive in separate frames. A terminal message for
508
+ // the same Submission is newer than a still-active state projection; a
509
+ // different active ID belongs to the next Turn and must remain live.
510
+ const authoritativeTurn = reportedTurn &&
511
+ hasTerminalMessage &&
512
+ latestMessage?.metadata?.turnId === reportedTurn.activeSubmissionId
513
+ ? {
514
+ steerable: false,
515
+ hasPendingSteer: false,
516
+ queued: reportedTurn.queued,
517
+ }
518
+ : reportedTurn;
473
519
  const normalizedSdkStatus = sdkStatus === "error" &&
474
520
  (stoppedByUser || hasTerminalMessage)
475
521
  ? "ready"
@@ -753,6 +799,32 @@ export function useUniversalAgentChat(): ChatRuntime {
753
799
  rollback();
754
800
  return true;
755
801
  }
802
+ if (delivery === "steer" && receipt.kind === "accepted") {
803
+ const activeAssistant = messagesRef.current.findLast((candidate) =>
804
+ candidate.role === "assistant" &&
805
+ candidate.metadata?.turnId === submissionId
806
+ );
807
+ const assistantMessageId = activeAssistant?.id ??
808
+ (authoritativeTurn?.activeSubmissionId === submissionId
809
+ ? authoritativeTurn.activeMessageId
810
+ : undefined);
811
+ if (assistantMessageId) {
812
+ const afterStep = activeAssistant?.parts.filter(
813
+ ({ type }) => type === "step-start",
814
+ ).length ?? 0;
815
+ setOptimisticMessages((current) => current.map((optimistic) =>
816
+ optimistic.id === message.id
817
+ ? {
818
+ ...optimistic,
819
+ metadata: {
820
+ ...optimistic.metadata,
821
+ optimisticSteer: { assistantMessageId, afterStep },
822
+ },
823
+ }
824
+ : optimistic
825
+ ));
826
+ }
827
+ }
756
828
  if (turnTimingRef.current?.messageId === message.id) {
757
829
  turnTimingRef.current.submissionId = submissionId;
758
830
  }
@@ -785,7 +857,13 @@ export function useUniversalAgentChat(): ChatRuntime {
785
857
  );
786
858
  return false;
787
859
  }
788
- }, [chatId, clearError, dispatchMessage]);
860
+ }, [
861
+ authoritativeTurn?.activeMessageId,
862
+ authoritativeTurn?.activeSubmissionId,
863
+ chatId,
864
+ clearError,
865
+ dispatchMessage,
866
+ ]);
789
867
 
790
868
  const sendText = useCallback(async (
791
869
  text: string,