@agent-native/toolkit 0.209.1-nightly-20261015153252 → 0.209.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.
Files changed (68) hide show
  1. package/dist/app/agentkit/react/styles.css +1 -1
  2. package/dist/app/chat/AgentKitAssistantChat.d.ts +1 -0
  3. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  4. package/dist/app/chat/AgentKitAssistantChat.js +96 -1
  5. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  6. package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
  7. package/dist/app/chat/MultiTabAssistantChat.js +21 -0
  8. package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
  9. package/dist/app/chat/chat/surface-types.d.ts +3 -1
  10. package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
  11. package/dist/app/chat/chat/surface-types.js.map +1 -1
  12. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  13. package/dist/app/i18n/catalogs/ar-SA.js +2 -0
  14. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  15. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  16. package/dist/app/i18n/catalogs/de-DE.js +2 -0
  17. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  18. package/dist/app/i18n/catalogs/en-US.d.ts +2 -0
  19. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  20. package/dist/app/i18n/catalogs/en-US.js +2 -0
  21. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  22. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  23. package/dist/app/i18n/catalogs/es-ES.js +2 -0
  24. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  25. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  26. package/dist/app/i18n/catalogs/fr-FR.js +2 -0
  27. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  28. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  29. package/dist/app/i18n/catalogs/hi-IN.js +2 -0
  30. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  31. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  32. package/dist/app/i18n/catalogs/ja-JP.js +2 -0
  33. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  34. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  35. package/dist/app/i18n/catalogs/ko-KR.js +2 -0
  36. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  37. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  38. package/dist/app/i18n/catalogs/pt-BR.js +2 -0
  39. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  40. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  41. package/dist/app/i18n/catalogs/zh-CN.js +2 -0
  42. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  43. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  44. package/dist/app/i18n/catalogs/zh-TW.js +2 -0
  45. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  46. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  47. package/dist/composer/TiptapComposer.js +2 -4
  48. package/dist/composer/TiptapComposer.js.map +1 -1
  49. package/package.json +4 -4
  50. package/src/app/agentkit/react/styles.css +1 -1
  51. package/src/app/chat/AgentKitAssistantChat.spec.tsx +137 -4
  52. package/src/app/chat/AgentKitAssistantChat.tsx +168 -0
  53. package/src/app/chat/MultiTabAssistantChat.spec.tsx +53 -0
  54. package/src/app/chat/MultiTabAssistantChat.tsx +36 -1
  55. package/src/app/chat/chat/surface-types.ts +6 -1
  56. package/src/app/i18n/catalogs/ar-SA.ts +4 -0
  57. package/src/app/i18n/catalogs/de-DE.ts +4 -0
  58. package/src/app/i18n/catalogs/en-US.ts +4 -0
  59. package/src/app/i18n/catalogs/es-ES.ts +4 -0
  60. package/src/app/i18n/catalogs/fr-FR.ts +4 -0
  61. package/src/app/i18n/catalogs/hi-IN.ts +4 -0
  62. package/src/app/i18n/catalogs/ja-JP.ts +4 -0
  63. package/src/app/i18n/catalogs/ko-KR.ts +4 -0
  64. package/src/app/i18n/catalogs/pt-BR.ts +4 -0
  65. package/src/app/i18n/catalogs/zh-CN.ts +3 -0
  66. package/src/app/i18n/catalogs/zh-TW.ts +3 -0
  67. package/src/composer/TiptapComposer.spec.ts +11 -12
  68. package/src/composer/TiptapComposer.tsx +2 -4
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.209.1-nightly-20261015153252",
3
+ "version": "0.209.1",
4
4
  "description": "Reusable app-building UI and helpers for Agent-Native apps.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -678,7 +678,7 @@
678
678
  "y-protocols": "^1.0.7",
679
679
  "yjs": "^13.6.33",
680
680
  "zod": "^4.3.6",
681
- "@agent-native/agentkit": "^0.209.1-nightly-20261015153252"
681
+ "@agent-native/agentkit": "^0.209.1"
682
682
  },
683
683
  "devDependencies": {
684
684
  "@excalidraw/excalidraw": "0.18.1",
@@ -702,10 +702,10 @@
702
702
  "typescript-7": "npm:typescript@^7.0.2",
703
703
  "vite": "8.1.0",
704
704
  "vitest": "^4.1.5",
705
- "@agent-native/core": "^0.209.1-nightly-20261015153252"
705
+ "@agent-native/core": "^0.209.1"
706
706
  },
707
707
  "peerDependencies": {
708
- "@agent-native/core": "0.209.1-nightly-20261015153252",
708
+ "@agent-native/core": ">=0.205.1",
709
709
  "@excalidraw/excalidraw": ">=0.18",
710
710
  "@excalidraw/mermaid-to-excalidraw": ">=2",
711
711
  "@tanstack/react-query": "^5.99.2",
@@ -1974,7 +1974,7 @@ button.agentkit-object {
1974
1974
  }
1975
1975
 
1976
1976
  .agentkit-host-suggestions-slot {
1977
- block-size: 2rem;
1977
+ block-size: 2.5rem;
1978
1978
  min-inline-size: 0;
1979
1979
  }
1980
1980
 
@@ -4,7 +4,10 @@ import {
4
4
  AgentKitClient,
5
5
  AgentKitUploadError,
6
6
  } from "@agent-native/agentkit/client";
7
- import { MAX_AGENT_REQUEST_ATTACHMENT_DATA_CHARS } from "@agent-native/agentkit/protocol";
7
+ import {
8
+ AGENT_NATIVE_SUBMISSION_EXPIRES_AT_METADATA_KEY,
9
+ MAX_AGENT_REQUEST_ATTACHMENT_DATA_CHARS,
10
+ } from "@agent-native/agentkit/protocol";
8
11
  import type {
9
12
  AgentInteraction,
10
13
  FilePart,
@@ -4597,6 +4600,7 @@ describe("AgentKitAssistantChat host behavior", () => {
4597
4600
 
4598
4601
  it("uploads inline images before storing a deferred submission", async () => {
4599
4602
  chatMocks.history = { isRestoring: true };
4603
+ const submissionExpiresAt = Date.now() + 20_000;
4600
4604
  chatMocks.control.uploadFiles.mockResolvedValueOnce([
4601
4605
  {
4602
4606
  type: "file",
@@ -4609,18 +4613,112 @@ describe("AgentKitAssistantChat host behavior", () => {
4609
4613
  await mount(baseProps(), ref);
4610
4614
 
4611
4615
  await act(async () => {
4612
- await ref.current?.sendMessage("Use this image", [
4613
- "data:image/png;base64,aGVsbG8=",
4614
- ]);
4616
+ await ref.current?.sendMessage(
4617
+ "Use this image",
4618
+ ["data:image/png;base64,aGVsbG8="],
4619
+ { submissionExpiresAt },
4620
+ );
4615
4621
  });
4616
4622
 
4617
4623
  const stored = JSON.stringify([...chatMocks.appState.values()]);
4618
4624
  expect(chatMocks.control.uploadFiles).toHaveBeenCalledOnce();
4619
4625
  expect(stored).toContain("https://files.example.test/image.png");
4626
+ expect(stored).toContain(String(submissionExpiresAt));
4620
4627
  expect(stored).not.toContain("base64,");
4621
4628
  expect(stored).not.toContain("data:image");
4622
4629
  });
4623
4630
 
4631
+ it("does not report a deferred retry as submitted if saving passes its deadline", async () => {
4632
+ chatMocks.history = { isRestoring: true };
4633
+ const threadId = chatMocks.threadId;
4634
+ const encodedThreadId = Array.from(threadId, (character) =>
4635
+ character.codePointAt(0)!.toString(16),
4636
+ ).join("-");
4637
+ const stateKey = `agentkit-deferred-provider-submissions:${encodedThreadId}`;
4638
+ const now = vi.spyOn(Date, "now").mockReturnValue(1_000);
4639
+ const compareAndSet = vi.mocked(compareAndSetClientAppState);
4640
+ const originalCompareAndSet = compareAndSet.getMockImplementation();
4641
+ if (!originalCompareAndSet) {
4642
+ throw new Error("Expected the app-state compare-and-set mock.");
4643
+ }
4644
+ let advancedDeadline = false;
4645
+ compareAndSet.mockImplementation(async (...args) => {
4646
+ const saved = await originalCompareAndSet(...args);
4647
+ if (args[0] === stateKey && args[2] !== null && !advancedDeadline) {
4648
+ advancedDeadline = true;
4649
+ now.mockReturnValue(1_001);
4650
+ }
4651
+ return saved;
4652
+ });
4653
+ const results: CustomEvent[] = [];
4654
+ const listener = (event: Event) => results.push(event as CustomEvent);
4655
+ window.addEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, listener);
4656
+ const ref = createRef<AssistantChatHandle>();
4657
+
4658
+ try {
4659
+ await mount(baseProps(), ref);
4660
+ await expect(
4661
+ ref.current?.sendMessage("Retry the same deck prompt", undefined, {
4662
+ submissionExpiresAt: 1_001,
4663
+ submitMessageId: "deadline-during-save",
4664
+ }),
4665
+ ).resolves.toEqual({
4666
+ status: "rejected",
4667
+ reason: "submission-expired",
4668
+ });
4669
+ expect(chatMocks.appState.has(stateKey)).toBe(false);
4670
+ expect(chatMocks.control.sendMessage).not.toHaveBeenCalled();
4671
+ expect(results.map((event) => event.detail)).toContainEqual({
4672
+ submitMessageId: "deadline-during-save",
4673
+ delivered: false,
4674
+ reason: "submission-expired",
4675
+ });
4676
+ } finally {
4677
+ window.removeEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, listener);
4678
+ compareAndSet.mockImplementation(originalCompareAndSet);
4679
+ now.mockRestore();
4680
+ }
4681
+ });
4682
+
4683
+ it("removes an expired deferred submission without dispatching it", async () => {
4684
+ const threadId = chatMocks.threadId;
4685
+ const encodedThreadId = Array.from(threadId, (character) =>
4686
+ character.codePointAt(0)!.toString(16),
4687
+ ).join("-");
4688
+ const stateKey = `agentkit-deferred-provider-submissions:${encodedThreadId}`;
4689
+ chatMocks.appState.set(stateKey, {
4690
+ version: 1,
4691
+ threadId,
4692
+ submissions: [
4693
+ {
4694
+ id: "expired-deferred-submit",
4695
+ threadId,
4696
+ text: "Create a deck",
4697
+ submissionExpiresAt: Date.now() - 1,
4698
+ fileParts: [],
4699
+ references: [],
4700
+ composerOptions: {},
4701
+ options: {},
4702
+ },
4703
+ ],
4704
+ });
4705
+ const results: CustomEvent[] = [];
4706
+ const listener = (event: Event) => results.push(event as CustomEvent);
4707
+ window.addEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, listener);
4708
+
4709
+ await mount(baseProps());
4710
+ await flush();
4711
+ window.removeEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, listener);
4712
+
4713
+ expect(chatMocks.control.sendMessage).not.toHaveBeenCalled();
4714
+ expect(chatMocks.appState.has(stateKey)).toBe(false);
4715
+ expect(results.map((event) => event.detail)).toContainEqual({
4716
+ submitMessageId: "expired-deferred-submit",
4717
+ delivered: false,
4718
+ reason: "submission-expired",
4719
+ });
4720
+ });
4721
+
4624
4722
  it("rechecks only submission scope after upload and reports its reason", async () => {
4625
4723
  const uploading = Promise.withResolvers<FilePart[]>();
4626
4724
  chatMocks.control.uploadFiles.mockReturnValueOnce(uploading.promise);
@@ -6042,6 +6140,41 @@ describe("AgentKitAssistantChat host behavior", () => {
6042
6140
  expect(chatMocks.control.queueMessage).not.toHaveBeenCalled();
6043
6141
  });
6044
6142
 
6143
+ it("keeps a deadline-bound retry out of the queue and forwards its expiry", async () => {
6144
+ chatMocks.thread.activeRunIds = ["run-1"];
6145
+ const ref = createRef<AssistantChatHandle>();
6146
+ container = document.createElement("div");
6147
+ document.body.appendChild(container);
6148
+ root = createRoot(container);
6149
+ await act(async () => {
6150
+ root.render(<AgentKitAssistantChat ref={ref} {...baseProps()} />);
6151
+ });
6152
+ const submissionExpiresAt = Date.now() + 20_000;
6153
+
6154
+ await act(async () => {
6155
+ await expect(
6156
+ ref.current?.sendMessage("Retry the same deck prompt", undefined, {
6157
+ submissionExpiresAt,
6158
+ submitMessageId: "slides-retry",
6159
+ }),
6160
+ ).resolves.toEqual({ status: "submitted" });
6161
+ });
6162
+
6163
+ expect(chatMocks.control.sendMessage).toHaveBeenCalledOnce();
6164
+ expect(chatMocks.control.sendMessage.mock.calls[0]?.[0]).toMatchObject({
6165
+ text: "Retry the same deck prompt",
6166
+ queuedWhileRunActive: true,
6167
+ queueWhileRunning: false,
6168
+ metadata: {
6169
+ custom: {
6170
+ [AGENT_NATIVE_SUBMISSION_EXPIRES_AT_METADATA_KEY]:
6171
+ submissionExpiresAt,
6172
+ },
6173
+ },
6174
+ });
6175
+ expect(chatMocks.control.queueMessage).not.toHaveBeenCalled();
6176
+ });
6177
+
6045
6178
  it("hides the shared guided-question card when the host owns that flow", async () => {
6046
6179
  chatMocks.guidedQuestions = [{ id: "question-1", question: "Title?" }];
6047
6180
  await mount(baseProps({ showGuidedQuestions: false }));
@@ -3,6 +3,7 @@ import {
3
3
  hasActiveAgentRuns,
4
4
  selectAgentSuggestions,
5
5
  isCurrentAgentSuggestion,
6
+ AGENT_NATIVE_SUBMISSION_EXPIRES_AT_METADATA_KEY,
6
7
  splitAgentKitMessageContext,
7
8
  type AgentKitUploadDriver,
8
9
  type AgentThreadState,
@@ -643,10 +644,22 @@ type AgentKitInternalSendOptions = AssistantChatSendOptions & {
643
644
  deferredSubmissionId?: string;
644
645
  };
645
646
 
647
+ function isSubmissionExpired(submissionExpiresAt: number | undefined): boolean {
648
+ return (
649
+ submissionExpiresAt !== undefined &&
650
+ (!Number.isFinite(submissionExpiresAt) || submissionExpiresAt <= Date.now())
651
+ );
652
+ }
653
+
654
+ function isSubmissionExpiredError(error: unknown): boolean {
655
+ return asRecord(error)?.code === "submission_expired";
656
+ }
657
+
646
658
  interface PendingProviderSubmission {
647
659
  id: string;
648
660
  threadId: string;
649
661
  text: string;
662
+ submissionExpiresAt?: number;
650
663
  fileParts: FilePart[];
651
664
  requestAttachments?: AgentRequestAttachment[];
652
665
  references: Reference[];
@@ -931,6 +944,9 @@ function parseDeferredProviderSubmissions(
931
944
  !Array.isArray(submission.references) ||
932
945
  !composerOptions ||
933
946
  !options ||
947
+ (submission.submissionExpiresAt !== undefined &&
948
+ (typeof submission.submissionExpiresAt !== "number" ||
949
+ !Number.isFinite(submission.submissionExpiresAt))) ||
934
950
  (submission.attempts !== undefined &&
935
951
  (!Number.isSafeInteger(submission.attempts) ||
936
952
  (submission.attempts as number) < 0)) ||
@@ -950,6 +966,9 @@ function parseDeferredProviderSubmissions(
950
966
  id: submission.id,
951
967
  threadId,
952
968
  text: submission.text,
969
+ ...(typeof submission.submissionExpiresAt === "number"
970
+ ? { submissionExpiresAt: submission.submissionExpiresAt }
971
+ : {}),
953
972
  fileParts: fileParts as FilePart[],
954
973
  ...(Array.isArray(requestAttachments)
955
974
  ? {
@@ -3536,6 +3555,12 @@ const AgentKitAssistantChatBody = forwardRef<
3536
3555
  : props.selectedAgent;
3537
3556
  const actionScope = options.actionScope ?? contextScope;
3538
3557
  const customMetadata = {
3558
+ ...(typeof options.submissionExpiresAt === "number"
3559
+ ? {
3560
+ [AGENT_NATIVE_SUBMISSION_EXPIRES_AT_METADATA_KEY]:
3561
+ options.submissionExpiresAt,
3562
+ }
3563
+ : {}),
3539
3564
  ...(options.recoveryAction
3540
3565
  ? { agentNativeRecoveryAction: options.recoveryAction }
3541
3566
  : {}),
@@ -3596,6 +3621,12 @@ const AgentKitAssistantChatBody = forwardRef<
3596
3621
  requestMode,
3597
3622
  };
3598
3623
  if (!queuedMessageReservationId) localSubmissionRef.current = true;
3624
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3625
+ throw Object.assign(
3626
+ new Error("The prompt expired before it could be sent."),
3627
+ { code: "submission_expired", retryable: false },
3628
+ );
3629
+ }
3599
3630
  await control.sendMessage({
3600
3631
  text: message,
3601
3632
  attachments: fileParts,
@@ -3603,6 +3634,9 @@ const AgentKitAssistantChatBody = forwardRef<
3603
3634
  queuedWhileRunActive:
3604
3635
  composerOptions.queuedWhileRunActive ||
3605
3636
  composerOptions.intent === "queued",
3637
+ ...(options.submissionExpiresAt !== undefined
3638
+ ? { queueWhileRunning: false }
3639
+ : {}),
3606
3640
  interruptActiveRun: composerOptions.steer,
3607
3641
  options: {
3608
3642
  model,
@@ -3684,12 +3718,28 @@ const AgentKitAssistantChatBody = forwardRef<
3684
3718
  composerOptions: AgentKitSuggestionSubmitOptions,
3685
3719
  options: AgentKitInternalSendOptions = {},
3686
3720
  ): Promise<AssistantChatSubmitResult> => {
3721
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3722
+ reportAgentChatSubmitResult(
3723
+ options.submitMessageId,
3724
+ false,
3725
+ "submission-expired",
3726
+ );
3727
+ return { status: "rejected", reason: "submission-expired" };
3728
+ }
3687
3729
  const submittedComposerOptions = captureQueuedRunState(
3688
3730
  composerOptions,
3689
3731
  isThreadRunning(),
3690
3732
  );
3691
3733
  const release = await acquireSubmission();
3692
3734
  if (!release) {
3735
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3736
+ reportAgentChatSubmitResult(
3737
+ options.submitMessageId,
3738
+ false,
3739
+ "submission-expired",
3740
+ );
3741
+ return { status: "rejected", reason: "submission-expired" };
3742
+ }
3693
3743
  if (isRestoring) {
3694
3744
  try {
3695
3745
  const selectedEngine =
@@ -3738,6 +3788,14 @@ const AgentKitAssistantChatBody = forwardRef<
3738
3788
  ) {
3739
3789
  throw new Error(t("onboarding.fileStorage.title"));
3740
3790
  }
3791
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3792
+ reportAgentChatSubmitResult(
3793
+ options.submitMessageId,
3794
+ false,
3795
+ "submission-expired",
3796
+ );
3797
+ return { status: "rejected", reason: "submission-expired" };
3798
+ }
3741
3799
  const requestAttachments = normalizeRequestAttachmentReferences(
3742
3800
  options.deferredRequestAttachments ??
3743
3801
  (await uploadRequestAttachments(
@@ -3746,6 +3804,14 @@ const AgentKitAssistantChatBody = forwardRef<
3746
3804
  )),
3747
3805
  );
3748
3806
  submittedComposerOptions.validateSubmissionScope?.();
3807
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3808
+ reportAgentChatSubmitResult(
3809
+ options.submitMessageId,
3810
+ false,
3811
+ "submission-expired",
3812
+ );
3813
+ return { status: "rejected", reason: "submission-expired" };
3814
+ }
3749
3815
  const selectionChangedDuringUpload =
3750
3816
  selectionRevision !== selectionRevisionRef.current;
3751
3817
  const context = options.recoveryAction
@@ -3794,12 +3860,23 @@ const AgentKitAssistantChatBody = forwardRef<
3794
3860
  id: deferredOptions.deferredSubmissionId,
3795
3861
  threadId,
3796
3862
  text: appendAgentChatContextToMessage(text, context),
3863
+ ...(typeof options.submissionExpiresAt === "number"
3864
+ ? { submissionExpiresAt: options.submissionExpiresAt }
3865
+ : {}),
3797
3866
  fileParts,
3798
3867
  ...(requestAttachments.length ? { requestAttachments } : {}),
3799
3868
  references: [...references],
3800
3869
  composerOptions: deferredComposerOptions,
3801
3870
  options: deferredOptions,
3802
3871
  };
3872
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3873
+ reportAgentChatSubmitResult(
3874
+ submitMessageId,
3875
+ false,
3876
+ "submission-expired",
3877
+ );
3878
+ return { status: "rejected", reason: "submission-expired" };
3879
+ }
3803
3880
  const submissions = await updateDeferredProviderSubmissions(
3804
3881
  threadId,
3805
3882
  (current) =>
@@ -3807,11 +3884,32 @@ const AgentKitAssistantChatBody = forwardRef<
3807
3884
  ? current
3808
3885
  : [...current, submission],
3809
3886
  );
3887
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3888
+ pendingProviderSubmissionsRef.current =
3889
+ await updateDeferredProviderSubmissions(threadId, (current) =>
3890
+ current.filter(({ id }) => id !== submission.id),
3891
+ );
3892
+ setPendingProviderSubmissionVersion((version) => version + 1);
3893
+ reportAgentChatSubmitResult(
3894
+ submitMessageId,
3895
+ false,
3896
+ "submission-expired",
3897
+ );
3898
+ return { status: "rejected", reason: "submission-expired" };
3899
+ }
3810
3900
  pendingProviderSubmissionsRef.current = submissions;
3811
3901
  setDeferredSubmissionsLoadedThread(threadId);
3812
3902
  reportAgentChatSubmitResult(submitMessageId, true);
3813
3903
  return { status: "submitted" };
3814
3904
  } catch (error) {
3905
+ if (isSubmissionExpiredError(error)) {
3906
+ reportAgentChatSubmitResult(
3907
+ options.submitMessageId,
3908
+ false,
3909
+ "submission-expired",
3910
+ );
3911
+ return { status: "rejected", reason: "submission-expired" };
3912
+ }
3815
3913
  reportAgentChatSubmitResult(
3816
3914
  options.submitMessageId,
3817
3915
  false,
@@ -3841,6 +3939,14 @@ const AgentKitAssistantChatBody = forwardRef<
3841
3939
  }
3842
3940
  }
3843
3941
  try {
3942
+ if (isSubmissionExpired(options.submissionExpiresAt)) {
3943
+ reportAgentChatSubmitResult(
3944
+ options.submitMessageId,
3945
+ false,
3946
+ "submission-expired",
3947
+ );
3948
+ return { status: "rejected", reason: "submission-expired" };
3949
+ }
3844
3950
  await dispatch(
3845
3951
  text,
3846
3952
  files,
@@ -3850,6 +3956,14 @@ const AgentKitAssistantChatBody = forwardRef<
3850
3956
  );
3851
3957
  return { status: "submitted" };
3852
3958
  } catch (error) {
3959
+ if (isSubmissionExpiredError(error)) {
3960
+ reportAgentChatSubmitResult(
3961
+ options.submitMessageId,
3962
+ false,
3963
+ "submission-expired",
3964
+ );
3965
+ return { status: "rejected", reason: "submission-expired" };
3966
+ }
3853
3967
  reportAgentChatSubmitResult(
3854
3968
  options.submitMessageId,
3855
3969
  false,
@@ -4232,6 +4346,20 @@ const AgentKitAssistantChatBody = forwardRef<
4232
4346
  let claimToken: string | undefined;
4233
4347
  let claimRenewalTimer: number | undefined;
4234
4348
  try {
4349
+ if (isSubmissionExpired(submission.submissionExpiresAt)) {
4350
+ const submissions = await updateDeferredProviderSubmissions(
4351
+ threadId,
4352
+ (current) => current.filter(({ id }) => id !== submission.id),
4353
+ );
4354
+ pendingProviderSubmissionsRef.current = submissions;
4355
+ reportAgentChatSubmitResult(
4356
+ submission.id,
4357
+ false,
4358
+ "submission-expired",
4359
+ );
4360
+ setPendingProviderSubmissionVersion((version) => version + 1);
4361
+ return;
4362
+ }
4235
4363
  release = await acquireSubmission();
4236
4364
  if (!release) {
4237
4365
  if (active) scheduleProviderSubmissionRetry();
@@ -4301,6 +4429,20 @@ const AgentKitAssistantChatBody = forwardRef<
4301
4429
  );
4302
4430
  return;
4303
4431
  }
4432
+ if (isSubmissionExpired(currentSubmission.submissionExpiresAt)) {
4433
+ const submissions = await updateDeferredProviderSubmissions(
4434
+ threadId,
4435
+ (current) => current.filter(({ id }) => id !== submission.id),
4436
+ );
4437
+ pendingProviderSubmissionsRef.current = submissions;
4438
+ reportAgentChatSubmitResult(
4439
+ submission.id,
4440
+ false,
4441
+ "submission-expired",
4442
+ );
4443
+ setPendingProviderSubmissionVersion((version) => version + 1);
4444
+ return;
4445
+ }
4304
4446
 
4305
4447
  claimRenewalTimer = window.setInterval(() => {
4306
4448
  void updateDeferredProviderSubmissions(threadId, (current) =>
@@ -4345,6 +4487,29 @@ const AgentKitAssistantChatBody = forwardRef<
4345
4487
  setDeferredProviderSubmissionFailureId(null);
4346
4488
  setPendingProviderSubmissionVersion((version) => version + 1);
4347
4489
  } catch (error) {
4490
+ if (isSubmissionExpiredError(error)) {
4491
+ const submissions = await updateDeferredProviderSubmissions(
4492
+ threadId,
4493
+ (current) =>
4494
+ current.filter(
4495
+ (candidate) =>
4496
+ candidate.id !== submission.id ||
4497
+ !claimToken ||
4498
+ candidate.claim?.token !== claimToken,
4499
+ ),
4500
+ );
4501
+ pendingProviderSubmissionsRef.current = submissions;
4502
+ reportAgentChatSubmitResult(
4503
+ submission.id,
4504
+ false,
4505
+ "submission-expired",
4506
+ );
4507
+ if (active) {
4508
+ setDeferredProviderSubmissionFailureId(null);
4509
+ setPendingProviderSubmissionVersion((version) => version + 1);
4510
+ }
4511
+ return;
4512
+ }
4348
4513
  let snapshotReadFailed = false;
4349
4514
  const latestThread = await props
4350
4515
  .getThreadSnapshot(threadId)
@@ -7353,6 +7518,9 @@ const ATTACHMENT_UNREADABLE_SUBMIT_REASON = "attachment-unreadable";
7353
7518
 
7354
7519
  function submitFailureReason(error: unknown): string {
7355
7520
  const record = asRecord(error);
7521
+ if (record?.code === "submission_expired") {
7522
+ return "submission-expired";
7523
+ }
7356
7524
  if (record?.code === ATTACHMENT_UNREADABLE_SUBMIT_REASON) {
7357
7525
  return ATTACHMENT_UNREADABLE_SUBMIT_REASON;
7358
7526
  }
@@ -6627,6 +6627,7 @@ describe("MultiTabAssistantChat cold-start delivery (Mode B)", () => {
6627
6627
  "fetch",
6628
6628
  vi.fn(async () => Response.json({ value: null })),
6629
6629
  );
6630
+ ensureLocalStorage();
6630
6631
  window.localStorage.clear();
6631
6632
  window.localStorage.setItem(
6632
6633
  openTabsStorageKey("mode-b"),
@@ -6669,6 +6670,58 @@ describe("MultiTabAssistantChat cold-start delivery (Mode B)", () => {
6669
6670
  );
6670
6671
  });
6671
6672
 
6673
+ it("rejects a submission whose retry window has expired", async () => {
6674
+ const submissionExpiresAt = Date.now() - 1;
6675
+ const results: unknown[] = [];
6676
+ const onResult = (event: Event) =>
6677
+ results.push((event as CustomEvent).detail);
6678
+ window.addEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, onResult);
6679
+
6680
+ try {
6681
+ await act(async () => {
6682
+ root.render(<MultiTabAssistantChat storageKey="mode-b" />);
6683
+ await Promise.resolve();
6684
+ });
6685
+ dispatchSubmitChat({
6686
+ message: "Create a deck",
6687
+ submissionExpiresAt,
6688
+ submitMessageId: "expired-submit",
6689
+ });
6690
+
6691
+ expect(chatHandleMocks.sendMessage).not.toHaveBeenCalled();
6692
+ expect(results).toContainEqual({
6693
+ submitMessageId: "expired-submit",
6694
+ delivered: false,
6695
+ reason: "submission-expired",
6696
+ });
6697
+ } finally {
6698
+ window.removeEventListener(AGENT_CHAT_SUBMIT_RESULT_EVENT, onResult);
6699
+ }
6700
+ });
6701
+
6702
+ it("passes a submission expiry to the chat that receives the message", async () => {
6703
+ const submissionExpiresAt = Date.now() + 20_000;
6704
+ await act(async () => {
6705
+ root.render(<MultiTabAssistantChat storageKey="mode-b" />);
6706
+ await Promise.resolve();
6707
+ });
6708
+
6709
+ dispatchSubmitChat({
6710
+ message: "Create a deck",
6711
+ submissionExpiresAt,
6712
+ submitMessageId: "deadline-submit",
6713
+ });
6714
+
6715
+ expect(chatHandleMocks.sendMessage).toHaveBeenCalledWith(
6716
+ "Create a deck",
6717
+ undefined,
6718
+ {
6719
+ submitMessageId: "deadline-submit",
6720
+ submissionExpiresAt,
6721
+ },
6722
+ );
6723
+ });
6724
+
6672
6725
  it("ignores a duplicate submit with the same submitMessageId", async () => {
6673
6726
  await act(async () => {
6674
6727
  root.render(<MultiTabAssistantChat storageKey="mode-b" />);
@@ -121,6 +121,7 @@ interface PendingSend {
121
121
  requestMode?: "act" | "plan";
122
122
  /** Correlates with `AGENT_CHAT_SUBMIT_RESULT_EVENT` — see agent-chat.ts. */
123
123
  submitMessageId?: string;
124
+ submissionExpiresAt?: number;
124
125
  /** See `AgentChatMessage.usageLabel`. */
125
126
  usageLabel?: string;
126
127
  actionScope?: AgentActionScope;
@@ -227,6 +228,18 @@ function deliverPendingSend(
227
228
  onHandoff: (delivered: boolean) => boolean,
228
229
  ): void | Promise<void> {
229
230
  if (isAgentChatSubmitCancelled(send.submitMessageId)) return;
231
+ if (
232
+ send.submissionExpiresAt !== undefined &&
233
+ send.submissionExpiresAt <= Date.now()
234
+ ) {
235
+ if (onHandoff(false))
236
+ reportAgentChatSubmitResult(
237
+ send.submitMessageId,
238
+ false,
239
+ "submission-expired",
240
+ );
241
+ return;
242
+ }
230
243
  if (!send.submit) {
231
244
  return deliverPendingPrefill(ref, send, isCurrent, onHandoff).catch(
232
245
  (error: unknown) => {
@@ -250,6 +263,9 @@ function deliverPendingSend(
250
263
  ...(send.requestMode ? { requestMode: send.requestMode } : {}),
251
264
  ...(send.attachments ? { attachments: send.attachments } : {}),
252
265
  ...(send.submitMessageId ? { submitMessageId: send.submitMessageId } : {}),
266
+ ...(send.submissionExpiresAt !== undefined
267
+ ? { submissionExpiresAt: send.submissionExpiresAt }
268
+ : {}),
253
269
  ...(send.usageLabel ? { usageLabel: send.usageLabel } : {}),
254
270
  ...(send.actionScope ? { actionScope: send.actionScope } : {}),
255
271
  // An approval resume is a protocol continuation, not a visible prompt.
@@ -2259,6 +2275,23 @@ export function MultiTabAssistantChat({
2259
2275
  (onlyThreadId?: string, immediate?: boolean) => void
2260
2276
  >(
2261
2277
  (onlyThreadId, immediate = false) => {
2278
+ for (const delivery of [...pendingDeliveries.current]) {
2279
+ if (
2280
+ delivery.send.submissionExpiresAt === undefined ||
2281
+ delivery.send.submissionExpiresAt > Date.now()
2282
+ ) {
2283
+ continue;
2284
+ }
2285
+ pendingDeliveries.current = pendingDeliveries.current.filter(
2286
+ (pending) => pending !== delivery,
2287
+ );
2288
+ reportAgentChatSubmitResult(
2289
+ delivery.send.submitMessageId,
2290
+ false,
2291
+ "submission-expired",
2292
+ );
2293
+ cancelAgentChatSubmit(delivery.send.submitMessageId);
2294
+ }
2262
2295
  const active = activeThreadIdRef.current;
2263
2296
  for (const delivery of pendingDeliveries.current) {
2264
2297
  if (delivery.threadId === null && active) delivery.threadId = active;
@@ -2524,9 +2557,11 @@ export function MultiTabAssistantChat({
2524
2557
  ...(submitMessageId ? { submitMessageId } : {}),
2525
2558
  ...(usageLabel ? { usageLabel } : {}),
2526
2559
  ...(actionScope ? { actionScope } : {}),
2560
+ ...(typeof parsed.submissionExpiresAt === "number"
2561
+ ? { submissionExpiresAt: parsed.submissionExpiresAt }
2562
+ : {}),
2527
2563
  ...(approvedToolCalls ? { approvedToolCalls } : {}),
2528
2564
  };
2529
-
2530
2565
  // Resolved once, up front, and carried with the send until a thread
2531
2566
  // exists to key it by. Applying it only when `model` matched
2532
2567
  // `availableModels` dropped every override that arrived before the
@@ -46,6 +46,8 @@ export interface AssistantChatSendOptions {
46
46
  attachments?: AgentChatAttachment[];
47
47
  /** Correlates with `AGENT_CHAT_SUBMIT_RESULT_EVENT` — see agent-chat.ts. */
48
48
  submitMessageId?: string;
49
+ /** Drop a queued submit instead of handing it to chat after this time. */
50
+ submissionExpiresAt?: number;
49
51
  /** See `AgentChatMessage.usageLabel`. */
50
52
  usageLabel?: string;
51
53
  actionScope?: AgentActionScope;
@@ -59,7 +61,10 @@ export type AssistantChatSubmitResult =
59
61
  | { status: "submitted" }
60
62
  | {
61
63
  status: "rejected";
62
- reason: "engine-not-configured" | "submission-unavailable";
64
+ reason:
65
+ | "engine-not-configured"
66
+ | "submission-unavailable"
67
+ | "submission-expired";
63
68
  };
64
69
 
65
70
  export interface AssistantChatHandle {