@assistant-ui/core 0.3.14 → 0.3.15

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 (137) hide show
  1. package/dist/adapters/voice.js +11 -0
  2. package/dist/adapters/voice.js.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/internal.d.ts +2 -1
  5. package/dist/internal.js +2 -1
  6. package/dist/model-context/frame/host.d.ts +1 -1
  7. package/dist/model-context/frame/host.d.ts.map +1 -1
  8. package/dist/model-context/frame/host.js +17 -3
  9. package/dist/model-context/frame/host.js.map +1 -1
  10. package/dist/model-context/frame/provider.d.ts +4 -0
  11. package/dist/model-context/frame/provider.d.ts.map +1 -1
  12. package/dist/model-context/frame/provider.js +50 -3
  13. package/dist/model-context/frame/provider.js.map +1 -1
  14. package/dist/model-context/frame/types.d.ts +4 -1
  15. package/dist/model-context/frame/types.d.ts.map +1 -1
  16. package/dist/model-context/frame/types.js.map +1 -1
  17. package/dist/react/client/RemoteThreadList.d.ts +1 -1
  18. package/dist/react/client/RemoteThreadList.d.ts.map +1 -1
  19. package/dist/react/client/RemoteThreadList.js +95 -23
  20. package/dist/react/client/RemoteThreadList.js.map +1 -1
  21. package/dist/react/primitive-hooks/useActionBarCopy.js +7 -1
  22. package/dist/react/primitive-hooks/useActionBarCopy.js.map +1 -1
  23. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts +3 -0
  24. package/dist/react/primitive-hooks/useThreadIsEmpty.d.ts.map +1 -1
  25. package/dist/react/primitive-hooks/useThreadIsEmpty.js +3 -0
  26. package/dist/react/primitive-hooks/useThreadIsEmpty.js.map +1 -1
  27. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts +3 -0
  28. package/dist/react/primitive-hooks/useThreadIsRunning.d.ts.map +1 -1
  29. package/dist/react/primitive-hooks/useThreadIsRunning.js +3 -0
  30. package/dist/react/primitive-hooks/useThreadIsRunning.js.map +1 -1
  31. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts +2 -0
  32. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.d.ts.map +1 -1
  33. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js +6 -0
  34. package/dist/react/runtimes/RemoteThreadListHookInstanceManager.js.map +1 -1
  35. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts +6 -0
  36. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.d.ts.map +1 -1
  37. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js +192 -50
  38. package/dist/react/runtimes/RemoteThreadListThreadListRuntimeCore.js.map +1 -1
  39. package/dist/react/runtimes/RemoteThreadResource.d.ts.map +1 -1
  40. package/dist/react/runtimes/RemoteThreadResource.js +2 -0
  41. package/dist/react/runtimes/RemoteThreadResource.js.map +1 -1
  42. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.d.ts.map +1 -1
  43. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js +1 -1
  44. package/dist/react/runtimes/assistant-transport/useAssistantTransportRuntime.js.map +1 -1
  45. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.d.ts.map +1 -1
  46. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js +38 -5
  47. package/dist/react/runtimes/cloud/useCloudThreadListAdapter.js.map +1 -1
  48. package/dist/runtime/utils/message-repository.d.ts.map +1 -1
  49. package/dist/runtime/utils/message-repository.js +27 -18
  50. package/dist/runtime/utils/message-repository.js.map +1 -1
  51. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  52. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +2 -0
  53. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  54. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts +102 -0
  55. package/dist/runtimes/inert/InertThreadRuntimeCore.d.ts.map +1 -0
  56. package/dist/runtimes/inert/InertThreadRuntimeCore.js +156 -0
  57. package/dist/runtimes/inert/InertThreadRuntimeCore.js.map +1 -0
  58. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  59. package/dist/runtimes/local/local-thread-runtime-core.js +25 -6
  60. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  61. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts +12 -60
  62. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.d.ts.map +1 -1
  63. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js +10 -145
  64. package/dist/runtimes/readonly/ReadonlyThreadRuntimeCore.js.map +1 -1
  65. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts +9 -0
  66. package/dist/runtimes/remote-thread-list/adapter-changed.d.ts.map +1 -0
  67. package/dist/runtimes/remote-thread-list/adapter-changed.js +13 -0
  68. package/dist/runtimes/remote-thread-list/adapter-changed.js.map +1 -0
  69. package/dist/runtimes/remote-thread-list/empty-thread-core.d.ts.map +1 -1
  70. package/dist/runtimes/remote-thread-list/empty-thread-core.js +16 -155
  71. package/dist/runtimes/remote-thread-list/empty-thread-core.js.map +1 -1
  72. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts +2 -0
  73. package/dist/runtimes/remote-thread-list/optimistic-state.d.ts.map +1 -1
  74. package/dist/runtimes/remote-thread-list/optimistic-state.js +11 -0
  75. package/dist/runtimes/remote-thread-list/optimistic-state.js.map +1 -1
  76. package/dist/runtimes/remote-thread-list/types.d.ts +3 -0
  77. package/dist/runtimes/remote-thread-list/types.d.ts.map +1 -1
  78. package/dist/store/clients/external-thread.js +1 -1
  79. package/dist/store/clients/external-thread.js.map +1 -1
  80. package/dist/store/runtime-clients/handle-runtime-action.d.ts.map +1 -1
  81. package/dist/store/runtime-clients/handle-runtime-action.js +2 -0
  82. package/dist/store/runtime-clients/handle-runtime-action.js.map +1 -1
  83. package/dist/types/message.d.ts +1 -1
  84. package/dist/types/message.js.map +1 -1
  85. package/dist/utils/silent-runtime-action.d.ts +6 -0
  86. package/dist/utils/silent-runtime-action.d.ts.map +1 -0
  87. package/dist/utils/silent-runtime-action.js +7 -0
  88. package/dist/utils/silent-runtime-action.js.map +1 -0
  89. package/package.json +4 -4
  90. package/src/adapters/voice.test.ts +66 -1
  91. package/src/adapters/voice.ts +8 -0
  92. package/src/internal.ts +1 -0
  93. package/src/model-context/frame/host.test.ts +64 -5
  94. package/src/model-context/frame/host.ts +16 -3
  95. package/src/model-context/frame/provider.test.ts +347 -20
  96. package/src/model-context/frame/provider.ts +58 -2
  97. package/src/model-context/frame/types.ts +5 -0
  98. package/src/react/client/RemoteThreadList.test.ts +196 -0
  99. package/src/react/client/RemoteThreadList.ts +93 -24
  100. package/src/react/primitive-hooks/useActionBarCopy.test.ts +15 -0
  101. package/src/react/primitive-hooks/useActionBarCopy.ts +13 -3
  102. package/src/react/primitive-hooks/useThreadIsEmpty.ts +3 -0
  103. package/src/react/primitive-hooks/useThreadIsRunning.ts +3 -0
  104. package/src/react/runtimes/RemoteThreadListHookInstanceManager.tsx +12 -0
  105. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.republish.test.ts +87 -0
  106. package/src/react/runtimes/RemoteThreadListThreadListRuntimeCore.tsx +268 -63
  107. package/src/react/runtimes/RemoteThreadResource.ts +2 -0
  108. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.test.tsx +58 -0
  109. package/src/react/runtimes/assistant-transport/useAssistantTransportRuntime.ts +3 -1
  110. package/src/react/runtimes/cloud/useCloudThreadListAdapter.test.tsx +98 -3
  111. package/src/react/runtimes/cloud/useCloudThreadListAdapter.tsx +68 -6
  112. package/src/react/runtimes/useLocalRuntime.test.tsx +5 -5
  113. package/src/runtime/utils/message-repository.ts +26 -18
  114. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +4 -0
  115. package/src/runtimes/inert/InertThreadRuntimeCore.test.ts +253 -0
  116. package/src/runtimes/inert/InertThreadRuntimeCore.ts +232 -0
  117. package/src/runtimes/local/local-thread-runtime-core.test.ts +319 -0
  118. package/src/runtimes/local/local-thread-runtime-core.ts +38 -8
  119. package/src/runtimes/readonly/ReadonlyThreadRuntimeCore.ts +17 -209
  120. package/src/runtimes/remote-thread-list/adapter-changed.ts +10 -0
  121. package/src/runtimes/remote-thread-list/empty-thread-core.ts +28 -221
  122. package/src/runtimes/remote-thread-list/optimistic-state.test.ts +18 -0
  123. package/src/runtimes/remote-thread-list/optimistic-state.ts +13 -0
  124. package/src/runtimes/remote-thread-list/types.ts +4 -0
  125. package/src/store/clients/external-thread.ts +1 -1
  126. package/src/store/runtime-clients/handle-runtime-action.test.ts +40 -0
  127. package/src/store/runtime-clients/handle-runtime-action.ts +3 -0
  128. package/src/tests/MessageRepository.test.ts +48 -0
  129. package/src/tests/RemoteThreadListThreadListRuntimeCore-errors.test.ts +2 -2
  130. package/src/tests/RemoteThreadListThreadListRuntimeCore-loadMore.test.ts +8 -6
  131. package/src/tests/empty-thread-core.test.ts +6 -0
  132. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +20 -0
  133. package/src/tests/external-thread-isEmpty.test.tsx +100 -0
  134. package/src/tests/remote-thread-list-adapter-switch.test.ts +383 -0
  135. package/src/tests/useRemoteThreadListRuntime-controlled.test.tsx +67 -0
  136. package/src/types/message.ts +1 -1
  137. package/src/utils/silent-runtime-action.ts +6 -0
@@ -0,0 +1,232 @@
1
+ import type { Unsubscribe } from "../../types/unsubscribe";
2
+ import type { ThreadMessage } from "../../types/message";
3
+ import type { ThreadRuntimeCore } from "../../runtime/interfaces/thread-runtime-core";
4
+ import type { ExportedMessageRepository } from "../../runtime/utils/message-repository";
5
+ import { BaseSubscribable } from "../../subscribable/subscribable";
6
+
7
+ export const createInertComposer = <TIsEditing extends boolean>(
8
+ error: Error,
9
+ isEditing: TIsEditing,
10
+ ) => ({
11
+ attachments: [] as never[],
12
+ attachmentAccept: "*",
13
+
14
+ async addAttachment() {
15
+ throw error;
16
+ },
17
+
18
+ async removeAttachment() {
19
+ throw error;
20
+ },
21
+
22
+ isEditing,
23
+ canCancel: false,
24
+ canSend: false,
25
+ isEmpty: true,
26
+ text: "",
27
+
28
+ setText() {
29
+ throw error;
30
+ },
31
+
32
+ role: "user" as const,
33
+
34
+ setRole() {
35
+ throw error;
36
+ },
37
+
38
+ runConfig: {},
39
+
40
+ setRunConfig() {
41
+ throw error;
42
+ },
43
+
44
+ async reset() {},
45
+
46
+ async clearAttachments() {},
47
+
48
+ send() {
49
+ throw error;
50
+ },
51
+
52
+ cancel() {},
53
+
54
+ queue: [] as never[],
55
+ moveQueueItem() {},
56
+ removeQueueItem() {},
57
+
58
+ dictation: undefined,
59
+
60
+ startDictation() {
61
+ throw error;
62
+ },
63
+
64
+ stopDictation() {},
65
+
66
+ quote: undefined,
67
+
68
+ setQuote() {
69
+ throw error;
70
+ },
71
+
72
+ subscribe() {
73
+ return () => {};
74
+ },
75
+
76
+ unstable_on() {
77
+ return () => {};
78
+ },
79
+ });
80
+
81
+ export abstract class InertThreadRuntimeCore
82
+ extends BaseSubscribable
83
+ implements ThreadRuntimeCore
84
+ {
85
+ protected abstract get error(): Error;
86
+
87
+ abstract readonly messages: readonly ThreadMessage[];
88
+ abstract readonly isLoading: boolean;
89
+ abstract readonly composer: ThreadRuntimeCore["composer"];
90
+
91
+ abstract getMessageById(messageId: string):
92
+ | {
93
+ parentId: string | null;
94
+ message: ThreadMessage;
95
+ index: number;
96
+ }
97
+ | undefined;
98
+
99
+ abstract getBranches(messageId: string): readonly string[];
100
+
101
+ abstract export(): ExportedMessageRepository;
102
+
103
+ switchToBranch(): void {
104
+ throw this.error;
105
+ }
106
+
107
+ append(): void {
108
+ throw this.error;
109
+ }
110
+
111
+ deleteMessage(): void {
112
+ throw this.error;
113
+ }
114
+
115
+ startRun(): void {
116
+ throw this.error;
117
+ }
118
+
119
+ resumeRun(): void {
120
+ throw this.error;
121
+ }
122
+
123
+ cancelRun(): void {
124
+ throw this.error;
125
+ }
126
+
127
+ unstable_notifySessionReset(): void {
128
+ throw this.error;
129
+ }
130
+
131
+ addToolResult(): void {
132
+ throw this.error;
133
+ }
134
+
135
+ resumeToolCall(): void {
136
+ throw this.error;
137
+ }
138
+
139
+ respondToToolApproval(): void {
140
+ throw this.error;
141
+ }
142
+
143
+ speak(): void {
144
+ throw this.error;
145
+ }
146
+
147
+ stopSpeaking(): void {
148
+ throw this.error;
149
+ }
150
+
151
+ connectVoice(): void {
152
+ throw this.error;
153
+ }
154
+
155
+ disconnectVoice(): void {
156
+ throw this.error;
157
+ }
158
+
159
+ muteVoice(): void {
160
+ throw this.error;
161
+ }
162
+
163
+ unmuteVoice(): void {
164
+ throw this.error;
165
+ }
166
+
167
+ submitFeedback(): void {
168
+ throw this.error;
169
+ }
170
+
171
+ exportExternalState(): void {
172
+ throw this.error;
173
+ }
174
+
175
+ importExternalState(): void {
176
+ throw this.error;
177
+ }
178
+
179
+ beginEdit(): void {
180
+ throw this.error;
181
+ }
182
+
183
+ import(): void {
184
+ throw this.error;
185
+ }
186
+
187
+ reset(): void {
188
+ throw this.error;
189
+ }
190
+
191
+ getModelContext() {
192
+ return {};
193
+ }
194
+
195
+ getEditComposer() {
196
+ return undefined;
197
+ }
198
+
199
+ getVoiceVolume = () => 0;
200
+ subscribeVoiceVolume = (): Unsubscribe => () => {};
201
+
202
+ speech = undefined;
203
+ voice = undefined;
204
+
205
+ capabilities = {
206
+ switchToBranch: false,
207
+ switchBranchDuringRun: false,
208
+ edit: false,
209
+ delete: false,
210
+ reload: false,
211
+ refetchThread: false,
212
+ cancel: false,
213
+ unstable_copy: false,
214
+ speech: false,
215
+ dictation: false,
216
+ voice: false,
217
+ attachments: false,
218
+ feedback: false,
219
+ queue: false,
220
+ } as const;
221
+
222
+ isDisabled = false;
223
+ isSendDisabled = false;
224
+
225
+ state = null;
226
+ suggestions = [] as never[];
227
+ extras = undefined;
228
+
229
+ unstable_on(): Unsubscribe {
230
+ return () => {};
231
+ }
232
+ }
@@ -673,6 +673,325 @@ describe("LocalThreadRuntimeCore tool approvals", () => {
673
673
  });
674
674
 
675
675
  describe("LocalThreadRuntimeCore cancellation", () => {
676
+ it("settles a superseded tool-call result without aborting its replacement", async () => {
677
+ let resolveFirst!: (result: ChatModelRunResult) => void;
678
+ let resolveSecond!: (result: ChatModelRunResult) => void;
679
+ const firstResult = new Promise<ChatModelRunResult>((resolve) => {
680
+ resolveFirst = resolve;
681
+ });
682
+ const secondResult = new Promise<ChatModelRunResult>((resolve) => {
683
+ resolveSecond = resolve;
684
+ });
685
+ const runs: ChatModelRunOptions[] = [];
686
+ const thread = createThread({
687
+ run(options) {
688
+ runs.push(options);
689
+ return runs.length === 1 ? firstResult : secondResult;
690
+ },
691
+ });
692
+
693
+ const firstAppend = thread.append(userMessage("first"));
694
+ await flush();
695
+ const supersededMessageId = thread.messages.at(-1)!.id;
696
+ const secondAppend = thread.append({
697
+ ...userMessage("second"),
698
+ parentId: supersededMessageId,
699
+ });
700
+ await flush();
701
+
702
+ expect(runs).toHaveLength(2);
703
+ expect(runs[0]?.abortSignal.aborted).toBe(true);
704
+ expect(runs[1]?.abortSignal.aborted).toBe(false);
705
+
706
+ resolveFirst(toolCallResult("lookup_weather"));
707
+ await firstAppend;
708
+
709
+ expect(runs).toHaveLength(2);
710
+ expect(runs[1]?.abortSignal.aborted).toBe(false);
711
+ expect(
712
+ thread.messages.find((item) => item.id === supersededMessageId)?.status,
713
+ ).toEqual({
714
+ type: "incomplete",
715
+ reason: "cancelled",
716
+ });
717
+
718
+ resolveSecond({ content: [{ type: "text", text: "replacement" }] });
719
+ await secondAppend;
720
+ });
721
+
722
+ it("cancels a superseded approval pause", async () => {
723
+ let resolveFirst!: (result: ChatModelRunResult) => void;
724
+ let resolveSecond!: (result: ChatModelRunResult) => void;
725
+ const firstResult = new Promise<ChatModelRunResult>((resolve) => {
726
+ resolveFirst = resolve;
727
+ });
728
+ const secondResult = new Promise<ChatModelRunResult>((resolve) => {
729
+ resolveSecond = resolve;
730
+ });
731
+ const runs: ChatModelRunOptions[] = [];
732
+ const thread = createThread({
733
+ run(options) {
734
+ runs.push(options);
735
+ return runs.length === 1 ? firstResult : secondResult;
736
+ },
737
+ });
738
+
739
+ const firstAppend = thread.append(userMessage("first"));
740
+ await flush();
741
+ const supersededMessageId = thread.messages.at(-1)!.id;
742
+ const secondAppend = thread.append({
743
+ ...userMessage("second"),
744
+ parentId: supersededMessageId,
745
+ });
746
+ await flush();
747
+
748
+ resolveFirst(toolCallResult("deploy", { id: "approval-1" }));
749
+ await firstAppend;
750
+
751
+ expect(
752
+ thread.messages.find((item) => item.id === supersededMessageId)?.status,
753
+ ).toEqual({
754
+ type: "incomplete",
755
+ reason: "cancelled",
756
+ });
757
+
758
+ resolveSecond({ content: [{ type: "text", text: "replacement" }] });
759
+ await secondAppend;
760
+ });
761
+
762
+ it("ignores a stale result after addToolResult replaces the same message", async () => {
763
+ let releaseFirst!: () => void;
764
+ let resolveSecond!: (result: ChatModelRunResult) => void;
765
+ const firstTeardown = new Promise<void>((resolve) => {
766
+ releaseFirst = resolve;
767
+ });
768
+ const secondResult = new Promise<ChatModelRunResult>((resolve) => {
769
+ resolveSecond = resolve;
770
+ });
771
+ const runs: ChatModelRunOptions[] = [];
772
+ const thread = createThread({
773
+ run(options) {
774
+ runs.push(options);
775
+ if (runs.length === 1) {
776
+ return (async function* () {
777
+ yield toolCallResult("send_email");
778
+ await firstTeardown;
779
+ yield toolCallResult("stale_tool");
780
+ })();
781
+ }
782
+ return secondResult;
783
+ },
784
+ });
785
+
786
+ const firstAppend = thread.append(userMessage("send an email"));
787
+ await flush();
788
+ const messageId = thread.messages.at(-1)!.id;
789
+
790
+ thread.addToolResult({
791
+ messageId,
792
+ toolCallId: "call-send_email",
793
+ toolName: "send_email",
794
+ result: { approved: true },
795
+ isError: false,
796
+ });
797
+ await flush();
798
+
799
+ expect(runs).toHaveLength(2);
800
+ expect(runs[1]?.abortSignal.aborted).toBe(false);
801
+
802
+ releaseFirst();
803
+ await firstAppend;
804
+
805
+ const toolCall = thread.messages
806
+ .find((item) => item.id === messageId)
807
+ ?.content.find(
808
+ (part) =>
809
+ part.type === "tool-call" && part.toolCallId === "call-send_email",
810
+ );
811
+ expect(toolCall?.result).toEqual({ approved: true });
812
+ expect(runs[1]?.abortSignal.aborted).toBe(false);
813
+
814
+ resolveSecond({ content: [{ type: "text", text: "replacement" }] });
815
+ await vi.waitFor(() => {
816
+ expect(
817
+ thread.messages.find((item) => item.id === messageId)?.status.type,
818
+ ).toBe("complete");
819
+ });
820
+ });
821
+
822
+ it("ignores stale chunks after a partial tool result updates the active message", async () => {
823
+ let releaseTeardown!: () => void;
824
+ const teardown = new Promise<void>((resolve) => {
825
+ releaseTeardown = resolve;
826
+ });
827
+ const runs: ChatModelRunOptions[] = [];
828
+ const thread = createThread({
829
+ run(options) {
830
+ runs.push(options);
831
+ return (async function* () {
832
+ yield {
833
+ content: [
834
+ toolCallPart("send_email"),
835
+ toolCallPart("deploy", { id: "approval-1" }),
836
+ ],
837
+ status: {
838
+ type: "requires-action",
839
+ reason: "tool-calls",
840
+ },
841
+ } satisfies ChatModelRunResult;
842
+ await teardown;
843
+ yield { content: [{ type: "text", text: "stale" }] };
844
+ })();
845
+ },
846
+ });
847
+
848
+ const appendPromise = thread.append(userMessage("send and deploy"));
849
+ await flush();
850
+ const messageId = thread.messages.at(-1)!.id;
851
+
852
+ thread.addToolResult({
853
+ messageId,
854
+ toolCallId: "call-send_email",
855
+ toolName: "send_email",
856
+ result: { approved: true },
857
+ isError: false,
858
+ });
859
+ await flush();
860
+
861
+ expect(runs).toHaveLength(1);
862
+
863
+ releaseTeardown();
864
+ await appendPromise;
865
+
866
+ const message = thread.messages.find((item) => item.id === messageId);
867
+ const sendEmail = message?.content.find(
868
+ (part) =>
869
+ part.type === "tool-call" && part.toolCallId === "call-send_email",
870
+ );
871
+ const deploy = message?.content.find(
872
+ (part) => part.type === "tool-call" && part.toolCallId === "call-deploy",
873
+ );
874
+ expect(sendEmail?.result).toEqual({ approved: true });
875
+ expect(deploy?.approval).toEqual({ id: "approval-1" });
876
+ expect(message?.status.type).toBe("requires-action");
877
+ });
878
+
879
+ it("ignores a superseded result after its message is removed", async () => {
880
+ let resolveFirst!: (result: ChatModelRunResult) => void;
881
+ const firstResult = new Promise<ChatModelRunResult>((resolve) => {
882
+ resolveFirst = resolve;
883
+ });
884
+ let runCount = 0;
885
+ const thread = createThread({
886
+ run() {
887
+ runCount++;
888
+ if (runCount === 1) return firstResult;
889
+ return Promise.resolve({
890
+ content: [{ type: "text", text: "replacement" }],
891
+ });
892
+ },
893
+ });
894
+
895
+ const firstAppend = thread.append(userMessage("first"));
896
+ await flush();
897
+ await thread.append(userMessage("second"));
898
+ thread.reset();
899
+
900
+ resolveFirst(toolCallResult("lookup_weather"));
901
+
902
+ await expect(firstAppend).resolves.toBeUndefined();
903
+ expect(thread.messages).toHaveLength(0);
904
+ });
905
+
906
+ it("does not continue after cancelRun when a delayed tool call resolves", async () => {
907
+ let resolveFirst!: (result: ChatModelRunResult) => void;
908
+ const firstResult = new Promise<ChatModelRunResult>((resolve) => {
909
+ resolveFirst = resolve;
910
+ });
911
+ const runs: ChatModelRunOptions[] = [];
912
+ const thread = createThread({
913
+ run(options) {
914
+ runs.push(options);
915
+ if (runs.length === 1) return firstResult;
916
+ return Promise.resolve({
917
+ content: [{ type: "text", text: "unexpected continuation" }],
918
+ status: { type: "complete", reason: "stop" },
919
+ });
920
+ },
921
+ });
922
+
923
+ const appendPromise = thread.append(userMessage("first"));
924
+ await flush();
925
+
926
+ thread.cancelRun();
927
+ resolveFirst(toolCallResult("lookup_weather"));
928
+ await appendPromise;
929
+
930
+ expect(runs).toHaveLength(1);
931
+ expect(thread.messages.at(-1)?.status).toEqual({
932
+ type: "incomplete",
933
+ reason: "cancelled",
934
+ });
935
+ });
936
+
937
+ it("preserves a terminal adapter status when cancelled during teardown", async () => {
938
+ let releaseTeardown!: () => void;
939
+ const teardown = new Promise<void>((resolve) => {
940
+ releaseTeardown = resolve;
941
+ });
942
+ const thread = createThread({
943
+ async *run() {
944
+ yield {
945
+ content: [{ type: "text", text: "done" }],
946
+ status: { type: "complete", reason: "stop" },
947
+ };
948
+ await teardown;
949
+ },
950
+ });
951
+
952
+ const appendPromise = thread.append(userMessage("first"));
953
+ await flush();
954
+
955
+ expect(thread.messages.at(-1)?.status).toEqual({
956
+ type: "complete",
957
+ reason: "stop",
958
+ });
959
+
960
+ thread.cancelRun();
961
+ releaseTeardown();
962
+ await appendPromise;
963
+
964
+ expect(thread.messages.at(-1)?.status).toEqual({
965
+ type: "complete",
966
+ reason: "stop",
967
+ });
968
+ });
969
+
970
+ it("preserves an approval pause when the adapter yields after cancellation", async () => {
971
+ let releaseTeardown!: () => void;
972
+ const teardown = new Promise<void>((resolve) => {
973
+ releaseTeardown = resolve;
974
+ });
975
+ const thread = createThread({
976
+ async *run() {
977
+ yield toolCallResult("deploy", { id: "approval-1" });
978
+ await teardown;
979
+ yield { content: [{ type: "text", text: "late" }] };
980
+ },
981
+ });
982
+
983
+ const appendPromise = thread.append(userMessage("deploy"));
984
+ await flush();
985
+
986
+ expect(thread.messages.at(-1)?.status?.type).toBe("requires-action");
987
+
988
+ thread.cancelRun();
989
+ releaseTeardown();
990
+ await appendPromise;
991
+
992
+ expect(thread.messages.at(-1)?.status?.type).toBe("requires-action");
993
+ });
994
+
676
995
  it("keeps a replacement run cancellable after the previous run settles", async () => {
677
996
  let releaseFirst!: () => void;
678
997
  let releaseSecond!: () => void;
@@ -501,9 +501,11 @@ export class LocalThreadRuntimeCore
501
501
  message,
502
502
  runConfig,
503
503
  alreadyPersisted,
504
+ run,
504
505
  runCallback,
505
506
  );
506
507
  runCallback = undefined;
508
+ if (this._activeRun !== run) break;
507
509
  } while (shouldContinue(message, this._options.unstable_humanToolNames));
508
510
  } finally {
509
511
  this._notifyEventSubscribers("runEnd", {});
@@ -548,6 +550,7 @@ export class LocalThreadRuntimeCore
548
550
  message: ThreadAssistantMessage,
549
551
  runConfig: RunConfig | undefined,
550
552
  alreadyPersisted: boolean,
553
+ run: { cancelled: boolean },
551
554
  runCallback?: ChatModelAdapter["run"],
552
555
  ) {
553
556
  const messages = parentId ? this.repository.getMessages(parentId) : [];
@@ -562,7 +565,23 @@ export class LocalThreadRuntimeCore
562
565
  const initialData = message.metadata?.unstable_data;
563
566
  const initialSteps = message.metadata?.steps;
564
567
  const initialCustom = message.metadata?.custom;
568
+ let hasStoredMessage = true;
569
+ try {
570
+ this.repository.getMessage(message.id);
571
+ } catch {
572
+ hasStoredMessage = false;
573
+ }
574
+ // Other writers replace the stored message object, so identity distinguishes this run from a newer owner.
575
+ const ownsMessage = () => {
576
+ if (!hasStoredMessage) return this._activeRun === run;
577
+ try {
578
+ return this.repository.getMessage(message.id).message === message;
579
+ } catch {
580
+ return false;
581
+ }
582
+ };
565
583
  const updateMessage = (m: Partial<ChatModelRunResult>) => {
584
+ if (!ownsMessage()) return;
566
585
  const newSteps = m.metadata?.steps;
567
586
  const steps = newSteps
568
587
  ? [...(initialSteps ?? []), ...newSteps]
@@ -609,6 +628,7 @@ export class LocalThreadRuntimeCore
609
628
  : undefined),
610
629
  };
611
630
  this.repository.addOrUpdateMessage(parentId, message);
631
+ hasStoredMessage = true;
612
632
  this._notifySubscribers();
613
633
  };
614
634
 
@@ -647,6 +667,12 @@ export class LocalThreadRuntimeCore
647
667
  this.adapters.chatModel.run.bind(this.adapters.chatModel);
648
668
 
649
669
  const abortSignal = abortController.signal;
670
+ const shouldCancelMessage = () =>
671
+ abortSignal.aborted &&
672
+ (message.status.type === "running" ||
673
+ (message.status.type === "requires-action" &&
674
+ (this._activeRun !== run ||
675
+ shouldContinue(message, this._options.unstable_humanToolNames))));
650
676
  const threadId = this._getThreadId?.();
651
677
  const promiseOrGenerator = runCallback({
652
678
  messages,
@@ -665,9 +691,11 @@ export class LocalThreadRuntimeCore
665
691
  if (Symbol.asyncIterator in promiseOrGenerator) {
666
692
  for await (const r of promiseOrGenerator) {
667
693
  if (abortSignal.aborted) {
668
- updateMessage({
669
- status: { type: "incomplete", reason: "cancelled" },
670
- });
694
+ if (shouldCancelMessage()) {
695
+ updateMessage({
696
+ status: { type: "incomplete", reason: "cancelled" },
697
+ });
698
+ }
671
699
  break;
672
700
  }
673
701
 
@@ -677,11 +705,13 @@ export class LocalThreadRuntimeCore
677
705
  updateMessage(await promiseOrGenerator);
678
706
  }
679
707
 
680
- if (message.status.type === "running") {
708
+ if (shouldCancelMessage()) {
709
+ updateMessage({
710
+ status: { type: "incomplete", reason: "cancelled" },
711
+ });
712
+ } else if (message.status.type === "running") {
681
713
  updateMessage({
682
- status: abortSignal.aborted
683
- ? { type: "incomplete", reason: "cancelled" }
684
- : { type: "complete", reason: "unknown" },
714
+ status: { type: "complete", reason: "unknown" },
685
715
  });
686
716
  }
687
717
  } catch (e) {
@@ -724,7 +754,7 @@ export class LocalThreadRuntimeCore
724
754
 
725
755
  // Pauses are written only for adapters that can rewrite the entry later;
726
756
  // an append-only adapter would strand a half-finished run in history.
727
- if (isTerminal || (isPausing && history?.update)) {
757
+ if (ownsMessage() && (isTerminal || (isPausing && history?.update))) {
728
758
  const write =
729
759
  alreadyPersisted && history?.update
730
760
  ? history.update.bind(history)