@agents24/react 0.2.11 → 0.2.13

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.
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractHash": "37437aff006eec5ea404f4259bf2e8cb0ba442ecc5aa72b4d39964530899d58a",
2
+ "contractHash": "0394b689ce7604b444b11c8792050fae0c1da4233d6b318d22832c3b89516a4a",
3
3
  "contractVersion": "0.3.0",
4
4
  "corpus": "conformance/v1/corpus.json",
5
5
  "generatedBy": "@agents24/sdk-contract",
@@ -15,11 +15,11 @@
15
15
  "reactDom": ">=19 <20"
16
16
  },
17
17
  "@agents24/client": {
18
- "contract": "37437aff006eec5ea404f4259bf2e8cb0ba442ecc5aa72b4d39964530899d58a",
18
+ "contract": "0394b689ce7604b444b11c8792050fae0c1da4233d6b318d22832c3b89516a4a",
19
19
  "runtime": "run-stream.v3"
20
20
  },
21
21
  "@agents24/node": {
22
- "contract": "37437aff006eec5ea404f4259bf2e8cb0ba442ecc5aa72b4d39964530899d58a",
22
+ "contract": "0394b689ce7604b444b11c8792050fae0c1da4233d6b318d22832c3b89516a4a",
23
23
  "node": "^22.0.0 || ^24.0.0"
24
24
  },
25
25
  "@agents24/react": {
@@ -27,11 +27,11 @@
27
27
  "react": ">=19 <20"
28
28
  },
29
29
  "@talmudpedia/runtime-sdk": {
30
- "contract": "37437aff006eec5ea404f4259bf2e8cb0ba442ecc5aa72b4d39964530899d58a",
30
+ "contract": "0394b689ce7604b444b11c8792050fae0c1da4233d6b318d22832c3b89516a4a",
31
31
  "protocolOwner": "@agents24/client"
32
32
  },
33
33
  "agents24": {
34
- "contract": "37437aff006eec5ea404f4259bf2e8cb0ba442ecc5aa72b4d39964530899d58a",
34
+ "contract": "0394b689ce7604b444b11c8792050fae0c1da4233d6b318d22832c3b89516a4a",
35
35
  "python": ">=3.10"
36
36
  }
37
37
  },
package/dist/index.js CHANGED
@@ -379,7 +379,8 @@ function threadDetailToMessages(detail) {
379
379
  runId,
380
380
  content: assistantText,
381
381
  createdAt: text(turn.completed_at) || createdAt,
382
- isFinal: !["queued", "running", "paused", "cancelling"].includes(String(turn.status || "").toLowerCase()),
382
+ responseCompletion: item.response_completion,
383
+ isFinal: Boolean(item.response_completion) || !["queued", "running", "paused", "cancelling"].includes(String(turn.status || "").toLowerCase()),
383
384
  feedback,
384
385
  parts: assistantParts,
385
386
  messageIndex: typeof turn.turn_index === "number" ? turn.turn_index : index
@@ -392,12 +393,14 @@ function threadTimelineToMessages(items, detail) {
392
393
  const feedbackByRun = new Map((detail?.turns || []).flatMap((turn) => turn.run_id && turn.feedback ? [[turn.run_id, turn.feedback]] : []));
393
394
  return items.map((item) => ({
394
395
  id: item.id,
396
+ clientSubmissionId: item.client_submission_id || void 0,
395
397
  role: item.role,
396
398
  runId: item.run_id,
397
399
  segmentIndex: item.segment_index,
398
400
  content: item.text,
399
401
  createdAt: item.created_at,
400
402
  isFinal: item.status === "completed" || item.status === "delivered",
403
+ responseCompletion: item.response_completion,
401
404
  parts: item.role === "assistant" ? partsFromResponseBlocks(item.response_blocks, item.text, `${item.id}-text`) : item.text ? [{ id: `${item.id}-text`, type: "text", kind: "text", text: item.text }] : [],
402
405
  attachments: item.role === "user" ? item.attachment_ids.map((id) => {
403
406
  const descriptor = item.attachments?.find((attachment) => attachment.id === id);
@@ -602,7 +605,7 @@ function appendStableStreamingTurn(messages, userMessage, assistantMessage) {
602
605
  }
603
606
  function preserveMountedMessageIds(canonical, current) {
604
607
  return canonical.map((message) => {
605
- const mounted = current.find((item) => item.role === message.role && (item.durableId === message.id || item.id === message.id || message.role === "assistant" && message.segmentIndex === 1 && item.runId && item.runId === message.runId && !item.durableId));
608
+ const mounted = current.find((item) => item.role === message.role && (item.durableId === message.id || item.id === message.id || Boolean(message.clientSubmissionId && item.clientSubmissionId === message.clientSubmissionId) || message.role === "assistant" && message.segmentIndex === 1 && item.runId && item.runId === message.runId && !item.durableId));
606
609
  return mounted && mounted.id !== message.id ? { ...message, id: mounted.id, durableId: message.id } : message;
607
610
  });
608
611
  }
@@ -612,15 +615,15 @@ function reconcileThreadTimeline(current, canonical, activeRunId, streamingMessa
612
615
  const retained = current.filter((message) => !canonicalIds.has(message.id) && (message.isFinal || activeRunId && (!message.runId || message.runId === activeRunId)));
613
616
  const reconciled = mounted.map((message) => {
614
617
  const live = current.find((item) => item.id === message.id);
615
- return activeRunId && live?.role === "assistant" && live.runId === activeRunId && (live.id === streamingMessageId || !live.isFinal) ? { ...message, ...live, durableId: message.durableId || live.durableId } : message;
618
+ return activeRunId && live?.role === "assistant" && live.runId === activeRunId && (live.id === streamingMessageId || !live.isFinal || live.responseCompletion) ? message.responseCompletion && !live.responseCompletion ? { ...message, id: live.id, durableId: message.durableId || live.durableId } : { ...message, ...live, durableId: message.durableId || live.durableId } : message;
616
619
  });
617
620
  return [...retained, ...reconciled];
618
621
  }
619
622
  function promoteThreadMessage(current, message, runId) {
620
623
  const promoted = { ...message, runId, isFinal: true };
621
- const index = current.findIndex((item) => item.id === message.id || item.durableId === message.id);
622
- if (index < 0) return [...current, promoted];
623
- return current.map((item, itemIndex) => itemIndex === index ? { ...item, ...promoted, id: item.id, durableId: item.id === message.id ? item.durableId : message.id } : item);
624
+ const index = current.findIndex((item) => item.id === message.id || item.durableId === message.id || Boolean(message.clientSubmissionId && item.clientSubmissionId === message.clientSubmissionId));
625
+ if (index < 0) return [...current, message.clientSubmissionId ? { ...promoted, id: message.clientSubmissionId, durableId: message.id } : promoted];
626
+ return current.map((item, itemIndex) => itemIndex === index ? { ...item, ...promoted, id: item.id, durableId: item.id === message.id ? item.durableId : message.id } : message.clientSubmissionId && item.role === "assistant" && item.id === `${current[index].id}:assistant` ? { ...item, runId } : item);
624
627
  }
625
628
  function upsertStableAssistantMessage(messages, input) {
626
629
  const next = [...messages];
@@ -659,7 +662,7 @@ function reconcileModelTurnParts(parts, event) {
659
662
  const terminal = eventName === "run.completed" || eventName === "run.failed" || eventName === "run.cancelled";
660
663
  const modelTurnId = typeof payload.model_turn_id === "string" ? payload.model_turn_id : null;
661
664
  let next = parts;
662
- if (eventName === "run.snapshot" || terminal) {
665
+ if (eventName === "run.snapshot" || eventName === "assistant.completed" || terminal) {
663
666
  next = next.filter((part) => part.kind !== "model-turn");
664
667
  } else if (eventName === "assistant.delta" && typeof payload.content === "string" && payload.content) {
665
668
  next = next.filter((part) => part.kind !== "model-turn" || part.turnKind !== "agent");
@@ -677,17 +680,20 @@ function applyRuntimeEvent(state, event) {
677
680
  let optimisticIndex = -1;
678
681
  for (let index = state.messages.length - 1; index >= 0; index -= 1) {
679
682
  const message = state.messages[index];
680
- if (message.role === "user" && !message.runId && !message.durableId) {
683
+ if (message.role === "user" && (event.payload.client_submission_id ? message.clientSubmissionId === event.payload.client_submission_id : !message.runId && !message.durableId)) {
681
684
  optimisticIndex = index;
682
685
  break;
683
686
  }
684
687
  }
685
688
  if (optimisticIndex >= 0) {
686
- state = { ...state, messages: state.messages.map((message, index) => index === optimisticIndex ? { ...message, durableId: messageId2, runId: event.run_id } : message.role === "assistant" && !message.runId ? { ...message, runId: event.run_id } : message) };
689
+ const assistantId = event.payload.client_submission_id ? `${state.messages[optimisticIndex].id}:assistant` : state.streamingMessageId;
690
+ state = { ...state, streamingMessageId: assistantId, messages: state.messages.map((message, index) => index === optimisticIndex ? { ...message, durableId: messageId2, runId: event.run_id } : message.role === "assistant" && message.id === assistantId ? { ...message, runId: event.run_id } : message) };
687
691
  }
688
692
  }
689
693
  const projection = assistantProjectionFromEvent(event);
690
694
  const eventName = String(event.event || "");
695
+ const completion = eventName === "assistant.completed" ? event.payload : event.payload?.response_completion;
696
+ const responseCompleted = Boolean(completion?.completed_at);
691
697
  const runId = state.activeRunId && event.event !== "run.accepted" ? state.activeRunId : event.run_id || state.activeRunId;
692
698
  const segmentId = typeof event.payload?.segment_id === "string" ? event.payload.segment_id : null;
693
699
  const distinctSegment = Boolean(segmentId && segmentId !== `${runId}:0`);
@@ -697,6 +703,7 @@ function applyRuntimeEvent(state, event) {
697
703
  const suspension = eventName === "run.waiting" ? "waiting" : eventName === "run.interrupted" ? "interrupted" : null;
698
704
  const cancelling = eventName === "run.cancelling" || String(event.payload?.status || "") === "cancelling";
699
705
  const existing = state.messages.find((message) => message.role === "assistant" && message.id === messageId) || (!distinctSegment ? state.messages.find((message) => message.role === "assistant" && Boolean(runId && message.runId === runId)) : void 0);
706
+ const sealedAfterTerminal = responseCompleted && !state.activeRunId && ["completed", "failed", "cancelled"].includes(state.runState) && existing?.runId === runId;
700
707
  const payload = event.payload || {};
701
708
  const eventThreadId = typeof payload.thread_id === "string" && payload.thread_id.trim() ? payload.thread_id.trim() : null;
702
709
  const contextApplies = (!state.activeThreadId || !eventThreadId || eventThreadId === state.activeThreadId) && (!state.activeRunId || !runId || runId === state.activeRunId);
@@ -715,13 +722,13 @@ function applyRuntimeEvent(state, event) {
715
722
  const projectedParts = hasCanonicalResponseBlocks ? projection.parts : delta ? appendAssistantDelta(
716
723
  existing?.parts || [],
717
724
  delta,
718
- `${runId || "run"}-assistant-text-${event.seq}`
725
+ typeof payload.block_id === "string" ? payload.block_id : `${runId || "run"}-assistant-text-${event.seq}`
719
726
  ) : projection.parts.length ? upsertResponseBlockParts(existing?.parts || [], projection.parts) : existing?.parts || [];
720
727
  const lifecycleParts = reconcileModelTurnParts(projectedParts, event);
721
728
  const compressionPart = contextApplies && explicitCompression?.active ? contextCompressionPartFromEvent(event, lifecycleParts) : null;
722
729
  const parts = compressionPart ? upsertResponseBlockParts(lifecycleParts, [compressionPart]) : lifecycleParts;
723
730
  const activeThreadId = eventThreadId || state.activeThreadId;
724
- const messages = upsertStableAssistantMessage(state.messages, {
731
+ let messages = upsertStableAssistantMessage(state.messages, {
725
732
  messageId,
726
733
  runId,
727
734
  strictMessageId: distinctSegment,
@@ -731,7 +738,8 @@ function applyRuntimeEvent(state, event) {
731
738
  runId,
732
739
  content,
733
740
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
734
- isFinal: terminal,
741
+ isFinal: terminal || responseCompleted,
742
+ responseCompletion: completion,
735
743
  parts
736
744
  }),
737
745
  update: (message) => ({
@@ -739,9 +747,20 @@ function applyRuntimeEvent(state, event) {
739
747
  runId: runId ?? message.runId,
740
748
  content,
741
749
  parts,
742
- isFinal: terminal || message.isFinal
750
+ isFinal: terminal || responseCompleted || message.isFinal,
751
+ responseCompletion: completion || message.responseCompletion
743
752
  })
744
753
  });
754
+ if (responseCompleted) messages = messages.map((message) => {
755
+ if (message.role !== "assistant" || message.runId !== runId) return message;
756
+ const segment = completion?.segments.find((item) => item.message_id === message.durableId || item.message_id === message.id || message.id.endsWith(`-assistant-${item.segment_id}`)) || (!message.segmentIndex || message.segmentIndex === 1 ? completion?.segments[0] : void 0);
757
+ return {
758
+ ...message,
759
+ isFinal: true,
760
+ responseCompletion: completion,
761
+ ...segment ? { durableId: segment.message_id } : {}
762
+ };
763
+ });
745
764
  const threads = activeThreadId ? state.threads.some((thread) => thread.id === activeThreadId) ? state.threads.map((thread) => thread.id === activeThreadId ? {
746
765
  ...thread,
747
766
  messages,
@@ -763,15 +782,15 @@ function applyRuntimeEvent(state, event) {
763
782
  return {
764
783
  ...state,
765
784
  activeThreadId,
766
- activeRunId: terminal ? null : runId,
785
+ activeRunId: terminal || sealedAfterTerminal ? null : runId,
767
786
  cursor: typeof event.seq === "number" ? event.seq : state.cursor,
768
787
  messages,
769
788
  threads,
770
789
  contextWindow,
771
790
  contextCompression,
772
791
  contextCompressionRevision: hasLiveCompressionUpdate ? state.contextCompressionRevision + 1 : state.contextCompressionRevision,
773
- runState: suspension || (paused ? "paused" : cancelling ? "cancelling" : terminal ? eventName === "run.completed" ? "completed" : eventName === "run.cancelled" ? "cancelled" : "failed" : "streaming"),
774
- streamingMessageId: terminal || paused || suspension ? null : messageId
792
+ runState: sealedAfterTerminal ? state.runState : suspension || (paused ? "paused" : cancelling ? "cancelling" : terminal ? eventName === "run.completed" ? "completed" : eventName === "run.cancelled" ? "cancelled" : "failed" : "streaming"),
793
+ streamingMessageId: terminal || paused || suspension || responseCompleted || existing?.responseCompletion ? null : messageId
775
794
  };
776
795
  }
777
796
  function applyRuntimeEvents(state, events) {
@@ -808,7 +827,16 @@ function applyThreadSummaryEvent(threads, event) {
808
827
 
809
828
  // src/thread-message-queue.ts
810
829
  import * as React2 from "react";
811
- function useThreadMessageQueue({ threadId, transport, onPromoted }) {
830
+ async function retrySubmissionAdmission(admit) {
831
+ try {
832
+ return await admit();
833
+ } catch (error) {
834
+ const failure = error;
835
+ if (failure?.kind !== "network" && failure?.name !== "HttpRequestTimeoutError" && !(error instanceof TypeError)) throw error;
836
+ return admit();
837
+ }
838
+ }
839
+ function useThreadMessageQueue({ threadId, transport, onPromoted, onOptimistic, onDiscard }) {
812
840
  const [pendingMessages, setPendingMessages] = React2.useState([]);
813
841
  const [messageQueue, setMessageQueue] = React2.useState(null);
814
842
  const pendingRef = React2.useRef(/* @__PURE__ */ new Map());
@@ -821,15 +849,19 @@ function useThreadMessageQueue({ threadId, transport, onPromoted }) {
821
849
  threadRef.current = threadId;
822
850
  transportRef.current = transport;
823
851
  onPromotedRef.current = onPromoted;
852
+ const callbacksRef = React2.useRef({ onOptimistic, onDiscard });
853
+ callbacksRef.current = { onOptimistic, onDiscard };
854
+ const knownMessage = (id, submissionId) => pendingRef.current.get(id) || (submissionId ? [...pendingRef.current.values()].find((item) => item.client_submission_id === submissionId) : void 0);
824
855
  const hydrate = React2.useCallback((page, selectedThreadId) => {
825
856
  if (threadRef.current !== selectedThreadId) return;
826
857
  const pending = page.pending.filter((message) => !promotedRef.current.has(message.id)).map((message) => {
827
- const known = pendingRef.current.get(message.id);
858
+ const known = knownMessage(message.id, message.client_submission_id);
828
859
  if (message.attachments?.length || !known?.attachments?.length) return message;
829
860
  return { ...message, attachments: known.attachments.filter((attachment) => message.attachment_ids.includes(attachment.id)) };
830
861
  });
831
862
  for (const message of pending) pendingRef.current.set(message.id, message);
832
- setPendingMessages(pending);
863
+ const optimistic = [...pendingRef.current.values()].filter((message) => message.thread_id === selectedThreadId && message.revision === 0 && message.optimisticQueued && !pending.some((item) => item.client_submission_id === message.client_submission_id));
864
+ setPendingMessages([...pending, ...optimistic]);
833
865
  setMessageQueue(page.queue);
834
866
  }, []);
835
867
  const refresh = React2.useCallback(async (selectedThreadId = threadRef.current) => {
@@ -839,9 +871,10 @@ function useThreadMessageQueue({ threadId, transport, onPromoted }) {
839
871
  if (version !== versionRef.current || threadRef.current !== selectedThreadId) return null;
840
872
  for (const item of page.items) {
841
873
  if (item.role === "user" && item.status === "delivered" && item.run_id) {
842
- const pending = pendingRef.current.get(item.id);
874
+ const pending = knownMessage(item.id, item.client_submission_id);
843
875
  if (pending && !promotedRef.current.has(item.id)) {
844
876
  promotedRef.current.add(item.id);
877
+ pendingRef.current.delete(pending.id);
845
878
  pendingRef.current.delete(item.id);
846
879
  onPromotedRef.current({
847
880
  ...pending,
@@ -857,56 +890,98 @@ function useThreadMessageQueue({ threadId, transport, onPromoted }) {
857
890
  const acceptNotice = React2.useCallback((notice) => {
858
891
  if (notice.cursor && notice.cursor <= cursorRef.current) return false;
859
892
  if (notice.cursor) cursorRef.current = notice.cursor;
860
- const pending = pendingRef.current.get(notice.messageId);
861
- if (notice.status === "delivered" && notice.runId && pending && !promotedRef.current.has(pending.id)) {
862
- promotedRef.current.add(pending.id);
893
+ const pending = knownMessage(notice.messageId, notice.clientSubmissionId);
894
+ if (notice.status === "delivered" && notice.runId && pending && !promotedRef.current.has(notice.messageId)) {
895
+ promotedRef.current.add(notice.messageId);
863
896
  pendingRef.current.delete(pending.id);
864
897
  versionRef.current += 1;
865
898
  setPendingMessages((current) => current.filter((message) => message.id !== pending.id));
866
- onPromotedRef.current(pending, notice.runId);
899
+ onPromotedRef.current({ ...pending, id: notice.messageId, run_id: notice.runId, status: "delivered" }, notice.runId);
867
900
  }
868
901
  if (notice.status === "removed") pendingRef.current.delete(notice.messageId);
869
902
  void refresh().catch(() => void 0);
870
903
  return true;
871
904
  }, [refresh]);
905
+ const beginSubmission = React2.useCallback((input, queued = false) => {
906
+ const now = (/* @__PURE__ */ new Date()).toISOString();
907
+ const message = {
908
+ id: input.clientSubmissionId,
909
+ client_submission_id: input.clientSubmissionId,
910
+ thread_id: threadRef.current || "",
911
+ sequence: Number.MAX_SAFE_INTEGER,
912
+ run_id: null,
913
+ turn_id: null,
914
+ segment_index: 0,
915
+ role: "user",
916
+ status: "queued",
917
+ requested_mode: input.mode || "queue",
918
+ text: input.input,
919
+ attachment_ids: input.attachmentIds || input.attachments?.map((item) => item.id) || [],
920
+ attachments: input.attachments,
921
+ response_blocks: [],
922
+ revision: 0,
923
+ created_at: now,
924
+ updated_at: now
925
+ };
926
+ pendingRef.current.set(message.id, { ...message, optimisticQueued: queued });
927
+ versionRef.current += 1;
928
+ if (queued) setPendingMessages((current) => [...current, message]);
929
+ else callbacksRef.current.onOptimistic?.(message, Boolean(input.uploading));
930
+ return message;
931
+ }, []);
932
+ const discardSubmission = React2.useCallback((message) => {
933
+ if (!pendingRef.current.has(message.id)) return;
934
+ pendingRef.current.delete(message.id);
935
+ callbacksRef.current.onDiscard?.(message);
936
+ versionRef.current += 1;
937
+ setPendingMessages((current) => current.filter((item) => item.client_submission_id !== message.client_submission_id));
938
+ }, []);
872
939
  const submit = React2.useCallback(async (input) => {
873
940
  const selectedThreadId = threadRef.current;
874
941
  if (!selectedThreadId) throw new Error("No active Agent thread is available.");
875
- const { attachments, ...submission } = input;
876
- const result = await transportRef.current.submit({ ...submission, threadId: selectedThreadId });
877
- if (threadRef.current !== selectedThreadId) return result;
878
- versionRef.current += 1;
879
- if (!result.run_id && ["queued", "steer_pending", "blocked"].includes(result.status)) {
880
- const now = (/* @__PURE__ */ new Date()).toISOString();
881
- const pending = {
942
+ const { queued, prepare, attachments, uploading, ...submission } = input;
943
+ const optimistic = beginSubmission({
944
+ ...submission,
945
+ attachments,
946
+ uploading,
947
+ clientSubmissionId: submission.clientSubmissionId || submission.idempotencyKey
948
+ }, queued);
949
+ try {
950
+ const prepared = prepare ? await prepare() : {};
951
+ if (threadRef.current === selectedThreadId) pendingRef.current.set(optimistic.id, { ...optimistic, optimisticQueued: queued, attachment_ids: prepared.attachmentIds || optimistic.attachment_ids, attachments: prepared.attachments || attachments });
952
+ const request = { ...submission, ...prepared, clientSubmissionId: optimistic.client_submission_id, threadId: selectedThreadId };
953
+ const result = await retrySubmissionAdmission(() => transportRef.current.submit(request));
954
+ if (threadRef.current !== selectedThreadId) return result;
955
+ versionRef.current += 1;
956
+ const bound = {
957
+ ...optimistic,
882
958
  id: result.message_id,
883
- thread_id: selectedThreadId,
884
- sequence: Number.MAX_SAFE_INTEGER,
885
- run_id: null,
886
- turn_id: null,
887
- segment_index: null,
888
- role: "user",
959
+ client_submission_id: result.client_submission_id || optimistic.client_submission_id,
960
+ attachment_ids: request.attachmentIds || [],
961
+ attachments: prepared.attachments || attachments,
962
+ run_id: result.run_id,
889
963
  status: result.status,
890
- requested_mode: input.mode,
891
- text: input.input,
892
- attachment_ids: input.attachmentIds || [],
893
- attachments,
894
- response_blocks: [],
895
- revision: 1,
896
- created_at: now,
897
- updated_at: now
964
+ revision: 1
898
965
  };
899
- pendingRef.current.set(pending.id, pending);
900
- setPendingMessages((current) => current.some((message) => message.id === pending.id) ? current : [...current, pending]);
901
- setMessageQueue((current) => ({
902
- pending_count: Math.max(current?.pending_count || 0, result.queue_position || 1),
903
- paused: current?.paused || false,
904
- reason: current?.reason || null
905
- }));
966
+ pendingRef.current.delete(optimistic.id);
967
+ if (result.run_id) {
968
+ setPendingMessages((current) => current.filter((item) => item.client_submission_id !== bound.client_submission_id));
969
+ if (!promotedRef.current.has(bound.id)) {
970
+ promotedRef.current.add(bound.id);
971
+ onPromotedRef.current(bound, result.run_id);
972
+ }
973
+ } else if (!promotedRef.current.has(bound.id)) {
974
+ if (!queued) callbacksRef.current.onDiscard?.(optimistic);
975
+ pendingRef.current.set(bound.id, bound);
976
+ setPendingMessages((current) => [...current.filter((item) => item.client_submission_id !== bound.client_submission_id), bound]);
977
+ }
978
+ void refresh().catch(() => void 0);
979
+ return result;
980
+ } catch (error) {
981
+ if (threadRef.current === selectedThreadId) discardSubmission(optimistic);
982
+ throw error;
906
983
  }
907
- void refresh().catch(() => void 0);
908
- return result;
909
- }, [refresh]);
984
+ }, [beginSubmission, discardSubmission, refresh]);
910
985
  const edit = React2.useCallback(async (message, input) => {
911
986
  const updated = await transportRef.current.edit(message, input);
912
987
  versionRef.current += 1;
@@ -949,7 +1024,51 @@ function useThreadMessageQueue({ threadId, transport, onPromoted }) {
949
1024
  }, 3e3);
950
1025
  return () => clock.clearInterval(timer);
951
1026
  }, [threadId, pendingMessages.length, messageQueue?.paused, refresh]);
952
- return { pendingMessages, messageQueue, refresh, hydrate, acceptNotice, submit, edit, remove, retractForEdit, resume };
1027
+ return { pendingMessages, messageQueue, refresh, hydrate, acceptNotice, beginSubmission, discardSubmission, submit, edit, remove, retractForEdit, resume };
1028
+ }
1029
+ function optimisticSubmissionTurn(message) {
1030
+ const user = {
1031
+ id: message.client_submission_id || message.id,
1032
+ clientSubmissionId: message.client_submission_id || void 0,
1033
+ role: "user",
1034
+ content: message.text,
1035
+ createdAt: message.created_at,
1036
+ isFinal: true,
1037
+ parts: message.text ? [{ id: `${message.id}:text`, type: "text", kind: "text", text: message.text }] : [],
1038
+ attachments: message.attachments?.map((item) => ({
1039
+ id: item.id,
1040
+ filename: item.filename,
1041
+ mediaType: item.mime_type,
1042
+ byteSize: item.byte_size
1043
+ }))
1044
+ };
1045
+ return [user, {
1046
+ id: `${user.id}:assistant`,
1047
+ role: "assistant",
1048
+ content: "",
1049
+ parts: [],
1050
+ createdAt: message.created_at,
1051
+ isFinal: false
1052
+ }];
1053
+ }
1054
+ function optimisticSubmissionState(state, message, uploading) {
1055
+ const [user, assistant] = optimisticSubmissionTurn(message);
1056
+ return {
1057
+ ...state,
1058
+ messages: appendStableStreamingTurn(state.messages, user, assistant),
1059
+ streamingMessageId: assistant.id,
1060
+ runState: uploading ? "uploading" : "streaming",
1061
+ error: null
1062
+ };
1063
+ }
1064
+ function discardOptimisticSubmission(state, message) {
1065
+ const activeDraft = state.streamingMessageId === `${message.id}:assistant`;
1066
+ return {
1067
+ ...state,
1068
+ messages: state.messages.filter((item) => item.runId || item.id !== message.id && item.id !== `${message.id}:assistant`),
1069
+ streamingMessageId: activeDraft ? null : state.streamingMessageId,
1070
+ runState: activeDraft && !state.activeRunId ? "idle" : state.runState
1071
+ };
953
1072
  }
954
1073
 
955
1074
  // src/use-agent-chat.ts
@@ -1254,7 +1373,13 @@ function useAgentChat(options = {}) {
1254
1373
  });
1255
1374
  }
1256
1375
  }, [update]);
1257
- const queue = useThreadMessageQueue({ threadId: state.activeThreadId, transport: queueTransport, onPromoted: onQueuePromotion });
1376
+ const queue = useThreadMessageQueue({
1377
+ threadId: state.activeThreadId,
1378
+ transport: queueTransport,
1379
+ onPromoted: onQueuePromotion,
1380
+ onOptimistic: (message, uploading) => update((current) => optimisticSubmissionState(current, message, uploading)),
1381
+ onDiscard: (message) => update((current) => discardOptimisticSubmission(current, message))
1382
+ });
1258
1383
  const { pendingMessages, messageQueue } = queue;
1259
1384
  const eventBuffer = React6.useMemo(
1260
1385
  () => createRuntimeEventFrameBuffer(
@@ -1465,38 +1590,46 @@ function useAgentChat(options = {}) {
1465
1590
  clearOperationError();
1466
1591
  setIsSubmitting(true);
1467
1592
  try {
1468
- const uploaded = input.uploads?.length ? await client.attachments.uploadBatch({
1469
- uploads: input.uploads,
1470
- threadId: existingThreadId,
1471
- requestedAgentId: input.requestedAgentId,
1472
- idempotencyKey: runtime.createId()
1473
- }) : [];
1474
- const result = await queue.submit({
1593
+ await queue.submit({
1475
1594
  mode: "queue",
1476
1595
  input: input.text.trim(),
1477
- attachmentIds: uploaded.length ? uploaded.map((item) => item.id) : input.attachmentIds,
1596
+ attachmentIds: input.attachmentIds,
1597
+ attachments: input.attachments?.map((item, index) => ({
1598
+ id: item.id || `attachment-${index}`,
1599
+ filename: item.filename || "Attachment",
1600
+ mime_type: item.mediaType || "application/octet-stream",
1601
+ byte_size: typeof item.byteSize === "number" ? item.byteSize : 0
1602
+ })) || input.uploads?.map((item, index) => ({
1603
+ id: `upload-${index}`,
1604
+ filename: item.name,
1605
+ mime_type: item.mediaType || "application/octet-stream",
1606
+ byte_size: 0
1607
+ })),
1608
+ queued: Boolean(stateRef.current.activeRunId),
1609
+ uploading: Boolean(input.uploads?.length),
1478
1610
  requestedAgentId: input.requestedAgentId,
1479
1611
  requestedModelId: input.requestedModelId,
1480
- idempotencyKey: runtime.createId()
1612
+ clientSubmissionId: runtime.createId(),
1613
+ idempotencyKey: runtime.createId(),
1614
+ prepare: async () => {
1615
+ const uploaded = input.uploads?.length ? await client.attachments.uploadBatch({
1616
+ uploads: input.uploads,
1617
+ threadId: existingThreadId,
1618
+ requestedAgentId: input.requestedAgentId,
1619
+ idempotencyKey: runtime.createId()
1620
+ }) : [];
1621
+ if (input.uploads?.length && uploaded.length !== input.uploads.length) throw new Error("Attachment upload returned an incomplete batch");
1622
+ return {
1623
+ attachmentIds: uploaded.length ? uploaded.map((item) => item.id) : input.attachmentIds,
1624
+ attachments: uploaded.length ? uploaded.map((item) => ({
1625
+ id: item.id,
1626
+ filename: item.filename,
1627
+ mime_type: item.mime_type,
1628
+ byte_size: item.byte_size
1629
+ })) : void 0
1630
+ };
1631
+ }
1481
1632
  });
1482
- if (stateRef.current.activeThreadId !== existingThreadId) return;
1483
- if (result.run_id) {
1484
- const userMessage = {
1485
- id: result.message_id,
1486
- role: "user",
1487
- runId: result.run_id,
1488
- content: input.text.trim(),
1489
- createdAt: (/* @__PURE__ */ new Date()).toISOString(),
1490
- isFinal: true,
1491
- parts: input.text.trim() ? [{ id: `${result.message_id}-text`, type: "text", kind: "text", text: input.text.trim() }] : [],
1492
- attachments: input.attachments || []
1493
- };
1494
- update((current) => current.activeThreadId !== existingThreadId || current.messages.some((message) => message.id === userMessage.id) ? current : { ...current, messages: [...current.messages, userMessage] });
1495
- }
1496
- void refreshMessages(existingThreadId).catch(() => void 0);
1497
- if (result.run_id && promotedAttachRef.current !== result.run_id && stateRef.current.activeRunId !== result.run_id) {
1498
- void attachRunRef.current(result.run_id).catch(() => void 0);
1499
- }
1500
1633
  } catch (error) {
1501
1634
  failOperation("submit", error);
1502
1635
  throw error;
@@ -1513,28 +1646,28 @@ function useAgentChat(options = {}) {
1513
1646
  detach();
1514
1647
  const controller = runtime.createAbortController();
1515
1648
  streamControllerRef.current = controller;
1516
- const userId = runtime.createId();
1517
- const assistantId = runtime.createId();
1649
+ const ticket = queue.beginSubmission({
1650
+ input: input.text.trim(),
1651
+ clientSubmissionId: runtime.createId(),
1652
+ uploading: Boolean(input.uploads?.length),
1653
+ attachmentIds: input.attachmentIds,
1654
+ attachments: input.attachments?.map((item, index) => ({
1655
+ id: item.id || `attachment-${index}`,
1656
+ filename: item.filename || "Attachment",
1657
+ mime_type: item.mediaType || "application/octet-stream",
1658
+ byte_size: typeof item.byteSize === "number" ? item.byteSize : 0
1659
+ })) || input.uploads?.map((upload, index) => ({
1660
+ id: `upload-${index}`,
1661
+ filename: upload.name,
1662
+ mime_type: upload.mediaType || "application/octet-stream",
1663
+ byte_size: 0
1664
+ }))
1665
+ });
1666
+ const userId = ticket.id;
1518
1667
  const idempotencyKey = runtime.createId();
1519
- const optimisticAttachments = input.attachments?.length ? input.attachments : input.uploads?.map((upload, index) => ({
1520
- id: `${userId}-attachment-${index}`,
1521
- filename: upload.name,
1522
- mediaType: upload.mediaType,
1523
- status: "uploading"
1524
- }));
1525
- const userMessage = { id: userId, role: "user", content: input.text.trim(), createdAt: (/* @__PURE__ */ new Date()).toISOString(), parts: input.text.trim() ? [{ id: `${userId}-text`, type: "text", kind: "text", text: input.text.trim() }] : [], attachments: optimisticAttachments };
1526
- const assistantMessage = { id: assistantId, role: "assistant", content: "", createdAt: (/* @__PURE__ */ new Date()).toISOString(), isFinal: false, parts: [] };
1527
1668
  clearOperationError();
1528
1669
  const hasPendingUploads = Boolean(input.uploads?.length);
1529
- update((current) => ({
1530
- ...current,
1531
- messages: appendStableStreamingTurn(current.messages, userMessage, assistantMessage),
1532
- streamingMessageId: assistantId,
1533
- runState: hasPendingUploads ? "uploading" : "streaming",
1534
- error: null
1535
- }));
1536
1670
  setIsSubmitting(true);
1537
- let streamStarted = false;
1538
1671
  try {
1539
1672
  const uploaded = hasPendingUploads ? await client.attachments.uploadBatch({
1540
1673
  uploads: input.uploads || [],
@@ -1555,8 +1688,7 @@ function useAgentChat(options = {}) {
1555
1688
  runState: "streaming"
1556
1689
  }));
1557
1690
  }
1558
- streamStarted = true;
1559
- const result = await client.chat.stream({ input: input.text.trim(), threadId: stateRef.current.activeThreadId || void 0, attachmentIds, requestedModelId: input.requestedModelId, requestedAgentId: input.requestedAgentId, idempotencyKey, signal: controller.signal }, consumeEvent);
1691
+ const result = await client.chat.stream({ clientSubmissionId: ticket.client_submission_id, input: input.text.trim(), threadId: stateRef.current.activeThreadId || void 0, attachmentIds, requestedModelId: input.requestedModelId, requestedAgentId: input.requestedAgentId, idempotencyKey, signal: controller.signal }, consumeEvent);
1560
1692
  eventBuffer.flush();
1561
1693
  update((current) => {
1562
1694
  const threadId = result.threadId || current.activeThreadId;
@@ -1572,19 +1704,13 @@ function useAgentChat(options = {}) {
1572
1704
  if (result.threadId) await refreshThreads().catch(() => void 0);
1573
1705
  if (result.threadId && !result.detached) await refreshMessages(result.threadId);
1574
1706
  } catch (error) {
1575
- if (isAbortLike(error) && !streamStarted) {
1576
- update((current) => current.streamingMessageId === assistantId ? {
1577
- ...current,
1578
- messages: current.messages.filter((message) => message.id !== userId && message.id !== assistantId),
1579
- runState: "idle",
1580
- streamingMessageId: null
1581
- } : current);
1582
- } else if (!isAbortLike(error)) {
1707
+ const admitted = Boolean(stateRef.current.messages.find((message) => message.id === userId)?.runId);
1708
+ if (!admitted) queue.discardSubmission(ticket);
1709
+ if (!isAbortLike(error)) {
1583
1710
  update((current) => ({
1584
1711
  ...current,
1585
- messages: streamStarted ? current.messages : current.messages.filter((message) => message.id !== userId && message.id !== assistantId),
1586
- runState: "failed",
1587
- streamingMessageId: null
1712
+ runState: admitted ? "reconnecting" : "failed",
1713
+ streamingMessageId: admitted ? current.streamingMessageId : null
1588
1714
  }));
1589
1715
  failOperation("submit", error);
1590
1716
  throw error;
@@ -1744,7 +1870,8 @@ function useAgentChat(options = {}) {
1744
1870
  messageId: event.message_id,
1745
1871
  status: event.status,
1746
1872
  runId: event.run_id,
1747
- cursor: event.cursor
1873
+ cursor: event.cursor,
1874
+ clientSubmissionId: event.client_submission_id
1748
1875
  });
1749
1876
  if (wasQueued) void refreshMessages(event.thread_id, event.run_id || void 0).catch(() => void 0);
1750
1877
  }
@@ -2041,11 +2168,14 @@ export {
2041
2168
  contextCompressionPartFromEvent,
2042
2169
  createPortableId,
2043
2170
  createRuntimeEventFrameBuffer,
2171
+ discardOptimisticSubmission,
2044
2172
  getThreadActivity,
2045
2173
  initialAgentChatState,
2046
2174
  isAbortLike,
2047
2175
  latestThreadContext,
2048
2176
  modelTurnPartFromEvent,
2177
+ optimisticSubmissionState,
2178
+ optimisticSubmissionTurn,
2049
2179
  partsFromResponseBlocks,
2050
2180
  preferNewestThreadSummary,
2051
2181
  preserveMountedMessageIds,
@@ -2053,6 +2183,7 @@ export {
2053
2183
  reconcileRefreshedThreads,
2054
2184
  reconcileThreadTimeline,
2055
2185
  restoreAgentChatThreadSelection,
2186
+ retrySubmissionAdmission,
2056
2187
  runtimeEventFrameScheduler,
2057
2188
  threadDetailToMessages,
2058
2189
  threadTimelineToMessages,