@ulearning/ai-sdk-adapter 0.0.0-stage → 1.0.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/adapters/aiSDKFormatAdapter.d.ts +6 -0
- package/dist/adapters/aiSDKFormatAdapter.d.ts.map +1 -0
- package/dist/adapters/aiSDKFormatAdapter.js +17 -0
- package/dist/adapters/aiSDKFormatAdapter.js.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts +3 -0
- package/dist/adapters/vercelAttachmentAdapter.d.ts.map +1 -0
- package/dist/adapters/vercelAttachmentAdapter.js +36 -0
- package/dist/adapters/vercelAttachmentAdapter.js.map +1 -0
- package/dist/aiSDKExtras.d.ts +13 -0
- package/dist/aiSDKExtras.d.ts.map +1 -0
- package/dist/aiSDKExtras.js +6 -0
- package/dist/aiSDKExtras.js.map +1 -0
- package/dist/converters/convertMessage.d.ts +44 -0
- package/dist/converters/convertMessage.d.ts.map +1 -0
- package/dist/converters/convertMessage.js +465 -0
- package/dist/converters/convertMessage.js.map +1 -0
- package/dist/converters/toCreateMessage.d.ts +4 -0
- package/dist/converters/toCreateMessage.d.ts.map +1 -0
- package/dist/converters/toCreateMessage.js +65 -0
- package/dist/converters/toCreateMessage.js.map +1 -0
- package/dist/converters/toolApprovalAnswers.d.ts +3 -0
- package/dist/converters/toolApprovalAnswers.d.ts.map +1 -0
- package/dist/converters/toolApprovalAnswers.js +16 -0
- package/dist/converters/toolApprovalAnswers.js.map +1 -0
- package/dist/hooks.d.ts +17 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +19 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/model-context/injectInteractableContext.d.ts +37 -0
- package/dist/model-context/injectInteractableContext.d.ts.map +1 -0
- package/dist/model-context/injectInteractableContext.js +54 -0
- package/dist/model-context/injectInteractableContext.js.map +1 -0
- package/dist/model-context/injectQuoteContext.d.ts +25 -0
- package/dist/model-context/injectQuoteContext.d.ts.map +1 -0
- package/dist/model-context/injectQuoteContext.js +51 -0
- package/dist/model-context/injectQuoteContext.js.map +1 -0
- package/dist/runtime/AISDKChat.d.ts +13 -0
- package/dist/runtime/AISDKChat.d.ts.map +1 -0
- package/dist/runtime/AISDKChat.js +34 -0
- package/dist/runtime/AISDKChat.js.map +1 -0
- package/dist/runtime/AISDKThreads.d.ts +24 -0
- package/dist/runtime/AISDKThreads.d.ts.map +1 -0
- package/dist/runtime/AISDKThreads.js +92 -0
- package/dist/runtime/AISDKThreads.js.map +1 -0
- package/dist/runtime/DynamicChatTransport.d.ts +50 -0
- package/dist/runtime/DynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/DynamicChatTransport.js +153 -0
- package/dist/runtime/DynamicChatTransport.js.map +1 -0
- package/dist/runtime/getResumableAdapter.d.ts +5 -0
- package/dist/runtime/getResumableAdapter.d.ts.map +1 -0
- package/dist/runtime/getResumableAdapter.js +11 -0
- package/dist/runtime/getResumableAdapter.js.map +1 -0
- package/dist/runtime/toolHistoryCodec.d.ts +20 -0
- package/dist/runtime/toolHistoryCodec.d.ts.map +1 -0
- package/dist/runtime/toolHistoryCodec.js +122 -0
- package/dist/runtime/toolHistoryCodec.js.map +1 -0
- package/dist/runtime/useAISDKRuntime.d.ts +95 -0
- package/dist/runtime/useAISDKRuntime.d.ts.map +1 -0
- package/dist/runtime/useAISDKRuntime.js +754 -0
- package/dist/runtime/useAISDKRuntime.js.map +1 -0
- package/dist/runtime/useChatRuntime.d.ts +17 -0
- package/dist/runtime/useChatRuntime.d.ts.map +1 -0
- package/dist/runtime/useChatRuntime.js +50 -0
- package/dist/runtime/useChatRuntime.js.map +1 -0
- package/dist/runtime/useChatSnapshot.d.ts +6 -0
- package/dist/runtime/useChatSnapshot.d.ts.map +1 -0
- package/dist/runtime/useChatSnapshot.js +62 -0
- package/dist/runtime/useChatSnapshot.js.map +1 -0
- package/dist/runtime/useChatThread.d.ts +121 -0
- package/dist/runtime/useChatThread.d.ts.map +1 -0
- package/dist/runtime/useChatThread.js +255 -0
- package/dist/runtime/useChatThread.js.map +1 -0
- package/dist/runtime/useDynamicChatTransport.d.ts +4 -0
- package/dist/runtime/useDynamicChatTransport.d.ts.map +1 -0
- package/dist/runtime/useDynamicChatTransport.js +26 -0
- package/dist/runtime/useDynamicChatTransport.js.map +1 -0
- package/dist/runtime/useExternalHistory.d.ts +12 -0
- package/dist/runtime/useExternalHistory.d.ts.map +1 -0
- package/dist/runtime/useExternalHistory.js +556 -0
- package/dist/runtime/useExternalHistory.js.map +1 -0
- package/dist/runtime/useMessageQueue.d.ts +14 -0
- package/dist/runtime/useMessageQueue.d.ts.map +1 -0
- package/dist/runtime/useMessageQueue.js +142 -0
- package/dist/runtime/useMessageQueue.js.map +1 -0
- package/dist/runtime/useResourceCleanup.d.ts +2 -0
- package/dist/runtime/useResourceCleanup.d.ts.map +1 -0
- package/dist/runtime/useResourceCleanup.js +47 -0
- package/dist/runtime/useResourceCleanup.js.map +1 -0
- package/dist/runtime/useStreamingTiming.d.ts +12 -0
- package/dist/runtime/useStreamingTiming.d.ts.map +1 -0
- package/dist/runtime/useStreamingTiming.js +36 -0
- package/dist/runtime/useStreamingTiming.js.map +1 -0
- package/dist/tools/generativeTools.d.ts +71 -0
- package/dist/tools/generativeTools.d.ts.map +1 -0
- package/dist/tools/generativeTools.js +258 -0
- package/dist/tools/generativeTools.js.map +1 -0
- package/dist/tools/mcp-stdio.node.d.ts +2 -0
- package/dist/tools/mcp-stdio.node.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.node.js +1 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts +3 -0
- package/dist/tools/mcp-stdio.unsupported.d.ts.map +1 -0
- package/dist/tools/mcp-stdio.unsupported.js +10 -0
- package/dist/tools/mcp-stdio.unsupported.js.map +1 -0
- package/dist/transport/AssistantChatTransport.d.ts +20 -0
- package/dist/transport/AssistantChatTransport.d.ts.map +1 -0
- package/dist/transport/AssistantChatTransport.js +151 -0
- package/dist/transport/AssistantChatTransport.js.map +1 -0
- package/dist/transport/resumable.d.ts +36 -0
- package/dist/transport/resumable.d.ts.map +1 -0
- package/dist/transport/resumable.js +112 -0
- package/dist/transport/resumable.js.map +1 -0
- package/dist/usage.d.ts +24 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +121 -0
- package/dist/usage.js.map +1 -0
- package/dist/utils/getVercelAIMessages.d.ts +4 -0
- package/dist/utils/getVercelAIMessages.d.ts.map +1 -0
- package/dist/utils/getVercelAIMessages.js +8 -0
- package/dist/utils/getVercelAIMessages.js.map +1 -0
- package/dist/utils/sliceMessagesUntil.d.ts +3 -0
- package/dist/utils/sliceMessagesUntil.d.ts.map +1 -0
- package/dist/utils/sliceMessagesUntil.js +10 -0
- package/dist/utils/sliceMessagesUntil.js.map +1 -0
- package/package.json +96 -3
- package/src/__tests__/standalone.e2e.ts +118 -0
- package/src/adapters/aiSDKFormatAdapter.test.ts +36 -0
- package/src/adapters/aiSDKFormatAdapter.ts +18 -0
- package/src/adapters/vercelAttachmentAdapter.test.ts +52 -0
- package/src/adapters/vercelAttachmentAdapter.ts +36 -0
- package/src/aiSDKExtras.ts +31 -0
- package/src/converters/convertMessage.metadata-key.test.tsx +182 -0
- package/src/converters/convertMessage.test.ts +2428 -0
- package/src/converters/convertMessage.tool-args-status.test.tsx +185 -0
- package/src/converters/convertMessage.ts +919 -0
- package/src/converters/toCreateMessage.test.ts +716 -0
- package/src/converters/toCreateMessage.ts +110 -0
- package/src/converters/toolApprovalAnswers.ts +27 -0
- package/src/hooks.test.ts +168 -0
- package/src/hooks.ts +26 -0
- package/src/index.ts +29 -0
- package/src/model-context/injectInteractableContext.test.ts +167 -0
- package/src/model-context/injectInteractableContext.ts +64 -0
- package/src/model-context/injectQuoteContext.test.ts +33 -0
- package/src/model-context/injectQuoteContext.ts +63 -0
- package/src/runtime/AISDKChat.artifacts.test.ts +48 -0
- package/src/runtime/AISDKChat.integration.test.tsx +148 -0
- package/src/runtime/AISDKChat.test.ts +241 -0
- package/src/runtime/AISDKChat.ts +43 -0
- package/src/runtime/AISDKThreads.test.ts +811 -0
- package/src/runtime/AISDKThreads.ts +194 -0
- package/src/runtime/DynamicChatTransport.test.ts +228 -0
- package/src/runtime/DynamicChatTransport.ts +281 -0
- package/src/runtime/__tests__/controlled-transport.ts +70 -0
- package/src/runtime/__tests__/tap.ts +80 -0
- package/src/runtime/__tests__/vue.ts +46 -0
- package/src/runtime/getResumableAdapter.ts +16 -0
- package/src/runtime/toolHistoryCodec.test.ts +187 -0
- package/src/runtime/toolHistoryCodec.ts +245 -0
- package/src/runtime/useAISDKRuntime.approval.integration.test.tsx +1956 -0
- package/src/runtime/useAISDKRuntime.approval.test.tsx +580 -0
- package/src/runtime/useAISDKRuntime.denied-tool.test.tsx +122 -0
- package/src/runtime/useAISDKRuntime.fast-refresh.test.tsx +302 -0
- package/src/runtime/useAISDKRuntime.metadata-cache.test.tsx +157 -0
- package/src/runtime/useAISDKRuntime.queue.test.tsx +707 -0
- package/src/runtime/useAISDKRuntime.reload.test.tsx +217 -0
- package/src/runtime/useAISDKRuntime.test.ts +2345 -0
- package/src/runtime/useAISDKRuntime.tool-output.test.tsx +113 -0
- package/src/runtime/useAISDKRuntime.ts +1503 -0
- package/src/runtime/useAISDKRuntime.voice.test.tsx +319 -0
- package/src/runtime/useChatOnTap.test.ts +60 -0
- package/src/runtime/useChatRuntime.fast-refresh.test.tsx +83 -0
- package/src/runtime/useChatRuntime.integration.test.tsx +661 -0
- package/src/runtime/useChatRuntime.local-storage.test.tsx +114 -0
- package/src/runtime/useChatRuntime.native.test.ts +147 -0
- package/src/runtime/useChatRuntime.run-telemetry.test.tsx +226 -0
- package/src/runtime/useChatRuntime.test.ts +619 -0
- package/src/runtime/useChatRuntime.ts +80 -0
- package/src/runtime/useChatSnapshot.test.ts +60 -0
- package/src/runtime/useChatSnapshot.ts +86 -0
- package/src/runtime/useChatThread.binding.test.tsx +140 -0
- package/src/runtime/useChatThread.test.ts +993 -0
- package/src/runtime/useChatThread.transport.test.tsx +149 -0
- package/src/runtime/useChatThread.ts +533 -0
- package/src/runtime/useDynamicChatTransport.ts +26 -0
- package/src/runtime/useExternalHistory.test.ts +4187 -0
- package/src/runtime/useExternalHistory.ts +902 -0
- package/src/runtime/useMessageQueue.effects.test.tsx +88 -0
- package/src/runtime/useMessageQueue.test.tsx +256 -0
- package/src/runtime/useMessageQueue.ts +189 -0
- package/src/runtime/useResourceCleanup.ts +58 -0
- package/src/runtime/useStreamingTiming.test.tsx +96 -0
- package/src/runtime/useStreamingTiming.ts +69 -0
- package/src/tools/generativeTools.test.ts +1058 -0
- package/src/tools/generativeTools.ts +491 -0
- package/src/tools/mcp-stdio.node.ts +1 -0
- package/src/tools/mcp-stdio.unsupported.ts +12 -0
- package/src/transport/AssistantChatTransport.test.ts +631 -0
- package/src/transport/AssistantChatTransport.ts +236 -0
- package/src/transport/resumable.test.ts +262 -0
- package/src/transport/resumable.ts +162 -0
- package/src/usage.test.ts +204 -0
- package/src/usage.ts +179 -0
- package/src/utils/getVercelAIMessages.ts +11 -0
- package/src/utils/sliceMessagesUntil.test.ts +51 -0
- package/src/utils/sliceMessagesUntil.ts +16 -0
|
@@ -0,0 +1,4187 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
3
|
+
import { act, renderHook, waitFor as waitForAssertion } from "./__tests__/tap";
|
|
4
|
+
import { beforeEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
|
5
|
+
import { bindExternalStoreMessage } from "@ulearning/ai-core";
|
|
6
|
+
import type {
|
|
7
|
+
AssistantRuntime,
|
|
8
|
+
ExportedMessageRepository,
|
|
9
|
+
MessageFormatAdapter,
|
|
10
|
+
MessageFormatRepository,
|
|
11
|
+
ThreadAssistantMessage,
|
|
12
|
+
ThreadHistoryAdapter,
|
|
13
|
+
ThreadMessage,
|
|
14
|
+
RespondToToolApprovalOptions,
|
|
15
|
+
Unstable_ToolInteractionLog,
|
|
16
|
+
} from "@ulearning/ai-core";
|
|
17
|
+
|
|
18
|
+
const mocks = vi.hoisted(() => ({
|
|
19
|
+
remoteId: undefined as string | undefined,
|
|
20
|
+
hasThreadListItem: false,
|
|
21
|
+
listeners: new Set<() => void>(),
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
const auiClient = {
|
|
25
|
+
threadListItem: {
|
|
26
|
+
get source() {
|
|
27
|
+
return mocks.hasThreadListItem ? "threads" : null;
|
|
28
|
+
},
|
|
29
|
+
getState: () => ({ remoteId: mocks.remoteId }),
|
|
30
|
+
},
|
|
31
|
+
subscribe: (listener: () => void) => {
|
|
32
|
+
mocks.listeners.add(listener);
|
|
33
|
+
return () => {
|
|
34
|
+
mocks.listeners.delete(listener);
|
|
35
|
+
};
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
vi.mock("@ulearning/ai-store", () => ({
|
|
40
|
+
useAui: () => auiClient,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
import { MessageRepository } from "@ulearning/ai-core/internal";
|
|
44
|
+
import {
|
|
45
|
+
toExportedMessageRepository,
|
|
46
|
+
useExternalHistory,
|
|
47
|
+
} from "./useExternalHistory";
|
|
48
|
+
|
|
49
|
+
const waitFor = (assertion: () => void) =>
|
|
50
|
+
waitForAssertion(assertion, { interval: 10 });
|
|
51
|
+
|
|
52
|
+
const noopThread = {
|
|
53
|
+
subscribe: () => () => {},
|
|
54
|
+
getState: () => ({ isRunning: false, messages: [] }),
|
|
55
|
+
import: () => {},
|
|
56
|
+
export: () => ({ headId: null, messages: [] }),
|
|
57
|
+
} as unknown as AssistantRuntime["thread"];
|
|
58
|
+
|
|
59
|
+
const runtimeRef = {
|
|
60
|
+
current: { thread: noopThread } as AssistantRuntime,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const storageFormat: MessageFormatAdapter<unknown, Record<string, unknown>> = {
|
|
64
|
+
format: "test",
|
|
65
|
+
encode: (item) => ({ data: item.message }),
|
|
66
|
+
decode: (stored) => ({
|
|
67
|
+
parentId: stored.parent_id,
|
|
68
|
+
message: stored.content,
|
|
69
|
+
}),
|
|
70
|
+
getId: () => "id",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const toThreadMessages = (_messages: unknown[]): ThreadMessage[] => [];
|
|
74
|
+
const onSetMessages = () => {};
|
|
75
|
+
|
|
76
|
+
describe("useExternalHistory withFormat contract", () => {
|
|
77
|
+
beforeEach(() => {
|
|
78
|
+
mocks.remoteId = undefined;
|
|
79
|
+
mocks.hasThreadListItem = false;
|
|
80
|
+
mocks.listeners.clear();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("throws when the adapter omits withFormat", () => {
|
|
84
|
+
const adapterWithoutWithFormat: ThreadHistoryAdapter = {
|
|
85
|
+
load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
|
|
86
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
90
|
+
|
|
91
|
+
expect(() =>
|
|
92
|
+
renderHook(() =>
|
|
93
|
+
useExternalHistory(
|
|
94
|
+
runtimeRef,
|
|
95
|
+
adapterWithoutWithFormat,
|
|
96
|
+
toThreadMessages,
|
|
97
|
+
storageFormat,
|
|
98
|
+
onSetMessages,
|
|
99
|
+
),
|
|
100
|
+
),
|
|
101
|
+
).toThrow(/withFormat/);
|
|
102
|
+
|
|
103
|
+
errorSpy.mockRestore();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("does not throw when no adapter is supplied", () => {
|
|
107
|
+
expect(() =>
|
|
108
|
+
renderHook(() =>
|
|
109
|
+
useExternalHistory(
|
|
110
|
+
runtimeRef,
|
|
111
|
+
undefined,
|
|
112
|
+
toThreadMessages,
|
|
113
|
+
storageFormat,
|
|
114
|
+
onSetMessages,
|
|
115
|
+
),
|
|
116
|
+
),
|
|
117
|
+
).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("accepts an adapter that implements withFormat", () => {
|
|
121
|
+
const withFormatResult = {
|
|
122
|
+
load: vi.fn().mockResolvedValue({ headId: null, messages: [] }),
|
|
123
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
124
|
+
};
|
|
125
|
+
const adapter: ThreadHistoryAdapter = {
|
|
126
|
+
load: vi.fn(),
|
|
127
|
+
append: vi.fn(),
|
|
128
|
+
withFormat: vi.fn().mockReturnValue(withFormatResult),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
expect(() =>
|
|
132
|
+
renderHook(() =>
|
|
133
|
+
useExternalHistory(
|
|
134
|
+
runtimeRef,
|
|
135
|
+
adapter,
|
|
136
|
+
toThreadMessages,
|
|
137
|
+
storageFormat,
|
|
138
|
+
onSetMessages,
|
|
139
|
+
),
|
|
140
|
+
),
|
|
141
|
+
).not.toThrow();
|
|
142
|
+
|
|
143
|
+
expect(adapter.withFormat).toHaveBeenCalledWith(storageFormat);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("loads history when threadListItem.remoteId appears after the first paint", async () => {
|
|
147
|
+
const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
|
|
148
|
+
const adapter: ThreadHistoryAdapter = {
|
|
149
|
+
load: vi.fn(),
|
|
150
|
+
append: vi.fn(),
|
|
151
|
+
withFormat: vi.fn().mockReturnValue({
|
|
152
|
+
load,
|
|
153
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
154
|
+
}),
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const { rerender } = renderHook(() =>
|
|
158
|
+
useExternalHistory(
|
|
159
|
+
runtimeRef,
|
|
160
|
+
adapter,
|
|
161
|
+
toThreadMessages,
|
|
162
|
+
storageFormat,
|
|
163
|
+
onSetMessages,
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await act(async () => {});
|
|
168
|
+
expect(load).not.toHaveBeenCalled();
|
|
169
|
+
|
|
170
|
+
mocks.hasThreadListItem = true;
|
|
171
|
+
mocks.remoteId = "remote-thread";
|
|
172
|
+
await act(async () => {
|
|
173
|
+
for (const listener of mocks.listeners) listener();
|
|
174
|
+
});
|
|
175
|
+
rerender();
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("loads when a mounted thread later receives a remoteId", async () => {
|
|
181
|
+
mocks.hasThreadListItem = true;
|
|
182
|
+
const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
|
|
183
|
+
const adapter: ThreadHistoryAdapter = {
|
|
184
|
+
load: vi.fn(),
|
|
185
|
+
append: vi.fn(),
|
|
186
|
+
withFormat: vi.fn().mockReturnValue({
|
|
187
|
+
load,
|
|
188
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
189
|
+
}),
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
renderHook(() =>
|
|
193
|
+
useExternalHistory(
|
|
194
|
+
runtimeRef,
|
|
195
|
+
adapter,
|
|
196
|
+
toThreadMessages,
|
|
197
|
+
storageFormat,
|
|
198
|
+
onSetMessages,
|
|
199
|
+
),
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
await act(async () => {});
|
|
203
|
+
expect(load).not.toHaveBeenCalled();
|
|
204
|
+
|
|
205
|
+
mocks.remoteId = "remote-thread";
|
|
206
|
+
await act(async () => {
|
|
207
|
+
for (const listener of mocks.listeners) listener();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("re-enters loading when remoteId arrives before history settles", async () => {
|
|
214
|
+
mocks.hasThreadListItem = true;
|
|
215
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
216
|
+
const load = vi.fn(
|
|
217
|
+
() =>
|
|
218
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
219
|
+
resolveLoad = resolve;
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
const adapter: ThreadHistoryAdapter = {
|
|
223
|
+
load: vi.fn(),
|
|
224
|
+
append: vi.fn(),
|
|
225
|
+
withFormat: vi.fn().mockReturnValue({
|
|
226
|
+
load,
|
|
227
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
228
|
+
}),
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const { result } = renderHook(() =>
|
|
232
|
+
useExternalHistory(
|
|
233
|
+
runtimeRef,
|
|
234
|
+
adapter,
|
|
235
|
+
toThreadMessages,
|
|
236
|
+
storageFormat,
|
|
237
|
+
onSetMessages,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
await act(async () => {});
|
|
242
|
+
expect(result.current.isLoading).toBe(false);
|
|
243
|
+
expect(load).not.toHaveBeenCalled();
|
|
244
|
+
|
|
245
|
+
mocks.remoteId = "remote-thread";
|
|
246
|
+
await act(async () => {
|
|
247
|
+
for (const listener of mocks.listeners) listener();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
251
|
+
expect(result.current.isLoading).toBe(true);
|
|
252
|
+
|
|
253
|
+
await act(async () => {
|
|
254
|
+
resolveLoad({ headId: null, messages: [] });
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("does not load history when remoteId appears during an active run", async () => {
|
|
261
|
+
mocks.hasThreadListItem = true;
|
|
262
|
+
const load = vi.fn().mockResolvedValue({ headId: null, messages: [] });
|
|
263
|
+
const adapter: ThreadHistoryAdapter = {
|
|
264
|
+
load: vi.fn(),
|
|
265
|
+
append: vi.fn(),
|
|
266
|
+
withFormat: vi.fn().mockReturnValue({
|
|
267
|
+
load,
|
|
268
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
269
|
+
}),
|
|
270
|
+
};
|
|
271
|
+
const runningRuntimeRef = {
|
|
272
|
+
current: {
|
|
273
|
+
thread: {
|
|
274
|
+
subscribe: () => () => {},
|
|
275
|
+
getState: () => ({ isRunning: true, messages: [{ id: "u1" }] }),
|
|
276
|
+
import: () => {},
|
|
277
|
+
export: () => ({ headId: null, messages: [] }),
|
|
278
|
+
},
|
|
279
|
+
} as unknown as AssistantRuntime,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
renderHook(() =>
|
|
283
|
+
useExternalHistory(
|
|
284
|
+
runningRuntimeRef,
|
|
285
|
+
adapter,
|
|
286
|
+
toThreadMessages,
|
|
287
|
+
storageFormat,
|
|
288
|
+
onSetMessages,
|
|
289
|
+
),
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
await act(async () => {});
|
|
293
|
+
expect(load).not.toHaveBeenCalled();
|
|
294
|
+
|
|
295
|
+
mocks.remoteId = "remote-thread";
|
|
296
|
+
await act(async () => {
|
|
297
|
+
for (const listener of mocks.listeners) listener();
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
await act(async () => {});
|
|
301
|
+
expect(load).not.toHaveBeenCalled();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("reports loading before an asynchronous history load settles", async () => {
|
|
305
|
+
mocks.hasThreadListItem = true;
|
|
306
|
+
mocks.remoteId = "remote-thread";
|
|
307
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
308
|
+
const load = vi.fn(
|
|
309
|
+
() =>
|
|
310
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
311
|
+
resolveLoad = resolve;
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
const adapter: ThreadHistoryAdapter = {
|
|
315
|
+
load: vi.fn(),
|
|
316
|
+
append: vi.fn(),
|
|
317
|
+
withFormat: vi.fn().mockReturnValue({
|
|
318
|
+
load,
|
|
319
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
320
|
+
}),
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const { result } = renderHook(() =>
|
|
324
|
+
useExternalHistory(
|
|
325
|
+
runtimeRef,
|
|
326
|
+
adapter,
|
|
327
|
+
toThreadMessages,
|
|
328
|
+
storageFormat,
|
|
329
|
+
onSetMessages,
|
|
330
|
+
),
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
expect(result.current.isLoading).toBe(true);
|
|
334
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
335
|
+
|
|
336
|
+
await act(async () => {
|
|
337
|
+
resolveLoad({ headId: null, messages: [] });
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("imports history beneath live messages when a run wins the load race", async () => {
|
|
344
|
+
mocks.hasThreadListItem = true;
|
|
345
|
+
mocks.remoteId = "remote-thread";
|
|
346
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
347
|
+
const load = vi.fn(
|
|
348
|
+
() =>
|
|
349
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
350
|
+
resolveLoad = resolve;
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
const threadState = {
|
|
354
|
+
isRunning: false,
|
|
355
|
+
messages: [] as ThreadMessage[],
|
|
356
|
+
};
|
|
357
|
+
const threadImport = vi.fn();
|
|
358
|
+
const setMessages = vi.fn();
|
|
359
|
+
const thread = {
|
|
360
|
+
subscribe: () => () => {},
|
|
361
|
+
getState: () => threadState,
|
|
362
|
+
import: threadImport,
|
|
363
|
+
export: () => ({ headId: null, messages: [] }),
|
|
364
|
+
} as unknown as AssistantRuntime["thread"];
|
|
365
|
+
const historyRuntimeRef = {
|
|
366
|
+
current: { thread } as AssistantRuntime,
|
|
367
|
+
};
|
|
368
|
+
const adapter: ThreadHistoryAdapter = {
|
|
369
|
+
load: vi.fn(),
|
|
370
|
+
append: vi.fn(),
|
|
371
|
+
withFormat: vi.fn().mockReturnValue({
|
|
372
|
+
load,
|
|
373
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
374
|
+
}),
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const { result } = renderHook(() =>
|
|
378
|
+
useExternalHistory(
|
|
379
|
+
historyRuntimeRef,
|
|
380
|
+
adapter,
|
|
381
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
382
|
+
storageFormat,
|
|
383
|
+
setMessages,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
388
|
+
const liveMessages = [
|
|
389
|
+
{ id: "live-user" },
|
|
390
|
+
{ id: "live-assistant" },
|
|
391
|
+
] as ThreadMessage[];
|
|
392
|
+
await act(async () => {
|
|
393
|
+
threadState.isRunning = true;
|
|
394
|
+
threadState.messages = liveMessages;
|
|
395
|
+
resolveLoad({
|
|
396
|
+
headId: "stored",
|
|
397
|
+
messages: [{ parentId: null, message: { id: "stored" } }],
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
402
|
+
expect(threadState.messages).toBe(liveMessages);
|
|
403
|
+
expect(threadImport).toHaveBeenCalledTimes(1);
|
|
404
|
+
expect(threadImport).toHaveBeenCalledWith({
|
|
405
|
+
headId: "live-assistant",
|
|
406
|
+
messages: [
|
|
407
|
+
{ parentId: null, message: { id: "stored" } },
|
|
408
|
+
{ parentId: "stored", message: { id: "live-user" } },
|
|
409
|
+
{ parentId: "live-user", message: { id: "live-assistant" } },
|
|
410
|
+
],
|
|
411
|
+
});
|
|
412
|
+
expect(setMessages).toHaveBeenCalledTimes(1);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("keeps a live message out of the merged tail when history already holds it", async () => {
|
|
416
|
+
mocks.hasThreadListItem = true;
|
|
417
|
+
mocks.remoteId = "remote-thread";
|
|
418
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
419
|
+
const load = vi.fn(
|
|
420
|
+
() =>
|
|
421
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
422
|
+
resolveLoad = resolve;
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
const threadState = {
|
|
426
|
+
isRunning: false,
|
|
427
|
+
messages: [] as ThreadMessage[],
|
|
428
|
+
};
|
|
429
|
+
const threadImport = vi.fn();
|
|
430
|
+
const thread = {
|
|
431
|
+
subscribe: () => () => {},
|
|
432
|
+
getState: () => threadState,
|
|
433
|
+
import: threadImport,
|
|
434
|
+
export: () => ({ headId: null, messages: [] }),
|
|
435
|
+
} as unknown as AssistantRuntime["thread"];
|
|
436
|
+
const historyRuntimeRef = {
|
|
437
|
+
current: { thread } as AssistantRuntime,
|
|
438
|
+
};
|
|
439
|
+
const adapter: ThreadHistoryAdapter = {
|
|
440
|
+
load: vi.fn(),
|
|
441
|
+
append: vi.fn(),
|
|
442
|
+
withFormat: vi.fn().mockReturnValue({
|
|
443
|
+
load,
|
|
444
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
445
|
+
}),
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
const { result } = renderHook(() =>
|
|
449
|
+
useExternalHistory(
|
|
450
|
+
historyRuntimeRef,
|
|
451
|
+
adapter,
|
|
452
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
453
|
+
storageFormat,
|
|
454
|
+
() => {},
|
|
455
|
+
),
|
|
456
|
+
);
|
|
457
|
+
|
|
458
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
459
|
+
await act(async () => {
|
|
460
|
+
threadState.isRunning = true;
|
|
461
|
+
threadState.messages = [
|
|
462
|
+
{ id: "stored-user" },
|
|
463
|
+
{ id: "live-assistant" },
|
|
464
|
+
] as ThreadMessage[];
|
|
465
|
+
resolveLoad({
|
|
466
|
+
headId: "stored-user",
|
|
467
|
+
messages: [{ parentId: null, message: { id: "stored-user" } }],
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
472
|
+
expect(threadImport).toHaveBeenCalledWith({
|
|
473
|
+
headId: "live-assistant",
|
|
474
|
+
messages: [
|
|
475
|
+
{ parentId: null, message: { id: "stored-user" } },
|
|
476
|
+
{ parentId: "stored-user", message: { id: "live-assistant" } },
|
|
477
|
+
],
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it("keeps the live content of a resumed assistant reply with a stored id", async () => {
|
|
482
|
+
mocks.hasThreadListItem = true;
|
|
483
|
+
mocks.remoteId = "remote-thread";
|
|
484
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
485
|
+
const load = vi.fn(
|
|
486
|
+
() =>
|
|
487
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
488
|
+
resolveLoad = resolve;
|
|
489
|
+
}),
|
|
490
|
+
);
|
|
491
|
+
const threadState = {
|
|
492
|
+
isRunning: false,
|
|
493
|
+
messages: [] as ThreadMessage[],
|
|
494
|
+
};
|
|
495
|
+
const threadImport = vi.fn();
|
|
496
|
+
const thread = {
|
|
497
|
+
subscribe: () => () => {},
|
|
498
|
+
getState: () => threadState,
|
|
499
|
+
import: threadImport,
|
|
500
|
+
export: () => ({ headId: null, messages: [] }),
|
|
501
|
+
} as unknown as AssistantRuntime["thread"];
|
|
502
|
+
const adapter: ThreadHistoryAdapter = {
|
|
503
|
+
load: vi.fn(),
|
|
504
|
+
append: vi.fn(),
|
|
505
|
+
withFormat: vi.fn().mockReturnValue({
|
|
506
|
+
load,
|
|
507
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
508
|
+
}),
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
renderHook(() =>
|
|
512
|
+
useExternalHistory(
|
|
513
|
+
{ current: { thread } as AssistantRuntime },
|
|
514
|
+
adapter,
|
|
515
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
516
|
+
storageFormat,
|
|
517
|
+
() => {},
|
|
518
|
+
),
|
|
519
|
+
);
|
|
520
|
+
|
|
521
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
522
|
+
const liveReply: ThreadAssistantMessage = {
|
|
523
|
+
id: "assistant",
|
|
524
|
+
role: "assistant",
|
|
525
|
+
content: [{ type: "text", text: "complete streamed reply" }],
|
|
526
|
+
createdAt: new Date(),
|
|
527
|
+
status: { type: "running" },
|
|
528
|
+
metadata: {
|
|
529
|
+
unstable_state: null,
|
|
530
|
+
unstable_annotations: [],
|
|
531
|
+
unstable_data: [],
|
|
532
|
+
steps: [],
|
|
533
|
+
custom: {},
|
|
534
|
+
},
|
|
535
|
+
};
|
|
536
|
+
await act(async () => {
|
|
537
|
+
threadState.isRunning = true;
|
|
538
|
+
threadState.messages = [liveReply];
|
|
539
|
+
resolveLoad({
|
|
540
|
+
headId: "assistant",
|
|
541
|
+
messages: [
|
|
542
|
+
{ parentId: null, message: { id: "user" } },
|
|
543
|
+
{
|
|
544
|
+
parentId: "user",
|
|
545
|
+
message: {
|
|
546
|
+
id: "assistant",
|
|
547
|
+
content: [{ type: "text", text: "partial reply" }],
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
],
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
expect(threadImport).toHaveBeenCalledWith({
|
|
555
|
+
headId: "assistant",
|
|
556
|
+
messages: [
|
|
557
|
+
{ parentId: null, message: { id: "user" } },
|
|
558
|
+
{ parentId: "user", message: liveReply },
|
|
559
|
+
],
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it("merges live and stored tool interactions when history loads during a run", async () => {
|
|
564
|
+
mocks.hasThreadListItem = true;
|
|
565
|
+
mocks.remoteId = "remote-thread";
|
|
566
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
567
|
+
const load = vi.fn(
|
|
568
|
+
() =>
|
|
569
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
570
|
+
resolveLoad = resolve;
|
|
571
|
+
}),
|
|
572
|
+
);
|
|
573
|
+
const threadState = {
|
|
574
|
+
isRunning: false,
|
|
575
|
+
messages: [] as ThreadMessage[],
|
|
576
|
+
};
|
|
577
|
+
const thread = {
|
|
578
|
+
subscribe: () => () => {},
|
|
579
|
+
getState: () => threadState,
|
|
580
|
+
import: vi.fn(),
|
|
581
|
+
} as unknown as AssistantRuntime["thread"];
|
|
582
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
|
|
583
|
+
const shared = { type: "action" as const, occurredAt: 1, payload: {} };
|
|
584
|
+
const stored = {
|
|
585
|
+
type: "human-response" as const,
|
|
586
|
+
occurredAt: 2,
|
|
587
|
+
payload: "stored",
|
|
588
|
+
};
|
|
589
|
+
const live = {
|
|
590
|
+
type: "human-response" as const,
|
|
591
|
+
occurredAt: 3,
|
|
592
|
+
payload: "live",
|
|
593
|
+
};
|
|
594
|
+
const adapter: ThreadHistoryAdapter = {
|
|
595
|
+
load: vi.fn(),
|
|
596
|
+
append: vi.fn(),
|
|
597
|
+
withFormat: vi.fn().mockReturnValue({
|
|
598
|
+
load,
|
|
599
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
600
|
+
}),
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
renderHook(() =>
|
|
604
|
+
useExternalHistory(
|
|
605
|
+
{ current: { thread } as AssistantRuntime },
|
|
606
|
+
adapter,
|
|
607
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
608
|
+
storageFormat,
|
|
609
|
+
() => {},
|
|
610
|
+
undefined,
|
|
611
|
+
undefined,
|
|
612
|
+
() => toolInteractions,
|
|
613
|
+
(next) => {
|
|
614
|
+
toolInteractions.clear();
|
|
615
|
+
for (const [id, log] of next) toolInteractions.set(id, log);
|
|
616
|
+
},
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
621
|
+
await act(async () => {
|
|
622
|
+
threadState.isRunning = true;
|
|
623
|
+
threadState.messages = [{ id: "live" } as ThreadMessage];
|
|
624
|
+
toolInteractions.set("call-1", { entries: [shared, live] });
|
|
625
|
+
resolveLoad({
|
|
626
|
+
headId: "stored",
|
|
627
|
+
messages: [
|
|
628
|
+
{
|
|
629
|
+
parentId: null,
|
|
630
|
+
message: {
|
|
631
|
+
id: "stored",
|
|
632
|
+
metadata: {
|
|
633
|
+
__aui_toolInteractions: {
|
|
634
|
+
"call-1": { entries: [shared, stored] },
|
|
635
|
+
"call-2": { entries: [stored] },
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
},
|
|
639
|
+
},
|
|
640
|
+
],
|
|
641
|
+
});
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
expect(toolInteractions.get("call-1")?.entries).toEqual([
|
|
645
|
+
shared,
|
|
646
|
+
stored,
|
|
647
|
+
live,
|
|
648
|
+
]);
|
|
649
|
+
expect(toolInteractions.get("call-2")?.entries).toEqual([stored]);
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it("discards a pending history load after switching threads", async () => {
|
|
653
|
+
mocks.hasThreadListItem = true;
|
|
654
|
+
mocks.remoteId = "thread-a";
|
|
655
|
+
let resolveLoad!: (repo: MessageFormatRepository<unknown>) => void;
|
|
656
|
+
const load = vi.fn(
|
|
657
|
+
() =>
|
|
658
|
+
new Promise<MessageFormatRepository<unknown>>((resolve) => {
|
|
659
|
+
resolveLoad = resolve;
|
|
660
|
+
}),
|
|
661
|
+
);
|
|
662
|
+
const threadState = {
|
|
663
|
+
isRunning: false,
|
|
664
|
+
messages: [] as ThreadMessage[],
|
|
665
|
+
};
|
|
666
|
+
const threadImport = vi.fn();
|
|
667
|
+
const setMessages = vi.fn();
|
|
668
|
+
const thread = {
|
|
669
|
+
subscribe: () => () => {},
|
|
670
|
+
getState: () => threadState,
|
|
671
|
+
import: threadImport,
|
|
672
|
+
export: () => ({ headId: null, messages: [] }),
|
|
673
|
+
} as unknown as AssistantRuntime["thread"];
|
|
674
|
+
const adapter: ThreadHistoryAdapter = {
|
|
675
|
+
load: vi.fn(),
|
|
676
|
+
append: vi.fn(),
|
|
677
|
+
withFormat: vi.fn().mockReturnValue({
|
|
678
|
+
load,
|
|
679
|
+
append: vi.fn().mockResolvedValue(undefined),
|
|
680
|
+
}),
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
renderHook(() =>
|
|
684
|
+
useExternalHistory(
|
|
685
|
+
{ current: { thread } as AssistantRuntime },
|
|
686
|
+
adapter,
|
|
687
|
+
(messages) => messages.map((message) => message as ThreadMessage),
|
|
688
|
+
storageFormat,
|
|
689
|
+
setMessages,
|
|
690
|
+
),
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
694
|
+
await act(async () => {
|
|
695
|
+
mocks.remoteId = "thread-b";
|
|
696
|
+
threadState.isRunning = true;
|
|
697
|
+
threadState.messages = [{ id: "thread-b-live" } as ThreadMessage];
|
|
698
|
+
resolveLoad({
|
|
699
|
+
headId: "thread-a-stored",
|
|
700
|
+
messages: [{ parentId: null, message: { id: "thread-a-stored" } }],
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
expect(threadImport).not.toHaveBeenCalled();
|
|
705
|
+
expect(setMessages).not.toHaveBeenCalled();
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
describe("toExportedMessageRepository", () => {
|
|
710
|
+
const convert = (items: { id: string; ok: boolean }[]): ThreadMessage[] =>
|
|
711
|
+
items[0]!.ok ? [{ id: items[0]!.id } as ThreadMessage] : [];
|
|
712
|
+
|
|
713
|
+
it("keeps descendants listed before their parents and preserves the head", () => {
|
|
714
|
+
const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
|
|
715
|
+
headId: "c",
|
|
716
|
+
messages: [
|
|
717
|
+
{ parentId: "b", message: { id: "c", ok: true } },
|
|
718
|
+
{ parentId: "a", message: { id: "b", ok: true } },
|
|
719
|
+
{ parentId: null, message: { id: "a", ok: true } },
|
|
720
|
+
],
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
const result = toExportedMessageRepository(convert, repo);
|
|
724
|
+
const repository = new MessageRepository();
|
|
725
|
+
repository.import(result);
|
|
726
|
+
|
|
727
|
+
expect(result.messages.map((m) => m.message.id)).toEqual(["a", "b", "c"]);
|
|
728
|
+
expect(result.headId).toBe("c");
|
|
729
|
+
expect(repository.getMessages().map((m) => m.id)).toEqual(["a", "b", "c"]);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("drops missing and malformed parents' descendants regardless of row order", () => {
|
|
733
|
+
const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
|
|
734
|
+
headId: "c",
|
|
735
|
+
messages: [
|
|
736
|
+
{ parentId: "b", message: { id: "c", ok: true } },
|
|
737
|
+
{ parentId: "missing", message: { id: "orphan", ok: true } },
|
|
738
|
+
{ parentId: "a", message: { id: "b", ok: false } },
|
|
739
|
+
{ parentId: "a", message: { id: "valid", ok: true } },
|
|
740
|
+
{ parentId: null, message: { id: "a", ok: true } },
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
const result = toExportedMessageRepository(convert, repo);
|
|
745
|
+
|
|
746
|
+
expect(result.messages.map((m) => m.message.id)).toEqual(["a", "valid"]);
|
|
747
|
+
expect(result.headId).toBeNull();
|
|
748
|
+
expect(() => new MessageRepository().import(result)).not.toThrow();
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
it("drops a malformed row together with its now-orphaned descendants", () => {
|
|
752
|
+
const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
|
|
753
|
+
headId: "c",
|
|
754
|
+
messages: [
|
|
755
|
+
{ parentId: null, message: { id: "a", ok: true } },
|
|
756
|
+
{ parentId: "a", message: { id: "b", ok: false } },
|
|
757
|
+
{ parentId: "b", message: { id: "c", ok: true } },
|
|
758
|
+
],
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const result = toExportedMessageRepository(convert, repo);
|
|
762
|
+
|
|
763
|
+
expect(result.messages.map((m) => m.message.id)).toEqual(["a"]);
|
|
764
|
+
expect(result.headId).toBeNull();
|
|
765
|
+
expect(() => new MessageRepository().import(result)).not.toThrow();
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("drops a headId that points at a filtered row", () => {
|
|
769
|
+
const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
|
|
770
|
+
headId: "b",
|
|
771
|
+
messages: [
|
|
772
|
+
{ parentId: null, message: { id: "a", ok: true } },
|
|
773
|
+
{ parentId: "a", message: { id: "b", ok: false } },
|
|
774
|
+
],
|
|
775
|
+
};
|
|
776
|
+
|
|
777
|
+
const result = toExportedMessageRepository(convert, repo);
|
|
778
|
+
|
|
779
|
+
expect(result.messages.map((m) => m.message.id)).toEqual(["a"]);
|
|
780
|
+
expect(result.headId).toBeNull();
|
|
781
|
+
expect(() => new MessageRepository().import(result)).not.toThrow();
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("drops a malformed root and its entire subtree", () => {
|
|
785
|
+
const repo: MessageFormatRepository<{ id: string; ok: boolean }> = {
|
|
786
|
+
headId: "b",
|
|
787
|
+
messages: [
|
|
788
|
+
{ parentId: null, message: { id: "a", ok: false } },
|
|
789
|
+
{ parentId: "a", message: { id: "b", ok: true } },
|
|
790
|
+
],
|
|
791
|
+
};
|
|
792
|
+
|
|
793
|
+
const result = toExportedMessageRepository(convert, repo);
|
|
794
|
+
|
|
795
|
+
expect(result.messages).toHaveLength(0);
|
|
796
|
+
expect(result.headId).toBeNull();
|
|
797
|
+
expect(() => new MessageRepository().import(result)).not.toThrow();
|
|
798
|
+
});
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
describe("useExternalHistory persistence", () => {
|
|
802
|
+
beforeEach(() => {
|
|
803
|
+
mocks.remoteId = undefined;
|
|
804
|
+
mocks.hasThreadListItem = false;
|
|
805
|
+
mocks.listeners.clear();
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
type InnerMessage = {
|
|
809
|
+
id: string;
|
|
810
|
+
parts: unknown[];
|
|
811
|
+
role?: string;
|
|
812
|
+
metadata?: Record<string, unknown>;
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
const persistenceStorageFormat: MessageFormatAdapter<
|
|
816
|
+
InnerMessage,
|
|
817
|
+
Record<string, unknown>
|
|
818
|
+
> = {
|
|
819
|
+
format: "test",
|
|
820
|
+
encode: (item) => ({ data: item.message }),
|
|
821
|
+
decode: (stored) => ({
|
|
822
|
+
parentId: stored.parent_id,
|
|
823
|
+
message: stored.content as unknown as InnerMessage,
|
|
824
|
+
}),
|
|
825
|
+
getId: (message) => message.id,
|
|
826
|
+
};
|
|
827
|
+
|
|
828
|
+
const createAssistantMessage = (
|
|
829
|
+
status: ThreadAssistantMessage["status"],
|
|
830
|
+
innerMessages: InnerMessage[],
|
|
831
|
+
id = "assistant-a",
|
|
832
|
+
): ThreadMessage => {
|
|
833
|
+
const message: ThreadAssistantMessage = {
|
|
834
|
+
id,
|
|
835
|
+
role: "assistant",
|
|
836
|
+
content: [],
|
|
837
|
+
createdAt: new Date(),
|
|
838
|
+
status,
|
|
839
|
+
metadata: {
|
|
840
|
+
unstable_state: null,
|
|
841
|
+
unstable_annotations: [],
|
|
842
|
+
unstable_data: [],
|
|
843
|
+
steps: [],
|
|
844
|
+
custom: {},
|
|
845
|
+
},
|
|
846
|
+
};
|
|
847
|
+
bindExternalStoreMessage(message, innerMessages);
|
|
848
|
+
return message;
|
|
849
|
+
};
|
|
850
|
+
|
|
851
|
+
const createUserMessage = (inner: InnerMessage) => {
|
|
852
|
+
const message = {
|
|
853
|
+
id: inner.id,
|
|
854
|
+
role: "user",
|
|
855
|
+
content: [],
|
|
856
|
+
} as unknown as ThreadMessage;
|
|
857
|
+
bindExternalStoreMessage(message, inner);
|
|
858
|
+
return message;
|
|
859
|
+
};
|
|
860
|
+
|
|
861
|
+
const stoppedPrompt: InnerMessage = {
|
|
862
|
+
id: "user-1",
|
|
863
|
+
role: "user",
|
|
864
|
+
parts: ["prompt"],
|
|
865
|
+
};
|
|
866
|
+
|
|
867
|
+
const createSlowLoadHarness = (resolveOnUnmount = true) => {
|
|
868
|
+
mocks.hasThreadListItem = true;
|
|
869
|
+
mocks.remoteId = "remote-thread";
|
|
870
|
+
|
|
871
|
+
const stored: MessageFormatRepository<InnerMessage> = {
|
|
872
|
+
headId: "stored",
|
|
873
|
+
messages: [
|
|
874
|
+
{
|
|
875
|
+
parentId: null,
|
|
876
|
+
message: { id: "stored", parts: ["earlier"] },
|
|
877
|
+
},
|
|
878
|
+
],
|
|
879
|
+
};
|
|
880
|
+
let resolveLoad!: (repo: MessageFormatRepository<InnerMessage>) => void;
|
|
881
|
+
let rejectLoad!: (error: Error) => void;
|
|
882
|
+
const load = vi
|
|
883
|
+
.fn()
|
|
884
|
+
.mockImplementationOnce(
|
|
885
|
+
() =>
|
|
886
|
+
new Promise<MessageFormatRepository<InnerMessage>>(
|
|
887
|
+
(resolve, reject) => {
|
|
888
|
+
resolveLoad = resolve;
|
|
889
|
+
rejectLoad = reject;
|
|
890
|
+
},
|
|
891
|
+
),
|
|
892
|
+
)
|
|
893
|
+
.mockImplementation(async () => ({
|
|
894
|
+
headId: stored.headId,
|
|
895
|
+
messages: [...stored.messages],
|
|
896
|
+
}));
|
|
897
|
+
const append = vi.fn(
|
|
898
|
+
async (
|
|
899
|
+
item: MessageFormatRepository<InnerMessage>["messages"][number],
|
|
900
|
+
) => {
|
|
901
|
+
stored.messages.push(item);
|
|
902
|
+
stored.headId = item.message.id;
|
|
903
|
+
},
|
|
904
|
+
);
|
|
905
|
+
const reportTelemetry = vi.fn();
|
|
906
|
+
const adapter: ThreadHistoryAdapter = {
|
|
907
|
+
load: vi.fn(),
|
|
908
|
+
append: vi.fn(),
|
|
909
|
+
withFormat: vi.fn().mockReturnValue({ load, append, reportTelemetry }),
|
|
910
|
+
};
|
|
911
|
+
const toMessages = (messages: InnerMessage[]) =>
|
|
912
|
+
messages.map((message) =>
|
|
913
|
+
createAssistantMessage(
|
|
914
|
+
{ type: "complete", reason: "stop" },
|
|
915
|
+
[message],
|
|
916
|
+
message.id,
|
|
917
|
+
),
|
|
918
|
+
);
|
|
919
|
+
const mount = () => {
|
|
920
|
+
let listener: (() => void) | undefined;
|
|
921
|
+
let isRunning = false;
|
|
922
|
+
let messages: ThreadMessage[] = [];
|
|
923
|
+
const importMessages = vi.fn((repo: ExportedMessageRepository) => {
|
|
924
|
+
messages = repo.messages.map((item) => item.message);
|
|
925
|
+
});
|
|
926
|
+
const thread = {
|
|
927
|
+
subscribe: (next: () => void) => {
|
|
928
|
+
listener = next;
|
|
929
|
+
return () => {};
|
|
930
|
+
},
|
|
931
|
+
getState: () => ({ isRunning, messages }),
|
|
932
|
+
import: importMessages,
|
|
933
|
+
} as unknown as AssistantRuntime["thread"];
|
|
934
|
+
const slowRuntimeRef = { current: { thread } as AssistantRuntime };
|
|
935
|
+
const hook = renderHook(() =>
|
|
936
|
+
useExternalHistory(
|
|
937
|
+
slowRuntimeRef,
|
|
938
|
+
adapter,
|
|
939
|
+
toMessages,
|
|
940
|
+
persistenceStorageFormat,
|
|
941
|
+
() => {},
|
|
942
|
+
),
|
|
943
|
+
);
|
|
944
|
+
return {
|
|
945
|
+
...hook,
|
|
946
|
+
importMessages,
|
|
947
|
+
step: async (next: {
|
|
948
|
+
isRunning: boolean;
|
|
949
|
+
messages: ThreadMessage[];
|
|
950
|
+
}) => {
|
|
951
|
+
await act(async () => {
|
|
952
|
+
isRunning = next.isRunning;
|
|
953
|
+
messages = next.messages;
|
|
954
|
+
listener?.();
|
|
955
|
+
});
|
|
956
|
+
},
|
|
957
|
+
settle: async () => {
|
|
958
|
+
await act(async () => {
|
|
959
|
+
isRunning = true;
|
|
960
|
+
messages = toMessages([{ id: "live", parts: ["reply"] }]);
|
|
961
|
+
listener?.();
|
|
962
|
+
});
|
|
963
|
+
await act(async () => {
|
|
964
|
+
isRunning = false;
|
|
965
|
+
listener?.();
|
|
966
|
+
});
|
|
967
|
+
},
|
|
968
|
+
};
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
const first = mount();
|
|
972
|
+
const unmount = first.unmount;
|
|
973
|
+
first.unmount = () => {
|
|
974
|
+
unmount();
|
|
975
|
+
if (resolveOnUnmount) {
|
|
976
|
+
resolveLoad({ headId: "stored", messages: [stored.messages[0]!] });
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
const settleBeforeLoad = async () => {
|
|
980
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
981
|
+
await first.settle();
|
|
982
|
+
await act(async () => {
|
|
983
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
984
|
+
});
|
|
985
|
+
const appendCountBeforeLoad = append.mock.calls.length;
|
|
986
|
+
await act(async () => {
|
|
987
|
+
resolveLoad({ headId: "stored", messages: [stored.messages[0]!] });
|
|
988
|
+
});
|
|
989
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
990
|
+
return appendCountBeforeLoad;
|
|
991
|
+
};
|
|
992
|
+
return {
|
|
993
|
+
append,
|
|
994
|
+
reportTelemetry,
|
|
995
|
+
first,
|
|
996
|
+
mount,
|
|
997
|
+
settleBeforeLoad,
|
|
998
|
+
resolveLoad: () =>
|
|
999
|
+
resolveLoad({ headId: "stored", messages: [stored.messages[0]!] }),
|
|
1000
|
+
resolveEmptyLoad: () => resolveLoad({ headId: null, messages: [] }),
|
|
1001
|
+
rejectLoad: () => rejectLoad(new Error("load failed")),
|
|
1002
|
+
};
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
it("waits for slow history before persisting a settled live turn", async () => {
|
|
1006
|
+
const { append, settleBeforeLoad } = createSlowLoadHarness();
|
|
1007
|
+
|
|
1008
|
+
const appendCountBeforeLoad = await settleBeforeLoad();
|
|
1009
|
+
|
|
1010
|
+
expect(append).toHaveBeenCalledWith({
|
|
1011
|
+
parentId: "stored",
|
|
1012
|
+
message: { id: "live", parts: ["reply"] },
|
|
1013
|
+
});
|
|
1014
|
+
expect(appendCountBeforeLoad).toBe(0);
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
it("persists a prompt sent before history loads beneath the loaded history", async () => {
|
|
1018
|
+
const { append, first, resolveLoad } = createSlowLoadHarness(false);
|
|
1019
|
+
const userInner: InnerMessage = {
|
|
1020
|
+
id: "user-1",
|
|
1021
|
+
role: "user",
|
|
1022
|
+
parts: ["prompt"],
|
|
1023
|
+
};
|
|
1024
|
+
const user = createUserMessage(userInner);
|
|
1025
|
+
const runningAssistant = createAssistantMessage(
|
|
1026
|
+
{ type: "running" },
|
|
1027
|
+
[{ id: "assistant-1", role: "assistant", parts: ["partial"] }],
|
|
1028
|
+
"assistant-1",
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
await first.step({ isRunning: true, messages: [user, runningAssistant] });
|
|
1032
|
+
await act(async () => {
|
|
1033
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1034
|
+
});
|
|
1035
|
+
expect(append).not.toHaveBeenCalled();
|
|
1036
|
+
|
|
1037
|
+
await act(async () => resolveLoad());
|
|
1038
|
+
|
|
1039
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
1040
|
+
expect(append).toHaveBeenCalledWith({
|
|
1041
|
+
parentId: "stored",
|
|
1042
|
+
message: userInner,
|
|
1043
|
+
});
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
it("persists a settled live turn when unmounted before history loads", async () => {
|
|
1047
|
+
const { append, first } = createSlowLoadHarness();
|
|
1048
|
+
await first.settle();
|
|
1049
|
+
await act(async () => {
|
|
1050
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1051
|
+
});
|
|
1052
|
+
expect(append).not.toHaveBeenCalled();
|
|
1053
|
+
|
|
1054
|
+
first.unmount();
|
|
1055
|
+
|
|
1056
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1057
|
+
expect(append).toHaveBeenCalledWith({
|
|
1058
|
+
parentId: "stored",
|
|
1059
|
+
message: { id: "live", parts: ["reply"] },
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
it("reloads an unmounted settled turn after its earlier history on one branch", async () => {
|
|
1064
|
+
const { append, first, mount, resolveLoad } = createSlowLoadHarness(false);
|
|
1065
|
+
await first.settle();
|
|
1066
|
+
await act(async () => {
|
|
1067
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1068
|
+
});
|
|
1069
|
+
first.unmount();
|
|
1070
|
+
expect(append).not.toHaveBeenCalled();
|
|
1071
|
+
|
|
1072
|
+
await act(async () => resolveLoad());
|
|
1073
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1074
|
+
|
|
1075
|
+
const second = mount();
|
|
1076
|
+
await waitFor(() => expect(second.importMessages).toHaveBeenCalledTimes(1));
|
|
1077
|
+
const imported = second.importMessages.mock.calls[0]![0];
|
|
1078
|
+
const repository = new MessageRepository();
|
|
1079
|
+
repository.import(imported);
|
|
1080
|
+
|
|
1081
|
+
expect(imported.headId).toBe("live");
|
|
1082
|
+
expect(
|
|
1083
|
+
imported.messages.map((item) => [item.parentId, item.message.id]),
|
|
1084
|
+
).toEqual([
|
|
1085
|
+
[null, "stored"],
|
|
1086
|
+
["stored", "live"],
|
|
1087
|
+
]);
|
|
1088
|
+
expect(repository.getMessages().map((message) => message.id)).toEqual([
|
|
1089
|
+
"stored",
|
|
1090
|
+
"live",
|
|
1091
|
+
]);
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("persists an unmounted settled turn when loaded history is empty", async () => {
|
|
1095
|
+
const { append, first, resolveEmptyLoad } = createSlowLoadHarness(false);
|
|
1096
|
+
await first.settle();
|
|
1097
|
+
first.unmount();
|
|
1098
|
+
await act(async () => {
|
|
1099
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1100
|
+
});
|
|
1101
|
+
expect(append).not.toHaveBeenCalled();
|
|
1102
|
+
|
|
1103
|
+
await act(async () => resolveEmptyLoad());
|
|
1104
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1105
|
+
expect(append).toHaveBeenCalledWith({
|
|
1106
|
+
parentId: null,
|
|
1107
|
+
message: { id: "live", parts: ["reply"] },
|
|
1108
|
+
});
|
|
1109
|
+
});
|
|
1110
|
+
|
|
1111
|
+
it("persists an unmounted settled turn when history loading fails", async () => {
|
|
1112
|
+
const { append, first, rejectLoad } = createSlowLoadHarness(false);
|
|
1113
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1114
|
+
onTestFinished(() => errorSpy.mockRestore());
|
|
1115
|
+
await first.settle();
|
|
1116
|
+
first.unmount();
|
|
1117
|
+
await act(async () => {
|
|
1118
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1119
|
+
});
|
|
1120
|
+
expect(append).not.toHaveBeenCalled();
|
|
1121
|
+
|
|
1122
|
+
await act(async () => rejectLoad());
|
|
1123
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1124
|
+
expect(append).toHaveBeenCalledWith({
|
|
1125
|
+
parentId: null,
|
|
1126
|
+
message: { id: "live", parts: ["reply"] },
|
|
1127
|
+
});
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
it("reloads the persisted turn after its earlier history on one branch", async () => {
|
|
1131
|
+
const { first, mount, settleBeforeLoad } = createSlowLoadHarness();
|
|
1132
|
+
await settleBeforeLoad();
|
|
1133
|
+
first.unmount();
|
|
1134
|
+
|
|
1135
|
+
const second = mount();
|
|
1136
|
+
await waitFor(() => expect(second.importMessages).toHaveBeenCalledTimes(1));
|
|
1137
|
+
const imported = second.importMessages.mock.calls[0]![0];
|
|
1138
|
+
const repository = new MessageRepository();
|
|
1139
|
+
repository.import(imported);
|
|
1140
|
+
|
|
1141
|
+
expect(imported.headId).toBe("live");
|
|
1142
|
+
expect(
|
|
1143
|
+
imported.messages.map((item) => [item.parentId, item.message.id]),
|
|
1144
|
+
).toEqual([
|
|
1145
|
+
[null, "stored"],
|
|
1146
|
+
["stored", "live"],
|
|
1147
|
+
]);
|
|
1148
|
+
expect(repository.getMessages().map((message) => message.id)).toEqual([
|
|
1149
|
+
"stored",
|
|
1150
|
+
"live",
|
|
1151
|
+
]);
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
const createPersistenceHarness = (
|
|
1155
|
+
supportsUpdate: boolean,
|
|
1156
|
+
options?: {
|
|
1157
|
+
loadMessages?: MessageFormatRepository<InnerMessage>;
|
|
1158
|
+
toThreadMessages?: (messages: InnerMessage[]) => ThreadMessage[];
|
|
1159
|
+
initialIsRunning?: boolean;
|
|
1160
|
+
skipRemoteId?: boolean;
|
|
1161
|
+
onSetMessages?: (messages: InnerMessage[]) => void;
|
|
1162
|
+
toolArtifacts?: Map<string, unknown>;
|
|
1163
|
+
onToolArtifactsRestored?: (next: Map<string, unknown>) => void;
|
|
1164
|
+
toolInteractions?: Map<string, Unstable_ToolInteractionLog>;
|
|
1165
|
+
onToolInteractionsRestored?: (
|
|
1166
|
+
next: Map<string, Unstable_ToolInteractionLog>,
|
|
1167
|
+
) => void;
|
|
1168
|
+
toolApprovalResponses?: Map<string, RespondToToolApprovalOptions>;
|
|
1169
|
+
onToolApprovalResponsesRestored?: () => void;
|
|
1170
|
+
},
|
|
1171
|
+
) => {
|
|
1172
|
+
const append = vi.fn(
|
|
1173
|
+
async (_item: { parentId: string | null; message: InnerMessage }) => {},
|
|
1174
|
+
);
|
|
1175
|
+
const update = vi.fn(
|
|
1176
|
+
async (
|
|
1177
|
+
_item: { parentId: string | null; message: InnerMessage },
|
|
1178
|
+
_localMessageId: string,
|
|
1179
|
+
) => {},
|
|
1180
|
+
);
|
|
1181
|
+
const deleteItems = vi.fn(
|
|
1182
|
+
async (
|
|
1183
|
+
_items: { parentId: string | null; message: InnerMessage }[],
|
|
1184
|
+
) => {},
|
|
1185
|
+
);
|
|
1186
|
+
const reportTelemetry = vi.fn();
|
|
1187
|
+
const load = vi
|
|
1188
|
+
.fn()
|
|
1189
|
+
.mockResolvedValue(options?.loadMessages ?? { messages: [] });
|
|
1190
|
+
const formattedAdapter = {
|
|
1191
|
+
load,
|
|
1192
|
+
append,
|
|
1193
|
+
delete: deleteItems,
|
|
1194
|
+
reportTelemetry,
|
|
1195
|
+
...(supportsUpdate ? { update } : {}),
|
|
1196
|
+
};
|
|
1197
|
+
const historyAdapter: ThreadHistoryAdapter = {
|
|
1198
|
+
load: vi.fn(),
|
|
1199
|
+
append: vi.fn(),
|
|
1200
|
+
withFormat: vi.fn().mockReturnValue(formattedAdapter),
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
let listener: (() => void) | undefined;
|
|
1204
|
+
let isRunning = options?.initialIsRunning ?? false;
|
|
1205
|
+
let messages: ThreadMessage[] = [];
|
|
1206
|
+
const getState = vi.fn(() => ({ isRunning, messages }));
|
|
1207
|
+
const importMessages = vi.fn();
|
|
1208
|
+
const thread = {
|
|
1209
|
+
subscribe: (nextListener: () => void) => {
|
|
1210
|
+
listener = nextListener;
|
|
1211
|
+
return () => {};
|
|
1212
|
+
},
|
|
1213
|
+
getState,
|
|
1214
|
+
import: importMessages,
|
|
1215
|
+
export: vi.fn(() => ({ headId: null, messages: [] })),
|
|
1216
|
+
} as unknown as AssistantRuntime["thread"];
|
|
1217
|
+
const persistenceRuntimeRef = {
|
|
1218
|
+
current: { thread } as AssistantRuntime,
|
|
1219
|
+
};
|
|
1220
|
+
|
|
1221
|
+
mocks.hasThreadListItem = true;
|
|
1222
|
+
mocks.remoteId = options?.skipRemoteId ? undefined : "remote-thread";
|
|
1223
|
+
|
|
1224
|
+
const { result, unmount } = renderHook(() =>
|
|
1225
|
+
useExternalHistory(
|
|
1226
|
+
persistenceRuntimeRef,
|
|
1227
|
+
historyAdapter,
|
|
1228
|
+
options?.toThreadMessages ?? (() => []),
|
|
1229
|
+
persistenceStorageFormat,
|
|
1230
|
+
options?.onSetMessages ?? (() => {}),
|
|
1231
|
+
options?.toolArtifacts && (() => options.toolArtifacts!),
|
|
1232
|
+
(next) => {
|
|
1233
|
+
options?.toolArtifacts?.clear();
|
|
1234
|
+
for (const [id, artifact] of next)
|
|
1235
|
+
options?.toolArtifacts?.set(id, artifact);
|
|
1236
|
+
options?.onToolArtifactsRestored?.(next);
|
|
1237
|
+
},
|
|
1238
|
+
options?.toolInteractions && (() => options.toolInteractions!),
|
|
1239
|
+
(next) => {
|
|
1240
|
+
options?.toolInteractions?.clear();
|
|
1241
|
+
for (const [id, log] of next) options?.toolInteractions?.set(id, log);
|
|
1242
|
+
options?.onToolInteractionsRestored?.(next);
|
|
1243
|
+
},
|
|
1244
|
+
options?.toolApprovalResponses,
|
|
1245
|
+
options?.onToolApprovalResponsesRestored,
|
|
1246
|
+
),
|
|
1247
|
+
);
|
|
1248
|
+
|
|
1249
|
+
const runCycle = async (nextMessages: ThreadMessage[]) => {
|
|
1250
|
+
await act(async () => {
|
|
1251
|
+
messages = nextMessages;
|
|
1252
|
+
isRunning = true;
|
|
1253
|
+
listener?.();
|
|
1254
|
+
});
|
|
1255
|
+
await act(async () => {
|
|
1256
|
+
isRunning = false;
|
|
1257
|
+
listener?.();
|
|
1258
|
+
});
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1261
|
+
const flush = () =>
|
|
1262
|
+
act(async () => {
|
|
1263
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
const step = (partial: {
|
|
1267
|
+
isRunning?: boolean;
|
|
1268
|
+
messages?: ThreadMessage[];
|
|
1269
|
+
}) =>
|
|
1270
|
+
act(async () => {
|
|
1271
|
+
if (partial.messages !== undefined) messages = partial.messages;
|
|
1272
|
+
if (partial.isRunning !== undefined) isRunning = partial.isRunning;
|
|
1273
|
+
listener?.();
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
return {
|
|
1277
|
+
append,
|
|
1278
|
+
update,
|
|
1279
|
+
deleteItems,
|
|
1280
|
+
formattedAdapter,
|
|
1281
|
+
importMessages,
|
|
1282
|
+
deleteMessage: result.current.deleteMessage,
|
|
1283
|
+
markRunCancelled: () => result.current.markRunCancelled(),
|
|
1284
|
+
reportTelemetry,
|
|
1285
|
+
load,
|
|
1286
|
+
persistToolInteractions: result.current.persistToolInteractions,
|
|
1287
|
+
persistToolApprovalResponses: result.current.persistToolApprovalResponses,
|
|
1288
|
+
runCycle,
|
|
1289
|
+
flush,
|
|
1290
|
+
step,
|
|
1291
|
+
unmount,
|
|
1292
|
+
};
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
it("clears tool data and invokes restore callbacks when loaded history is empty", async () => {
|
|
1296
|
+
const toolArtifacts = new Map<string, unknown>([
|
|
1297
|
+
["call-1", { preview: "72°F and sunny" }],
|
|
1298
|
+
]);
|
|
1299
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
|
|
1300
|
+
["call-1", { entries: [{ type: "action", occurredAt: 1, payload: {} }] }],
|
|
1301
|
+
]);
|
|
1302
|
+
const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
|
|
1303
|
+
[["approval-1", { approvalId: "approval-1", approved: true }]],
|
|
1304
|
+
);
|
|
1305
|
+
const onToolArtifactsRestored = vi.fn();
|
|
1306
|
+
const onToolInteractionsRestored = vi.fn();
|
|
1307
|
+
const onToolApprovalResponsesRestored = vi.fn();
|
|
1308
|
+
const { importMessages, load } = createPersistenceHarness(false, {
|
|
1309
|
+
loadMessages: { messages: [] },
|
|
1310
|
+
toolArtifacts,
|
|
1311
|
+
onToolArtifactsRestored,
|
|
1312
|
+
toolInteractions,
|
|
1313
|
+
onToolInteractionsRestored,
|
|
1314
|
+
toolApprovalResponses,
|
|
1315
|
+
onToolApprovalResponsesRestored,
|
|
1316
|
+
});
|
|
1317
|
+
|
|
1318
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1319
|
+
|
|
1320
|
+
expect(toolArtifacts).toEqual(new Map());
|
|
1321
|
+
expect(toolInteractions).toEqual(new Map());
|
|
1322
|
+
expect(toolApprovalResponses).toEqual(new Map());
|
|
1323
|
+
expect(onToolArtifactsRestored).toHaveBeenCalledOnce();
|
|
1324
|
+
expect(onToolInteractionsRestored).toHaveBeenCalledOnce();
|
|
1325
|
+
expect(onToolApprovalResponsesRestored).toHaveBeenCalledOnce();
|
|
1326
|
+
expect(importMessages).not.toHaveBeenCalled();
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it("persists a message that lands while the thread is idle", async () => {
|
|
1330
|
+
const { append, step } = createPersistenceHarness(false);
|
|
1331
|
+
const message = createAssistantMessage(
|
|
1332
|
+
{ type: "complete", reason: "stop" },
|
|
1333
|
+
[{ id: "spoken-1", parts: ["spoken"] }],
|
|
1334
|
+
);
|
|
1335
|
+
|
|
1336
|
+
await step({ messages: [message] });
|
|
1337
|
+
|
|
1338
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1339
|
+
expect(append).toHaveBeenCalledWith({
|
|
1340
|
+
parentId: null,
|
|
1341
|
+
message: { id: "spoken-1", parts: ["spoken"] },
|
|
1342
|
+
});
|
|
1343
|
+
});
|
|
1344
|
+
|
|
1345
|
+
it("persists a settled turn without a remote history load", async () => {
|
|
1346
|
+
const { append, load, runCycle } = createPersistenceHarness(false, {
|
|
1347
|
+
skipRemoteId: true,
|
|
1348
|
+
});
|
|
1349
|
+
const message = createAssistantMessage(
|
|
1350
|
+
{ type: "complete", reason: "stop" },
|
|
1351
|
+
[{ id: "local", parts: ["reply"] }],
|
|
1352
|
+
);
|
|
1353
|
+
|
|
1354
|
+
await runCycle([message]);
|
|
1355
|
+
|
|
1356
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1357
|
+
expect(load).not.toHaveBeenCalled();
|
|
1358
|
+
expect(append).toHaveBeenCalledWith({
|
|
1359
|
+
parentId: null,
|
|
1360
|
+
message: { id: "local", parts: ["reply"] },
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it("updates stored tool artifacts without mutating chat messages", async () => {
|
|
1365
|
+
const toolArtifacts = new Map<string, unknown>();
|
|
1366
|
+
const { append, update, runCycle } = createPersistenceHarness(true, {
|
|
1367
|
+
toolArtifacts,
|
|
1368
|
+
});
|
|
1369
|
+
const innerMessage: InnerMessage = {
|
|
1370
|
+
id: "inner-a",
|
|
1371
|
+
role: "assistant",
|
|
1372
|
+
parts: [
|
|
1373
|
+
{
|
|
1374
|
+
type: "tool-weather",
|
|
1375
|
+
toolCallId: "call-1",
|
|
1376
|
+
state: "output-available",
|
|
1377
|
+
input: { city: "New York" },
|
|
1378
|
+
output: { temperature: 72 },
|
|
1379
|
+
},
|
|
1380
|
+
],
|
|
1381
|
+
};
|
|
1382
|
+
const content: ThreadAssistantMessage["content"] = [
|
|
1383
|
+
{
|
|
1384
|
+
type: "tool-call",
|
|
1385
|
+
toolCallId: "call-1",
|
|
1386
|
+
toolName: "weather",
|
|
1387
|
+
args: { city: "New York" },
|
|
1388
|
+
argsText: '{"city":"New York"}',
|
|
1389
|
+
result: { temperature: 72 },
|
|
1390
|
+
isError: false,
|
|
1391
|
+
},
|
|
1392
|
+
];
|
|
1393
|
+
const message = Object.assign(
|
|
1394
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1395
|
+
innerMessage,
|
|
1396
|
+
]),
|
|
1397
|
+
{ content },
|
|
1398
|
+
);
|
|
1399
|
+
|
|
1400
|
+
await runCycle([message]);
|
|
1401
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1402
|
+
expect(append).toHaveBeenCalledWith({
|
|
1403
|
+
parentId: null,
|
|
1404
|
+
message: innerMessage,
|
|
1405
|
+
});
|
|
1406
|
+
|
|
1407
|
+
toolArtifacts.set("call-1", { preview: "72°F and sunny" });
|
|
1408
|
+
await runCycle([message]);
|
|
1409
|
+
|
|
1410
|
+
await waitFor(() =>
|
|
1411
|
+
expect(update).toHaveBeenCalledWith(
|
|
1412
|
+
{
|
|
1413
|
+
parentId: null,
|
|
1414
|
+
message: {
|
|
1415
|
+
...innerMessage,
|
|
1416
|
+
metadata: {
|
|
1417
|
+
__aui_toolArtifacts: {
|
|
1418
|
+
"call-1": { preview: "72°F and sunny" },
|
|
1419
|
+
},
|
|
1420
|
+
},
|
|
1421
|
+
},
|
|
1422
|
+
},
|
|
1423
|
+
"inner-a",
|
|
1424
|
+
),
|
|
1425
|
+
);
|
|
1426
|
+
expect(innerMessage.metadata).toBeUndefined();
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
it.each([
|
|
1430
|
+
[
|
|
1431
|
+
"a stopped run whose prompt core moved to the composer",
|
|
1432
|
+
true,
|
|
1433
|
+
[],
|
|
1434
|
+
"cancelled",
|
|
1435
|
+
true,
|
|
1436
|
+
],
|
|
1437
|
+
[
|
|
1438
|
+
"a stopped run whose prompt the composer could not take back",
|
|
1439
|
+
true,
|
|
1440
|
+
[createUserMessage(stoppedPrompt)],
|
|
1441
|
+
"cancelled",
|
|
1442
|
+
false,
|
|
1443
|
+
],
|
|
1444
|
+
[
|
|
1445
|
+
"a run that settled without output",
|
|
1446
|
+
false,
|
|
1447
|
+
[createUserMessage(stoppedPrompt)],
|
|
1448
|
+
"other",
|
|
1449
|
+
false,
|
|
1450
|
+
],
|
|
1451
|
+
[
|
|
1452
|
+
"a run that settled without output after its prompt disappeared",
|
|
1453
|
+
false,
|
|
1454
|
+
[],
|
|
1455
|
+
"other",
|
|
1456
|
+
false,
|
|
1457
|
+
],
|
|
1458
|
+
] as const)(
|
|
1459
|
+
"reports %s",
|
|
1460
|
+
async (
|
|
1461
|
+
_,
|
|
1462
|
+
cancelled,
|
|
1463
|
+
settledMessages: readonly ThreadMessage[],
|
|
1464
|
+
reason,
|
|
1465
|
+
shouldDelete,
|
|
1466
|
+
) => {
|
|
1467
|
+
const {
|
|
1468
|
+
append,
|
|
1469
|
+
deleteItems,
|
|
1470
|
+
load,
|
|
1471
|
+
markRunCancelled,
|
|
1472
|
+
reportTelemetry,
|
|
1473
|
+
step,
|
|
1474
|
+
flush,
|
|
1475
|
+
} = createPersistenceHarness(false);
|
|
1476
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1477
|
+
|
|
1478
|
+
await step({
|
|
1479
|
+
isRunning: true,
|
|
1480
|
+
messages: [createUserMessage(stoppedPrompt)],
|
|
1481
|
+
});
|
|
1482
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1483
|
+
if (cancelled) markRunCancelled();
|
|
1484
|
+
await step({ isRunning: false, messages: [...settledMessages] });
|
|
1485
|
+
await flush();
|
|
1486
|
+
|
|
1487
|
+
await waitFor(() =>
|
|
1488
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
1489
|
+
[],
|
|
1490
|
+
expect.objectContaining({
|
|
1491
|
+
message: expect.objectContaining({
|
|
1492
|
+
role: "assistant",
|
|
1493
|
+
status: { type: "incomplete", reason },
|
|
1494
|
+
}),
|
|
1495
|
+
}),
|
|
1496
|
+
),
|
|
1497
|
+
);
|
|
1498
|
+
expect(
|
|
1499
|
+
reportTelemetry.mock.calls.filter(
|
|
1500
|
+
([, options]) => options?.message?.role === "assistant",
|
|
1501
|
+
),
|
|
1502
|
+
).toHaveLength(1);
|
|
1503
|
+
if (shouldDelete) {
|
|
1504
|
+
expect(deleteItems).toHaveBeenCalledOnce();
|
|
1505
|
+
expect(deleteItems).toHaveBeenCalledWith([
|
|
1506
|
+
{ parentId: null, message: stoppedPrompt },
|
|
1507
|
+
]);
|
|
1508
|
+
} else {
|
|
1509
|
+
expect(deleteItems).not.toHaveBeenCalled();
|
|
1510
|
+
}
|
|
1511
|
+
},
|
|
1512
|
+
);
|
|
1513
|
+
|
|
1514
|
+
it("reports a reply that waited for tool approval with the prompt it answers", async () => {
|
|
1515
|
+
const { load, reportTelemetry, step, flush } =
|
|
1516
|
+
createPersistenceHarness(true);
|
|
1517
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1518
|
+
const first = createUserMessage({
|
|
1519
|
+
id: "user-1",
|
|
1520
|
+
role: "user",
|
|
1521
|
+
parts: ["first"],
|
|
1522
|
+
});
|
|
1523
|
+
const second = createUserMessage({
|
|
1524
|
+
id: "user-2",
|
|
1525
|
+
role: "user",
|
|
1526
|
+
parts: ["second"],
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
await step({ isRunning: true, messages: [first] });
|
|
1530
|
+
await step({
|
|
1531
|
+
isRunning: false,
|
|
1532
|
+
messages: [
|
|
1533
|
+
first,
|
|
1534
|
+
createAssistantMessage(
|
|
1535
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
1536
|
+
[{ id: "inner-a", parts: ["pending"] }],
|
|
1537
|
+
"assistant-a",
|
|
1538
|
+
),
|
|
1539
|
+
],
|
|
1540
|
+
});
|
|
1541
|
+
await flush();
|
|
1542
|
+
await step({
|
|
1543
|
+
isRunning: true,
|
|
1544
|
+
messages: [
|
|
1545
|
+
first,
|
|
1546
|
+
createAssistantMessage(
|
|
1547
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
1548
|
+
[{ id: "inner-a", parts: ["pending"] }],
|
|
1549
|
+
"assistant-a",
|
|
1550
|
+
),
|
|
1551
|
+
second,
|
|
1552
|
+
],
|
|
1553
|
+
});
|
|
1554
|
+
await step({
|
|
1555
|
+
isRunning: false,
|
|
1556
|
+
messages: [
|
|
1557
|
+
first,
|
|
1558
|
+
createAssistantMessage(
|
|
1559
|
+
{ type: "complete", reason: "stop" },
|
|
1560
|
+
[{ id: "inner-a", parts: ["pending", "final"] }],
|
|
1561
|
+
"assistant-a",
|
|
1562
|
+
),
|
|
1563
|
+
second,
|
|
1564
|
+
createAssistantMessage(
|
|
1565
|
+
{ type: "complete", reason: "stop" },
|
|
1566
|
+
[{ id: "inner-b", parts: ["answer"] }],
|
|
1567
|
+
"assistant-b",
|
|
1568
|
+
),
|
|
1569
|
+
],
|
|
1570
|
+
});
|
|
1571
|
+
await flush();
|
|
1572
|
+
|
|
1573
|
+
const reportedPrompts = () =>
|
|
1574
|
+
Object.fromEntries(
|
|
1575
|
+
reportTelemetry.mock.calls
|
|
1576
|
+
.filter(([, options]) => options?.message?.role === "assistant")
|
|
1577
|
+
.map(([, options]) => [options.message.id, options.userMessageId]),
|
|
1578
|
+
);
|
|
1579
|
+
await waitFor(() =>
|
|
1580
|
+
expect(reportedPrompts()).toEqual({
|
|
1581
|
+
"assistant-a": "user-1",
|
|
1582
|
+
"assistant-b": "user-2",
|
|
1583
|
+
}),
|
|
1584
|
+
);
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it("reports an unanswered cancelled run even when prompt deletion fails", async () => {
|
|
1588
|
+
const {
|
|
1589
|
+
append,
|
|
1590
|
+
deleteItems,
|
|
1591
|
+
load,
|
|
1592
|
+
markRunCancelled,
|
|
1593
|
+
reportTelemetry,
|
|
1594
|
+
step,
|
|
1595
|
+
flush,
|
|
1596
|
+
} = createPersistenceHarness(false);
|
|
1597
|
+
const failure = new Error("delete failed");
|
|
1598
|
+
deleteItems.mockRejectedValueOnce(failure);
|
|
1599
|
+
const log = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1600
|
+
try {
|
|
1601
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
1602
|
+
await step({
|
|
1603
|
+
isRunning: true,
|
|
1604
|
+
messages: [createUserMessage(stoppedPrompt)],
|
|
1605
|
+
});
|
|
1606
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
1607
|
+
markRunCancelled();
|
|
1608
|
+
await step({ isRunning: false, messages: [] });
|
|
1609
|
+
await flush();
|
|
1610
|
+
expect(deleteItems).toHaveBeenCalledOnce();
|
|
1611
|
+
expect(log).toHaveBeenCalledWith(
|
|
1612
|
+
"Failed to delete message history:",
|
|
1613
|
+
failure,
|
|
1614
|
+
);
|
|
1615
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
1616
|
+
[],
|
|
1617
|
+
expect.objectContaining({
|
|
1618
|
+
message: expect.objectContaining({
|
|
1619
|
+
status: { type: "incomplete", reason: "cancelled" },
|
|
1620
|
+
}),
|
|
1621
|
+
}),
|
|
1622
|
+
);
|
|
1623
|
+
} finally {
|
|
1624
|
+
log.mockRestore();
|
|
1625
|
+
}
|
|
1626
|
+
});
|
|
1627
|
+
|
|
1628
|
+
it("reports a run stopped while history is still loading", async () => {
|
|
1629
|
+
const { first, reportTelemetry, resolveEmptyLoad } =
|
|
1630
|
+
createSlowLoadHarness(false);
|
|
1631
|
+
|
|
1632
|
+
await first.step({
|
|
1633
|
+
isRunning: true,
|
|
1634
|
+
messages: [createUserMessage(stoppedPrompt)],
|
|
1635
|
+
});
|
|
1636
|
+
first.result.current.markRunCancelled();
|
|
1637
|
+
await first.step({ isRunning: false, messages: [] });
|
|
1638
|
+
await act(async () => {
|
|
1639
|
+
resolveEmptyLoad();
|
|
1640
|
+
});
|
|
1641
|
+
|
|
1642
|
+
await waitFor(() =>
|
|
1643
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
1644
|
+
[],
|
|
1645
|
+
expect.objectContaining({
|
|
1646
|
+
message: expect.objectContaining({
|
|
1647
|
+
status: { type: "incomplete", reason: "cancelled" },
|
|
1648
|
+
}),
|
|
1649
|
+
}),
|
|
1650
|
+
),
|
|
1651
|
+
);
|
|
1652
|
+
});
|
|
1653
|
+
|
|
1654
|
+
it("reports only the reply of a run that answered its prompt", async () => {
|
|
1655
|
+
const { load, reportTelemetry, runCycle, flush } =
|
|
1656
|
+
createPersistenceHarness(false);
|
|
1657
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1658
|
+
const reply = createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1659
|
+
{ id: "assistant-1", role: "assistant", parts: ["hi"] },
|
|
1660
|
+
]);
|
|
1661
|
+
|
|
1662
|
+
await runCycle([createUserMessage(stoppedPrompt), reply]);
|
|
1663
|
+
await flush();
|
|
1664
|
+
|
|
1665
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalled());
|
|
1666
|
+
expect(
|
|
1667
|
+
reportTelemetry.mock.calls.filter(
|
|
1668
|
+
([, options]) => options?.message?.role === "assistant",
|
|
1669
|
+
),
|
|
1670
|
+
).toEqual([
|
|
1671
|
+
[expect.any(Array), expect.objectContaining({ message: reply })],
|
|
1672
|
+
]);
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
it("keeps tool artifacts out of run telemetry", async () => {
|
|
1676
|
+
const toolArtifacts = new Map<string, unknown>([
|
|
1677
|
+
["call-1", { preview: "72°F and sunny" }],
|
|
1678
|
+
]);
|
|
1679
|
+
const { append, load, reportTelemetry, runCycle } =
|
|
1680
|
+
createPersistenceHarness(false, { toolArtifacts });
|
|
1681
|
+
const innerMessage: InnerMessage = {
|
|
1682
|
+
id: "inner-a",
|
|
1683
|
+
role: "assistant",
|
|
1684
|
+
parts: [
|
|
1685
|
+
{
|
|
1686
|
+
type: "tool-weather",
|
|
1687
|
+
toolCallId: "call-1",
|
|
1688
|
+
state: "output-available",
|
|
1689
|
+
},
|
|
1690
|
+
],
|
|
1691
|
+
};
|
|
1692
|
+
const message = Object.assign(
|
|
1693
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1694
|
+
innerMessage,
|
|
1695
|
+
]),
|
|
1696
|
+
{
|
|
1697
|
+
content: [
|
|
1698
|
+
{
|
|
1699
|
+
type: "tool-call" as const,
|
|
1700
|
+
toolCallId: "call-1",
|
|
1701
|
+
toolName: "weather",
|
|
1702
|
+
args: {},
|
|
1703
|
+
argsText: "{}",
|
|
1704
|
+
result: { temperature: 72 },
|
|
1705
|
+
isError: false,
|
|
1706
|
+
},
|
|
1707
|
+
],
|
|
1708
|
+
},
|
|
1709
|
+
);
|
|
1710
|
+
|
|
1711
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1712
|
+
toolArtifacts.set("call-1", { preview: "72°F and sunny" });
|
|
1713
|
+
|
|
1714
|
+
await runCycle([message]);
|
|
1715
|
+
|
|
1716
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
|
|
1717
|
+
expect(append.mock.calls[0]?.[0].message).toHaveProperty(
|
|
1718
|
+
"metadata.__aui_toolArtifacts",
|
|
1719
|
+
);
|
|
1720
|
+
expect(reportTelemetry.mock.calls[0]?.[0]).toEqual([
|
|
1721
|
+
{ parentId: null, message: innerMessage },
|
|
1722
|
+
]);
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it("keeps tool interactions and approval responses out of run telemetry", async () => {
|
|
1726
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
|
|
1727
|
+
[
|
|
1728
|
+
"call-1",
|
|
1729
|
+
{
|
|
1730
|
+
entries: [
|
|
1731
|
+
{
|
|
1732
|
+
type: "action",
|
|
1733
|
+
occurredAt: 1,
|
|
1734
|
+
payload: { refresh: true },
|
|
1735
|
+
},
|
|
1736
|
+
],
|
|
1737
|
+
},
|
|
1738
|
+
],
|
|
1739
|
+
]);
|
|
1740
|
+
const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
|
|
1741
|
+
[
|
|
1742
|
+
[
|
|
1743
|
+
"approval-1",
|
|
1744
|
+
{ approvalId: "approval-1", approved: true, reason: "Approved" },
|
|
1745
|
+
],
|
|
1746
|
+
],
|
|
1747
|
+
);
|
|
1748
|
+
const { append, load, reportTelemetry, runCycle } =
|
|
1749
|
+
createPersistenceHarness(false, {
|
|
1750
|
+
toolInteractions,
|
|
1751
|
+
toolApprovalResponses,
|
|
1752
|
+
});
|
|
1753
|
+
const innerMessage: InnerMessage = {
|
|
1754
|
+
id: "inner-a",
|
|
1755
|
+
role: "assistant",
|
|
1756
|
+
parts: [
|
|
1757
|
+
{
|
|
1758
|
+
type: "tool-weather",
|
|
1759
|
+
toolCallId: "call-1",
|
|
1760
|
+
state: "output-available",
|
|
1761
|
+
},
|
|
1762
|
+
{
|
|
1763
|
+
type: "tool-deploy",
|
|
1764
|
+
toolCallId: "call-2",
|
|
1765
|
+
state: "approval-requested",
|
|
1766
|
+
approval: { id: "approval-1" },
|
|
1767
|
+
},
|
|
1768
|
+
],
|
|
1769
|
+
};
|
|
1770
|
+
const message = Object.assign(
|
|
1771
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1772
|
+
innerMessage,
|
|
1773
|
+
]),
|
|
1774
|
+
{
|
|
1775
|
+
content: [
|
|
1776
|
+
{
|
|
1777
|
+
type: "tool-call" as const,
|
|
1778
|
+
toolCallId: "call-1",
|
|
1779
|
+
toolName: "weather",
|
|
1780
|
+
args: {},
|
|
1781
|
+
argsText: "{}",
|
|
1782
|
+
result: { temperature: 72 },
|
|
1783
|
+
isError: false,
|
|
1784
|
+
},
|
|
1785
|
+
{
|
|
1786
|
+
type: "tool-call" as const,
|
|
1787
|
+
toolCallId: "call-2",
|
|
1788
|
+
toolName: "deploy",
|
|
1789
|
+
args: {},
|
|
1790
|
+
argsText: "{}",
|
|
1791
|
+
result: undefined,
|
|
1792
|
+
isError: false,
|
|
1793
|
+
approval: { id: "approval-1" },
|
|
1794
|
+
},
|
|
1795
|
+
],
|
|
1796
|
+
},
|
|
1797
|
+
);
|
|
1798
|
+
|
|
1799
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1800
|
+
toolInteractions.set("call-1", {
|
|
1801
|
+
entries: [
|
|
1802
|
+
{
|
|
1803
|
+
type: "action",
|
|
1804
|
+
occurredAt: 1,
|
|
1805
|
+
payload: { refresh: true },
|
|
1806
|
+
},
|
|
1807
|
+
],
|
|
1808
|
+
});
|
|
1809
|
+
toolApprovalResponses.set("approval-1", {
|
|
1810
|
+
approvalId: "approval-1",
|
|
1811
|
+
approved: true,
|
|
1812
|
+
reason: "Approved",
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
await runCycle([message]);
|
|
1816
|
+
|
|
1817
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
|
|
1818
|
+
expect(append.mock.calls[0]?.[0].message).toMatchObject({
|
|
1819
|
+
metadata: {
|
|
1820
|
+
__aui_toolInteractions: { "call-1": toolInteractions.get("call-1") },
|
|
1821
|
+
__aui_toolApprovalResponses: {
|
|
1822
|
+
"approval-1": { approved: true, reason: "Approved" },
|
|
1823
|
+
},
|
|
1824
|
+
},
|
|
1825
|
+
});
|
|
1826
|
+
expect(reportTelemetry.mock.calls[0]?.[0]).toEqual([
|
|
1827
|
+
{ parentId: null, message: innerMessage },
|
|
1828
|
+
]);
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
it("stores host approval responses with the first message write", async () => {
|
|
1832
|
+
const toolApprovalResponses = new Map<string, RespondToToolApprovalOptions>(
|
|
1833
|
+
[
|
|
1834
|
+
[
|
|
1835
|
+
"approval-1",
|
|
1836
|
+
{
|
|
1837
|
+
approvalId: "approval-1",
|
|
1838
|
+
approved: true,
|
|
1839
|
+
optionId: "allow-once",
|
|
1840
|
+
text: "Staging only",
|
|
1841
|
+
reason: "Approved by operator",
|
|
1842
|
+
},
|
|
1843
|
+
],
|
|
1844
|
+
],
|
|
1845
|
+
);
|
|
1846
|
+
const { append, load, runCycle } = createPersistenceHarness(false, {
|
|
1847
|
+
toolApprovalResponses,
|
|
1848
|
+
});
|
|
1849
|
+
const innerMessage: InnerMessage = {
|
|
1850
|
+
id: "inner-a",
|
|
1851
|
+
role: "assistant",
|
|
1852
|
+
parts: [
|
|
1853
|
+
{
|
|
1854
|
+
type: "tool-deploy",
|
|
1855
|
+
toolCallId: "call-1",
|
|
1856
|
+
state: "approval-requested",
|
|
1857
|
+
approval: { id: "approval-1" },
|
|
1858
|
+
},
|
|
1859
|
+
],
|
|
1860
|
+
};
|
|
1861
|
+
const message = Object.assign(
|
|
1862
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1863
|
+
innerMessage,
|
|
1864
|
+
]),
|
|
1865
|
+
{
|
|
1866
|
+
content: [
|
|
1867
|
+
{
|
|
1868
|
+
type: "tool-call" as const,
|
|
1869
|
+
toolCallId: "call-1",
|
|
1870
|
+
toolName: "deploy",
|
|
1871
|
+
args: {},
|
|
1872
|
+
argsText: "{}",
|
|
1873
|
+
result: undefined,
|
|
1874
|
+
isError: false,
|
|
1875
|
+
approval: { id: "approval-1" },
|
|
1876
|
+
},
|
|
1877
|
+
],
|
|
1878
|
+
},
|
|
1879
|
+
);
|
|
1880
|
+
|
|
1881
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
1882
|
+
toolApprovalResponses.set("approval-1", {
|
|
1883
|
+
approvalId: "approval-1",
|
|
1884
|
+
approved: true,
|
|
1885
|
+
optionId: "allow-once",
|
|
1886
|
+
text: "Staging only",
|
|
1887
|
+
reason: "Approved by operator",
|
|
1888
|
+
});
|
|
1889
|
+
|
|
1890
|
+
await runCycle([message]);
|
|
1891
|
+
|
|
1892
|
+
await waitFor(() =>
|
|
1893
|
+
expect(append).toHaveBeenCalledWith({
|
|
1894
|
+
parentId: null,
|
|
1895
|
+
message: {
|
|
1896
|
+
...innerMessage,
|
|
1897
|
+
metadata: {
|
|
1898
|
+
__aui_toolApprovalResponses: {
|
|
1899
|
+
"approval-1": {
|
|
1900
|
+
approved: true,
|
|
1901
|
+
optionId: "allow-once",
|
|
1902
|
+
text: "Staging only",
|
|
1903
|
+
reason: "Approved by operator",
|
|
1904
|
+
},
|
|
1905
|
+
},
|
|
1906
|
+
},
|
|
1907
|
+
},
|
|
1908
|
+
}),
|
|
1909
|
+
);
|
|
1910
|
+
expect(innerMessage.metadata).toBeUndefined();
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
it("logs and retries a failed host approval response update after a settled run", async () => {
|
|
1914
|
+
const toolApprovalResponses = new Map<
|
|
1915
|
+
string,
|
|
1916
|
+
RespondToToolApprovalOptions
|
|
1917
|
+
>();
|
|
1918
|
+
const { append, update, persistToolApprovalResponses, runCycle } =
|
|
1919
|
+
createPersistenceHarness(true, { toolApprovalResponses });
|
|
1920
|
+
const innerMessage: InnerMessage = {
|
|
1921
|
+
id: "inner-a",
|
|
1922
|
+
role: "assistant",
|
|
1923
|
+
parts: [
|
|
1924
|
+
{
|
|
1925
|
+
type: "tool-deploy",
|
|
1926
|
+
toolCallId: "call-1",
|
|
1927
|
+
state: "approval-requested",
|
|
1928
|
+
approval: { id: "approval-1" },
|
|
1929
|
+
},
|
|
1930
|
+
],
|
|
1931
|
+
};
|
|
1932
|
+
const message = Object.assign(
|
|
1933
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
1934
|
+
innerMessage,
|
|
1935
|
+
]),
|
|
1936
|
+
{
|
|
1937
|
+
content: [
|
|
1938
|
+
{
|
|
1939
|
+
type: "tool-call" as const,
|
|
1940
|
+
toolCallId: "call-1",
|
|
1941
|
+
toolName: "deploy",
|
|
1942
|
+
args: {},
|
|
1943
|
+
argsText: "{}",
|
|
1944
|
+
result: undefined,
|
|
1945
|
+
isError: false,
|
|
1946
|
+
approval: { id: "approval-1" },
|
|
1947
|
+
},
|
|
1948
|
+
],
|
|
1949
|
+
},
|
|
1950
|
+
);
|
|
1951
|
+
|
|
1952
|
+
await runCycle([message]);
|
|
1953
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
1954
|
+
|
|
1955
|
+
toolApprovalResponses.set("approval-1", {
|
|
1956
|
+
approvalId: "approval-1",
|
|
1957
|
+
approved: false,
|
|
1958
|
+
reason: "Not now",
|
|
1959
|
+
});
|
|
1960
|
+
const failure = new Error("storage unavailable");
|
|
1961
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1962
|
+
update.mockRejectedValueOnce(failure);
|
|
1963
|
+
|
|
1964
|
+
try {
|
|
1965
|
+
await act(async () => {
|
|
1966
|
+
await persistToolApprovalResponses("inner-a");
|
|
1967
|
+
});
|
|
1968
|
+
|
|
1969
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
1970
|
+
"Failed to persist tool data:",
|
|
1971
|
+
failure,
|
|
1972
|
+
);
|
|
1973
|
+
await act(async () => {
|
|
1974
|
+
await persistToolApprovalResponses("inner-a");
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
1978
|
+
expect(update).toHaveBeenLastCalledWith(
|
|
1979
|
+
{
|
|
1980
|
+
parentId: null,
|
|
1981
|
+
message: {
|
|
1982
|
+
...innerMessage,
|
|
1983
|
+
metadata: {
|
|
1984
|
+
__aui_toolApprovalResponses: {
|
|
1985
|
+
"approval-1": { approved: false, reason: "Not now" },
|
|
1986
|
+
},
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
},
|
|
1990
|
+
"inner-a",
|
|
1991
|
+
);
|
|
1992
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
1993
|
+
expect(innerMessage.metadata).toBeUndefined();
|
|
1994
|
+
} finally {
|
|
1995
|
+
errorSpy.mockRestore();
|
|
1996
|
+
}
|
|
1997
|
+
});
|
|
1998
|
+
|
|
1999
|
+
it("stores tool interactions with the first message write", async () => {
|
|
2000
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>([
|
|
2001
|
+
[
|
|
2002
|
+
"call-1",
|
|
2003
|
+
{
|
|
2004
|
+
entries: [
|
|
2005
|
+
{
|
|
2006
|
+
type: "action",
|
|
2007
|
+
occurredAt: 1,
|
|
2008
|
+
payload: { refresh: true },
|
|
2009
|
+
},
|
|
2010
|
+
],
|
|
2011
|
+
},
|
|
2012
|
+
],
|
|
2013
|
+
]);
|
|
2014
|
+
const { append, load, runCycle } = createPersistenceHarness(false, {
|
|
2015
|
+
toolArtifacts: new Map(),
|
|
2016
|
+
toolInteractions,
|
|
2017
|
+
});
|
|
2018
|
+
const innerMessage: InnerMessage = {
|
|
2019
|
+
id: "inner-a",
|
|
2020
|
+
role: "assistant",
|
|
2021
|
+
parts: [
|
|
2022
|
+
{
|
|
2023
|
+
type: "tool-weather",
|
|
2024
|
+
toolCallId: "call-1",
|
|
2025
|
+
state: "output-available",
|
|
2026
|
+
},
|
|
2027
|
+
],
|
|
2028
|
+
};
|
|
2029
|
+
const message = Object.assign(
|
|
2030
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
2031
|
+
innerMessage,
|
|
2032
|
+
]),
|
|
2033
|
+
{
|
|
2034
|
+
content: [
|
|
2035
|
+
{
|
|
2036
|
+
type: "tool-call" as const,
|
|
2037
|
+
toolCallId: "call-1",
|
|
2038
|
+
toolName: "weather",
|
|
2039
|
+
args: {},
|
|
2040
|
+
argsText: "{}",
|
|
2041
|
+
result: { temperature: 72 },
|
|
2042
|
+
isError: false,
|
|
2043
|
+
},
|
|
2044
|
+
],
|
|
2045
|
+
},
|
|
2046
|
+
);
|
|
2047
|
+
|
|
2048
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
2049
|
+
toolInteractions.set("call-1", {
|
|
2050
|
+
entries: [
|
|
2051
|
+
{
|
|
2052
|
+
type: "action",
|
|
2053
|
+
occurredAt: 1,
|
|
2054
|
+
payload: { refresh: true },
|
|
2055
|
+
},
|
|
2056
|
+
],
|
|
2057
|
+
});
|
|
2058
|
+
|
|
2059
|
+
await runCycle([message]);
|
|
2060
|
+
|
|
2061
|
+
await waitFor(() =>
|
|
2062
|
+
expect(append).toHaveBeenCalledWith({
|
|
2063
|
+
parentId: null,
|
|
2064
|
+
message: {
|
|
2065
|
+
...innerMessage,
|
|
2066
|
+
metadata: {
|
|
2067
|
+
__aui_toolInteractions: {
|
|
2068
|
+
"call-1": toolInteractions.get("call-1"),
|
|
2069
|
+
},
|
|
2070
|
+
},
|
|
2071
|
+
},
|
|
2072
|
+
}),
|
|
2073
|
+
);
|
|
2074
|
+
expect(innerMessage.metadata).toBeUndefined();
|
|
2075
|
+
});
|
|
2076
|
+
|
|
2077
|
+
it("leaves stored rows unchanged without tool artifacts", async () => {
|
|
2078
|
+
const toolArtifacts = new Map<string, unknown>();
|
|
2079
|
+
const { append, runCycle } = createPersistenceHarness(false, {
|
|
2080
|
+
toolArtifacts,
|
|
2081
|
+
});
|
|
2082
|
+
const innerMessage = { id: "inner-a", parts: ["answer"] };
|
|
2083
|
+
const message = createAssistantMessage(
|
|
2084
|
+
{ type: "complete", reason: "stop" },
|
|
2085
|
+
[innerMessage],
|
|
2086
|
+
);
|
|
2087
|
+
|
|
2088
|
+
await runCycle([message]);
|
|
2089
|
+
|
|
2090
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2091
|
+
expect(append).toHaveBeenCalledWith({
|
|
2092
|
+
parentId: null,
|
|
2093
|
+
message: innerMessage,
|
|
2094
|
+
});
|
|
2095
|
+
expect(append.mock.calls[0]?.[0].message).toBe(innerMessage);
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
it("logs and retries a failed tool interaction update after a settled run", async () => {
|
|
2099
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
|
|
2100
|
+
const { append, update, persistToolInteractions, runCycle } =
|
|
2101
|
+
createPersistenceHarness(true, { toolInteractions });
|
|
2102
|
+
const initialInnerMessage: InnerMessage = {
|
|
2103
|
+
id: "inner-a",
|
|
2104
|
+
role: "assistant",
|
|
2105
|
+
parts: [
|
|
2106
|
+
{
|
|
2107
|
+
type: "tool-weather",
|
|
2108
|
+
toolCallId: "call-1",
|
|
2109
|
+
state: "output-available",
|
|
2110
|
+
},
|
|
2111
|
+
],
|
|
2112
|
+
};
|
|
2113
|
+
const createMessage = (innerMessage: InnerMessage) =>
|
|
2114
|
+
Object.assign(
|
|
2115
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
2116
|
+
innerMessage,
|
|
2117
|
+
]),
|
|
2118
|
+
{
|
|
2119
|
+
content: [
|
|
2120
|
+
{
|
|
2121
|
+
type: "tool-call" as const,
|
|
2122
|
+
toolCallId: "call-1",
|
|
2123
|
+
toolName: "weather",
|
|
2124
|
+
args: {},
|
|
2125
|
+
argsText: "{}",
|
|
2126
|
+
result: undefined,
|
|
2127
|
+
isError: false,
|
|
2128
|
+
},
|
|
2129
|
+
],
|
|
2130
|
+
},
|
|
2131
|
+
);
|
|
2132
|
+
|
|
2133
|
+
await runCycle([createMessage(initialInnerMessage)]);
|
|
2134
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2135
|
+
|
|
2136
|
+
const log: Unstable_ToolInteractionLog = {
|
|
2137
|
+
entries: [
|
|
2138
|
+
{
|
|
2139
|
+
type: "human-response",
|
|
2140
|
+
occurredAt: 2,
|
|
2141
|
+
payload: "yes",
|
|
2142
|
+
},
|
|
2143
|
+
],
|
|
2144
|
+
};
|
|
2145
|
+
toolInteractions.set("call-1", log);
|
|
2146
|
+
const failure = new Error("storage unavailable");
|
|
2147
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
2148
|
+
update.mockRejectedValueOnce(failure);
|
|
2149
|
+
|
|
2150
|
+
try {
|
|
2151
|
+
await act(async () => {
|
|
2152
|
+
await persistToolInteractions("assistant-a");
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
expect(errorSpy).toHaveBeenCalledWith(
|
|
2156
|
+
"Failed to persist tool data:",
|
|
2157
|
+
failure,
|
|
2158
|
+
);
|
|
2159
|
+
await act(async () => {
|
|
2160
|
+
await persistToolInteractions("assistant-a");
|
|
2161
|
+
});
|
|
2162
|
+
|
|
2163
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
2164
|
+
expect(update).toHaveBeenLastCalledWith(
|
|
2165
|
+
{
|
|
2166
|
+
parentId: null,
|
|
2167
|
+
message: {
|
|
2168
|
+
...initialInnerMessage,
|
|
2169
|
+
metadata: {
|
|
2170
|
+
__aui_toolInteractions: { "call-1": log },
|
|
2171
|
+
},
|
|
2172
|
+
},
|
|
2173
|
+
},
|
|
2174
|
+
"inner-a",
|
|
2175
|
+
);
|
|
2176
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
2177
|
+
|
|
2178
|
+
const rewrittenInnerMessage = {
|
|
2179
|
+
...initialInnerMessage,
|
|
2180
|
+
parts: [...initialInnerMessage.parts, { type: "text", text: "done" }],
|
|
2181
|
+
};
|
|
2182
|
+
await runCycle([createMessage(rewrittenInnerMessage)]);
|
|
2183
|
+
|
|
2184
|
+
await waitFor(() =>
|
|
2185
|
+
expect(update).toHaveBeenLastCalledWith(
|
|
2186
|
+
{
|
|
2187
|
+
parentId: null,
|
|
2188
|
+
message: {
|
|
2189
|
+
...rewrittenInnerMessage,
|
|
2190
|
+
metadata: {
|
|
2191
|
+
__aui_toolInteractions: { "call-1": log },
|
|
2192
|
+
},
|
|
2193
|
+
},
|
|
2194
|
+
},
|
|
2195
|
+
"inner-a",
|
|
2196
|
+
),
|
|
2197
|
+
);
|
|
2198
|
+
} finally {
|
|
2199
|
+
errorSpy.mockRestore();
|
|
2200
|
+
}
|
|
2201
|
+
});
|
|
2202
|
+
|
|
2203
|
+
it("leaves stored rows unchanged without tool artifacts or interactions", async () => {
|
|
2204
|
+
const toolArtifacts = new Map<string, unknown>();
|
|
2205
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
|
|
2206
|
+
const { append, runCycle } = createPersistenceHarness(false, {
|
|
2207
|
+
toolArtifacts,
|
|
2208
|
+
toolInteractions,
|
|
2209
|
+
});
|
|
2210
|
+
const innerMessage = {
|
|
2211
|
+
id: "inner-a",
|
|
2212
|
+
parts: [
|
|
2213
|
+
{
|
|
2214
|
+
type: "tool-weather",
|
|
2215
|
+
toolCallId: "call-1",
|
|
2216
|
+
state: "output-available",
|
|
2217
|
+
},
|
|
2218
|
+
],
|
|
2219
|
+
};
|
|
2220
|
+
const message = Object.assign(
|
|
2221
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
2222
|
+
innerMessage,
|
|
2223
|
+
]),
|
|
2224
|
+
{
|
|
2225
|
+
content: [
|
|
2226
|
+
{
|
|
2227
|
+
type: "tool-call" as const,
|
|
2228
|
+
toolCallId: "call-1",
|
|
2229
|
+
toolName: "weather",
|
|
2230
|
+
args: {},
|
|
2231
|
+
argsText: "{}",
|
|
2232
|
+
result: undefined,
|
|
2233
|
+
isError: false,
|
|
2234
|
+
},
|
|
2235
|
+
],
|
|
2236
|
+
},
|
|
2237
|
+
);
|
|
2238
|
+
|
|
2239
|
+
await runCycle([message]);
|
|
2240
|
+
|
|
2241
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2242
|
+
expect(append).toHaveBeenCalledWith({
|
|
2243
|
+
parentId: null,
|
|
2244
|
+
message: innerMessage,
|
|
2245
|
+
});
|
|
2246
|
+
expect(append.mock.calls[0]?.[0].message).toBe(innerMessage);
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it("restores stored tool interactions without returning metadata to the chat", async () => {
|
|
2250
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
|
|
2251
|
+
const onSetMessages = vi.fn();
|
|
2252
|
+
const log: Unstable_ToolInteractionLog = {
|
|
2253
|
+
entries: [
|
|
2254
|
+
{
|
|
2255
|
+
type: "human-response",
|
|
2256
|
+
occurredAt: 3,
|
|
2257
|
+
payload: "yes",
|
|
2258
|
+
},
|
|
2259
|
+
],
|
|
2260
|
+
};
|
|
2261
|
+
const storedMessage: InnerMessage = {
|
|
2262
|
+
id: "inner-a",
|
|
2263
|
+
role: "assistant",
|
|
2264
|
+
parts: [
|
|
2265
|
+
{
|
|
2266
|
+
type: "tool-weather",
|
|
2267
|
+
toolCallId: "call-1",
|
|
2268
|
+
state: "output-available",
|
|
2269
|
+
},
|
|
2270
|
+
],
|
|
2271
|
+
metadata: {
|
|
2272
|
+
__aui_toolInteractions: { "call-1": log },
|
|
2273
|
+
},
|
|
2274
|
+
};
|
|
2275
|
+
const { importMessages, load } = createPersistenceHarness(true, {
|
|
2276
|
+
loadMessages: {
|
|
2277
|
+
messages: [{ parentId: null, message: storedMessage }],
|
|
2278
|
+
},
|
|
2279
|
+
toThreadMessages: (messages) =>
|
|
2280
|
+
messages.map((message) => {
|
|
2281
|
+
const tool = message.parts[0] as { toolCallId: string };
|
|
2282
|
+
const converted = createAssistantMessage(
|
|
2283
|
+
{ type: "complete", reason: "stop" },
|
|
2284
|
+
[message],
|
|
2285
|
+
message.id,
|
|
2286
|
+
) as ThreadAssistantMessage;
|
|
2287
|
+
return Object.assign(converted, {
|
|
2288
|
+
content: [
|
|
2289
|
+
{
|
|
2290
|
+
type: "tool-call" as const,
|
|
2291
|
+
toolCallId: tool.toolCallId,
|
|
2292
|
+
toolName: "weather",
|
|
2293
|
+
args: {},
|
|
2294
|
+
argsText: "{}",
|
|
2295
|
+
result: undefined,
|
|
2296
|
+
isError: false,
|
|
2297
|
+
unstable_interactions: toolInteractions.get(tool.toolCallId),
|
|
2298
|
+
},
|
|
2299
|
+
],
|
|
2300
|
+
});
|
|
2301
|
+
}),
|
|
2302
|
+
onSetMessages,
|
|
2303
|
+
toolInteractions,
|
|
2304
|
+
});
|
|
2305
|
+
|
|
2306
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
2307
|
+
await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
|
|
2308
|
+
|
|
2309
|
+
const imported = importMessages.mock.calls[0]?.[0] as {
|
|
2310
|
+
messages: { message: ThreadMessage }[];
|
|
2311
|
+
};
|
|
2312
|
+
expect(imported.messages[0]?.message.content[0]).toMatchObject({
|
|
2313
|
+
type: "tool-call",
|
|
2314
|
+
toolCallId: "call-1",
|
|
2315
|
+
unstable_interactions: log,
|
|
2316
|
+
});
|
|
2317
|
+
expect(toolInteractions.get("call-1")).toEqual(log);
|
|
2318
|
+
expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
|
|
2319
|
+
"metadata.__aui_toolInteractions",
|
|
2320
|
+
);
|
|
2321
|
+
});
|
|
2322
|
+
|
|
2323
|
+
it("restores host approval responses without returning metadata to the chat", async () => {
|
|
2324
|
+
const toolApprovalResponses = new Map<
|
|
2325
|
+
string,
|
|
2326
|
+
RespondToToolApprovalOptions
|
|
2327
|
+
>();
|
|
2328
|
+
const onSetMessages = vi.fn();
|
|
2329
|
+
const storedMessage: InnerMessage = {
|
|
2330
|
+
id: "inner-a",
|
|
2331
|
+
role: "assistant",
|
|
2332
|
+
parts: [
|
|
2333
|
+
{
|
|
2334
|
+
type: "tool-deploy",
|
|
2335
|
+
toolCallId: "call-1",
|
|
2336
|
+
state: "approval-requested",
|
|
2337
|
+
approval: { id: "approval-1" },
|
|
2338
|
+
},
|
|
2339
|
+
],
|
|
2340
|
+
metadata: {
|
|
2341
|
+
__aui_toolApprovalResponses: {
|
|
2342
|
+
"approval-1": {
|
|
2343
|
+
approved: true,
|
|
2344
|
+
optionId: "allow-once",
|
|
2345
|
+
text: "Staging only",
|
|
2346
|
+
reason: "Approved by operator",
|
|
2347
|
+
},
|
|
2348
|
+
},
|
|
2349
|
+
},
|
|
2350
|
+
};
|
|
2351
|
+
const { importMessages, load } = createPersistenceHarness(true, {
|
|
2352
|
+
loadMessages: {
|
|
2353
|
+
messages: [{ parentId: null, message: storedMessage }],
|
|
2354
|
+
},
|
|
2355
|
+
toThreadMessages: (messages) =>
|
|
2356
|
+
messages.map((message) => {
|
|
2357
|
+
const tool = message.parts[0] as {
|
|
2358
|
+
toolCallId: string;
|
|
2359
|
+
approval: { id: string };
|
|
2360
|
+
};
|
|
2361
|
+
const response = toolApprovalResponses.get(tool.approval.id);
|
|
2362
|
+
const converted = createAssistantMessage(
|
|
2363
|
+
{ type: "complete", reason: "stop" },
|
|
2364
|
+
[message],
|
|
2365
|
+
message.id,
|
|
2366
|
+
) as ThreadAssistantMessage;
|
|
2367
|
+
return Object.assign(converted, {
|
|
2368
|
+
content: [
|
|
2369
|
+
{
|
|
2370
|
+
type: "tool-call" as const,
|
|
2371
|
+
toolCallId: tool.toolCallId,
|
|
2372
|
+
toolName: "deploy",
|
|
2373
|
+
args: {},
|
|
2374
|
+
argsText: "{}",
|
|
2375
|
+
result: undefined,
|
|
2376
|
+
isError: false,
|
|
2377
|
+
approval: {
|
|
2378
|
+
id: tool.approval.id,
|
|
2379
|
+
...(response && { approved: response.approved }),
|
|
2380
|
+
...(response?.optionId && { optionId: response.optionId }),
|
|
2381
|
+
...(response?.text && { text: response.text }),
|
|
2382
|
+
...(response?.reason && { reason: response.reason }),
|
|
2383
|
+
},
|
|
2384
|
+
},
|
|
2385
|
+
],
|
|
2386
|
+
});
|
|
2387
|
+
}),
|
|
2388
|
+
onSetMessages,
|
|
2389
|
+
toolApprovalResponses,
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
2393
|
+
await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
|
|
2394
|
+
|
|
2395
|
+
const imported = importMessages.mock.calls[0]?.[0] as {
|
|
2396
|
+
messages: { message: ThreadMessage }[];
|
|
2397
|
+
};
|
|
2398
|
+
expect(imported.messages[0]?.message.content[0]).toMatchObject({
|
|
2399
|
+
type: "tool-call",
|
|
2400
|
+
toolCallId: "call-1",
|
|
2401
|
+
approval: {
|
|
2402
|
+
id: "approval-1",
|
|
2403
|
+
approved: true,
|
|
2404
|
+
optionId: "allow-once",
|
|
2405
|
+
text: "Staging only",
|
|
2406
|
+
reason: "Approved by operator",
|
|
2407
|
+
},
|
|
2408
|
+
});
|
|
2409
|
+
expect(toolApprovalResponses.get("approval-1")).toEqual({
|
|
2410
|
+
approvalId: "approval-1",
|
|
2411
|
+
approved: true,
|
|
2412
|
+
optionId: "allow-once",
|
|
2413
|
+
text: "Staging only",
|
|
2414
|
+
reason: "Approved by operator",
|
|
2415
|
+
});
|
|
2416
|
+
expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
|
|
2417
|
+
"metadata.__aui_toolApprovalResponses",
|
|
2418
|
+
);
|
|
2419
|
+
});
|
|
2420
|
+
|
|
2421
|
+
it("stores and restores questionnaire answers with the approval response", async () => {
|
|
2422
|
+
const answers = {
|
|
2423
|
+
scope: { optionIds: ["src", "tests"] },
|
|
2424
|
+
note: { text: "keep it short" },
|
|
2425
|
+
};
|
|
2426
|
+
const saved = new Map<string, RespondToToolApprovalOptions>();
|
|
2427
|
+
const writer = createPersistenceHarness(false, {
|
|
2428
|
+
toolApprovalResponses: saved,
|
|
2429
|
+
});
|
|
2430
|
+
const innerMessage: InnerMessage = {
|
|
2431
|
+
id: "inner-a",
|
|
2432
|
+
role: "assistant",
|
|
2433
|
+
parts: [
|
|
2434
|
+
{
|
|
2435
|
+
type: "tool-ask",
|
|
2436
|
+
toolCallId: "call-1",
|
|
2437
|
+
state: "approval-requested",
|
|
2438
|
+
approval: { id: "approval-1" },
|
|
2439
|
+
},
|
|
2440
|
+
],
|
|
2441
|
+
};
|
|
2442
|
+
const message = Object.assign(
|
|
2443
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
2444
|
+
innerMessage,
|
|
2445
|
+
]),
|
|
2446
|
+
{
|
|
2447
|
+
content: [
|
|
2448
|
+
{
|
|
2449
|
+
type: "tool-call" as const,
|
|
2450
|
+
toolCallId: "call-1",
|
|
2451
|
+
toolName: "ask",
|
|
2452
|
+
args: {},
|
|
2453
|
+
argsText: "{}",
|
|
2454
|
+
result: undefined,
|
|
2455
|
+
isError: false,
|
|
2456
|
+
approval: { id: "approval-1" },
|
|
2457
|
+
},
|
|
2458
|
+
],
|
|
2459
|
+
},
|
|
2460
|
+
);
|
|
2461
|
+
await waitFor(() => expect(writer.load).toHaveBeenCalledTimes(1));
|
|
2462
|
+
saved.set("approval-1", {
|
|
2463
|
+
approvalId: "approval-1",
|
|
2464
|
+
approved: true,
|
|
2465
|
+
answers,
|
|
2466
|
+
});
|
|
2467
|
+
await writer.runCycle([message]);
|
|
2468
|
+
await waitFor(() =>
|
|
2469
|
+
expect(writer.append).toHaveBeenCalledWith({
|
|
2470
|
+
parentId: null,
|
|
2471
|
+
message: {
|
|
2472
|
+
...innerMessage,
|
|
2473
|
+
metadata: {
|
|
2474
|
+
__aui_toolApprovalResponses: {
|
|
2475
|
+
"approval-1": { approved: true, answers },
|
|
2476
|
+
},
|
|
2477
|
+
},
|
|
2478
|
+
},
|
|
2479
|
+
}),
|
|
2480
|
+
);
|
|
2481
|
+
writer.unmount();
|
|
2482
|
+
|
|
2483
|
+
const restored = new Map<string, RespondToToolApprovalOptions>();
|
|
2484
|
+
const reader = createPersistenceHarness(true, {
|
|
2485
|
+
loadMessages: {
|
|
2486
|
+
messages: [
|
|
2487
|
+
{
|
|
2488
|
+
parentId: null,
|
|
2489
|
+
message: {
|
|
2490
|
+
...innerMessage,
|
|
2491
|
+
metadata: {
|
|
2492
|
+
__aui_toolApprovalResponses: {
|
|
2493
|
+
"approval-1": {
|
|
2494
|
+
approved: true,
|
|
2495
|
+
answers: { ...answers, junk: { optionIds: [1] } },
|
|
2496
|
+
},
|
|
2497
|
+
},
|
|
2498
|
+
},
|
|
2499
|
+
},
|
|
2500
|
+
},
|
|
2501
|
+
],
|
|
2502
|
+
},
|
|
2503
|
+
toolApprovalResponses: restored,
|
|
2504
|
+
});
|
|
2505
|
+
await waitFor(() => expect(reader.load).toHaveBeenCalledTimes(1));
|
|
2506
|
+
await waitFor(() =>
|
|
2507
|
+
expect(restored.get("approval-1")).toEqual({
|
|
2508
|
+
approvalId: "approval-1",
|
|
2509
|
+
approved: true,
|
|
2510
|
+
answers,
|
|
2511
|
+
}),
|
|
2512
|
+
);
|
|
2513
|
+
});
|
|
2514
|
+
|
|
2515
|
+
it("restores stored tool artifacts without returning metadata to the chat", async () => {
|
|
2516
|
+
const toolArtifacts = new Map<string, unknown>();
|
|
2517
|
+
const onSetMessages = vi.fn();
|
|
2518
|
+
const storedMessage: InnerMessage = {
|
|
2519
|
+
id: "inner-a",
|
|
2520
|
+
role: "assistant",
|
|
2521
|
+
parts: [
|
|
2522
|
+
{
|
|
2523
|
+
type: "tool-weather",
|
|
2524
|
+
toolCallId: "call-1",
|
|
2525
|
+
state: "output-available",
|
|
2526
|
+
input: { city: "New York" },
|
|
2527
|
+
output: { temperature: 72 },
|
|
2528
|
+
},
|
|
2529
|
+
],
|
|
2530
|
+
metadata: {
|
|
2531
|
+
__aui_toolArtifacts: {
|
|
2532
|
+
"call-1": { preview: "72°F and sunny" },
|
|
2533
|
+
},
|
|
2534
|
+
},
|
|
2535
|
+
};
|
|
2536
|
+
const { importMessages, load } = createPersistenceHarness(true, {
|
|
2537
|
+
loadMessages: {
|
|
2538
|
+
messages: [{ parentId: null, message: storedMessage }],
|
|
2539
|
+
},
|
|
2540
|
+
toThreadMessages: (messages) =>
|
|
2541
|
+
messages.map((message) => {
|
|
2542
|
+
const tool = message.parts[0] as { toolCallId: string };
|
|
2543
|
+
const converted = createAssistantMessage(
|
|
2544
|
+
{ type: "complete", reason: "stop" },
|
|
2545
|
+
[message],
|
|
2546
|
+
message.id,
|
|
2547
|
+
) as ThreadAssistantMessage;
|
|
2548
|
+
return Object.assign(converted, {
|
|
2549
|
+
content: [
|
|
2550
|
+
{
|
|
2551
|
+
type: "tool-call" as const,
|
|
2552
|
+
toolCallId: tool.toolCallId,
|
|
2553
|
+
toolName: "weather",
|
|
2554
|
+
args: { city: "New York" },
|
|
2555
|
+
argsText: '{"city":"New York"}',
|
|
2556
|
+
result: { temperature: 72 },
|
|
2557
|
+
isError: false,
|
|
2558
|
+
artifact: toolArtifacts.get(tool.toolCallId),
|
|
2559
|
+
},
|
|
2560
|
+
],
|
|
2561
|
+
});
|
|
2562
|
+
}),
|
|
2563
|
+
onSetMessages,
|
|
2564
|
+
toolArtifacts,
|
|
2565
|
+
});
|
|
2566
|
+
|
|
2567
|
+
await waitFor(() => expect(load).toHaveBeenCalledTimes(1));
|
|
2568
|
+
await waitFor(() => expect(importMessages).toHaveBeenCalledTimes(1));
|
|
2569
|
+
|
|
2570
|
+
const imported = importMessages.mock.calls[0]?.[0] as {
|
|
2571
|
+
messages: { message: ThreadMessage }[];
|
|
2572
|
+
};
|
|
2573
|
+
expect(imported.messages[0]?.message.content[0]).toMatchObject({
|
|
2574
|
+
type: "tool-call",
|
|
2575
|
+
toolCallId: "call-1",
|
|
2576
|
+
artifact: { preview: "72°F and sunny" },
|
|
2577
|
+
});
|
|
2578
|
+
expect(toolArtifacts.get("call-1")).toEqual({
|
|
2579
|
+
preview: "72°F and sunny",
|
|
2580
|
+
});
|
|
2581
|
+
expect(onSetMessages.mock.calls[0]?.[0][0]).not.toHaveProperty(
|
|
2582
|
+
"metadata.__aui_toolArtifacts",
|
|
2583
|
+
);
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it("schedules one idle persistence attempt per landed message when append fails", async () => {
|
|
2587
|
+
const { append, step, flush } = createPersistenceHarness(false);
|
|
2588
|
+
append.mockRejectedValueOnce(new Error("offline"));
|
|
2589
|
+
const first = createAssistantMessage(
|
|
2590
|
+
{ type: "complete", reason: "stop" },
|
|
2591
|
+
[{ id: "spoken-1", parts: ["first"] }],
|
|
2592
|
+
"assistant-a",
|
|
2593
|
+
);
|
|
2594
|
+
|
|
2595
|
+
await step({ messages: [first] });
|
|
2596
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2597
|
+
await step({ messages: [first] });
|
|
2598
|
+
await step({ messages: [first] });
|
|
2599
|
+
await flush();
|
|
2600
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
2601
|
+
|
|
2602
|
+
const second = createAssistantMessage(
|
|
2603
|
+
{ type: "complete", reason: "stop" },
|
|
2604
|
+
[{ id: "spoken-2", parts: ["second"] }],
|
|
2605
|
+
"assistant-b",
|
|
2606
|
+
);
|
|
2607
|
+
await step({ messages: [first, second] });
|
|
2608
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
|
|
2609
|
+
expect(append.mock.calls.map(([item]) => item)).toEqual([
|
|
2610
|
+
{ parentId: null, message: { id: "spoken-1", parts: ["first"] } },
|
|
2611
|
+
{ parentId: null, message: { id: "spoken-1", parts: ["first"] } },
|
|
2612
|
+
{ parentId: "spoken-1", message: { id: "spoken-2", parts: ["second"] } },
|
|
2613
|
+
]);
|
|
2614
|
+
});
|
|
2615
|
+
|
|
2616
|
+
it("persists a settled turn when the history adapter becomes active after it", async () => {
|
|
2617
|
+
let listener: (() => void) | undefined;
|
|
2618
|
+
let isRunning = false;
|
|
2619
|
+
let messages: ThreadMessage[] = [];
|
|
2620
|
+
const append = vi.fn(async () => {});
|
|
2621
|
+
const formattedAdapter = {
|
|
2622
|
+
load: vi.fn().mockResolvedValue({ messages: [] }),
|
|
2623
|
+
append,
|
|
2624
|
+
};
|
|
2625
|
+
const historyAdapter: ThreadHistoryAdapter = {
|
|
2626
|
+
load: vi.fn(),
|
|
2627
|
+
append: vi.fn(),
|
|
2628
|
+
withFormat: vi.fn().mockReturnValue(formattedAdapter),
|
|
2629
|
+
};
|
|
2630
|
+
let activeAdapter: ThreadHistoryAdapter | undefined;
|
|
2631
|
+
const thread = {
|
|
2632
|
+
subscribe: (next: () => void) => {
|
|
2633
|
+
listener = next;
|
|
2634
|
+
return () => {};
|
|
2635
|
+
},
|
|
2636
|
+
getState: () => ({ isRunning, messages }),
|
|
2637
|
+
import: vi.fn(),
|
|
2638
|
+
export: vi.fn(() => ({ headId: null, messages: [] })),
|
|
2639
|
+
} as unknown as AssistantRuntime["thread"];
|
|
2640
|
+
const persistenceRuntimeRef = {
|
|
2641
|
+
current: { thread } as AssistantRuntime,
|
|
2642
|
+
};
|
|
2643
|
+
const message = createAssistantMessage(
|
|
2644
|
+
{ type: "complete", reason: "stop" },
|
|
2645
|
+
[{ id: "inner-1", parts: ["answer"] }],
|
|
2646
|
+
);
|
|
2647
|
+
|
|
2648
|
+
mocks.hasThreadListItem = true;
|
|
2649
|
+
mocks.remoteId = "remote-thread";
|
|
2650
|
+
|
|
2651
|
+
const { rerender } = renderHook(() =>
|
|
2652
|
+
useExternalHistory(
|
|
2653
|
+
persistenceRuntimeRef,
|
|
2654
|
+
activeAdapter,
|
|
2655
|
+
() => [],
|
|
2656
|
+
persistenceStorageFormat,
|
|
2657
|
+
() => {},
|
|
2658
|
+
),
|
|
2659
|
+
);
|
|
2660
|
+
|
|
2661
|
+
await act(async () => {
|
|
2662
|
+
messages = [message];
|
|
2663
|
+
isRunning = true;
|
|
2664
|
+
listener?.();
|
|
2665
|
+
isRunning = false;
|
|
2666
|
+
listener?.();
|
|
2667
|
+
});
|
|
2668
|
+
|
|
2669
|
+
activeAdapter = historyAdapter;
|
|
2670
|
+
await act(async () => rerender());
|
|
2671
|
+
|
|
2672
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2673
|
+
expect(append).toHaveBeenCalledWith({
|
|
2674
|
+
parentId: null,
|
|
2675
|
+
message: { id: "inner-1", parts: ["answer"] },
|
|
2676
|
+
});
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
it("persists a turn that is already running when the subscription starts", async () => {
|
|
2680
|
+
const { append, step } = createPersistenceHarness(false, {
|
|
2681
|
+
initialIsRunning: true,
|
|
2682
|
+
});
|
|
2683
|
+
const message = createAssistantMessage(
|
|
2684
|
+
{ type: "complete", reason: "stop" },
|
|
2685
|
+
[{ id: "inner-1", parts: ["answer"] }],
|
|
2686
|
+
);
|
|
2687
|
+
|
|
2688
|
+
await step({ isRunning: false, messages: [message] });
|
|
2689
|
+
|
|
2690
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2691
|
+
});
|
|
2692
|
+
|
|
2693
|
+
it("persists a prompt that reaches the thread after its run started", async () => {
|
|
2694
|
+
const { append, flush, load, step } = createPersistenceHarness(false);
|
|
2695
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
2696
|
+
await flush();
|
|
2697
|
+
const promptInner: InnerMessage = {
|
|
2698
|
+
id: "late-prompt",
|
|
2699
|
+
role: "user",
|
|
2700
|
+
parts: ["prompt"],
|
|
2701
|
+
};
|
|
2702
|
+
|
|
2703
|
+
await step({ isRunning: true, messages: [] });
|
|
2704
|
+
await step({ messages: [createUserMessage(promptInner)] });
|
|
2705
|
+
|
|
2706
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
2707
|
+
expect(append).toHaveBeenCalledWith({
|
|
2708
|
+
parentId: null,
|
|
2709
|
+
message: promptInner,
|
|
2710
|
+
});
|
|
2711
|
+
});
|
|
2712
|
+
|
|
2713
|
+
it("retries an unstored prompt when the next run starts", async () => {
|
|
2714
|
+
const consoleError = vi
|
|
2715
|
+
.spyOn(console, "error")
|
|
2716
|
+
.mockImplementation(() => {});
|
|
2717
|
+
onTestFinished(() => consoleError.mockRestore());
|
|
2718
|
+
const { append, flush, load, step } = createPersistenceHarness(false);
|
|
2719
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
2720
|
+
await flush();
|
|
2721
|
+
append
|
|
2722
|
+
.mockRejectedValueOnce(new Error("temporary storage failure"))
|
|
2723
|
+
.mockRejectedValueOnce(new Error("temporary storage failure"));
|
|
2724
|
+
const promptInner: InnerMessage = {
|
|
2725
|
+
id: "user-retry",
|
|
2726
|
+
role: "user",
|
|
2727
|
+
parts: ["prompt"],
|
|
2728
|
+
};
|
|
2729
|
+
const prompt = createUserMessage(promptInner);
|
|
2730
|
+
|
|
2731
|
+
await step({ isRunning: true, messages: [prompt] });
|
|
2732
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
2733
|
+
await step({ isRunning: false, messages: [prompt] });
|
|
2734
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
2735
|
+
await step({ isRunning: true, messages: [prompt] });
|
|
2736
|
+
|
|
2737
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
|
|
2738
|
+
expect(append).toHaveBeenLastCalledWith({
|
|
2739
|
+
parentId: null,
|
|
2740
|
+
message: promptInner,
|
|
2741
|
+
});
|
|
2742
|
+
});
|
|
2743
|
+
|
|
2744
|
+
it("persists a new user message while its run is active", async () => {
|
|
2745
|
+
const previousInner: InnerMessage = {
|
|
2746
|
+
id: "previous-assistant",
|
|
2747
|
+
parts: ["previous"],
|
|
2748
|
+
};
|
|
2749
|
+
const userInner: InnerMessage = {
|
|
2750
|
+
id: "user-1",
|
|
2751
|
+
role: "user",
|
|
2752
|
+
parts: ["prompt"],
|
|
2753
|
+
};
|
|
2754
|
+
const previous = createAssistantMessage(
|
|
2755
|
+
{ type: "complete", reason: "stop" },
|
|
2756
|
+
[previousInner],
|
|
2757
|
+
"previous-assistant",
|
|
2758
|
+
);
|
|
2759
|
+
const user = {
|
|
2760
|
+
id: "user-1",
|
|
2761
|
+
role: "user",
|
|
2762
|
+
content: [],
|
|
2763
|
+
} as unknown as ThreadMessage;
|
|
2764
|
+
bindExternalStoreMessage(user, userInner);
|
|
2765
|
+
const partialInner: InnerMessage = {
|
|
2766
|
+
id: "assistant-1",
|
|
2767
|
+
role: "assistant",
|
|
2768
|
+
parts: ["partial"],
|
|
2769
|
+
};
|
|
2770
|
+
const partialAssistant = createAssistantMessage(
|
|
2771
|
+
{ type: "running" },
|
|
2772
|
+
[partialInner],
|
|
2773
|
+
"assistant-1",
|
|
2774
|
+
);
|
|
2775
|
+
const { append, flush, load, step } = createPersistenceHarness(false, {
|
|
2776
|
+
loadMessages: {
|
|
2777
|
+
headId: previousInner.id,
|
|
2778
|
+
messages: [{ parentId: null, message: previousInner }],
|
|
2779
|
+
},
|
|
2780
|
+
toThreadMessages: (messages) =>
|
|
2781
|
+
messages.map((message) =>
|
|
2782
|
+
createAssistantMessage(
|
|
2783
|
+
{ type: "complete", reason: "stop" },
|
|
2784
|
+
[message],
|
|
2785
|
+
message.id,
|
|
2786
|
+
),
|
|
2787
|
+
),
|
|
2788
|
+
});
|
|
2789
|
+
|
|
2790
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
2791
|
+
await flush();
|
|
2792
|
+
|
|
2793
|
+
await step({
|
|
2794
|
+
isRunning: true,
|
|
2795
|
+
messages: [previous, user, partialAssistant],
|
|
2796
|
+
});
|
|
2797
|
+
|
|
2798
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
2799
|
+
expect(append).toHaveBeenCalledWith({
|
|
2800
|
+
parentId: previousInner.id,
|
|
2801
|
+
message: userInner,
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
const completeAssistant = createAssistantMessage(
|
|
2805
|
+
{ type: "complete", reason: "stop" },
|
|
2806
|
+
[{ ...partialInner, parts: ["complete"] }],
|
|
2807
|
+
"assistant-1",
|
|
2808
|
+
);
|
|
2809
|
+
await step({
|
|
2810
|
+
isRunning: false,
|
|
2811
|
+
messages: [previous, user, completeAssistant],
|
|
2812
|
+
});
|
|
2813
|
+
|
|
2814
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
2815
|
+
expect(append).toHaveBeenNthCalledWith(1, {
|
|
2816
|
+
parentId: previousInner.id,
|
|
2817
|
+
message: userInner,
|
|
2818
|
+
});
|
|
2819
|
+
expect(append).toHaveBeenNthCalledWith(2, {
|
|
2820
|
+
parentId: userInner.id,
|
|
2821
|
+
message: { ...partialInner, parts: ["complete"] },
|
|
2822
|
+
});
|
|
2823
|
+
});
|
|
2824
|
+
|
|
2825
|
+
it("keeps a prompt deleted during its pending write deleted after the subscription restarts", async () => {
|
|
2826
|
+
type Item = { parentId: string | null; message: InnerMessage };
|
|
2827
|
+
const stored = new Map<string, Item>();
|
|
2828
|
+
let releaseAppend!: () => void;
|
|
2829
|
+
const appendGate = new Promise<void>((resolve) => {
|
|
2830
|
+
releaseAppend = resolve;
|
|
2831
|
+
});
|
|
2832
|
+
const adapter: ThreadHistoryAdapter = {
|
|
2833
|
+
load: vi.fn(),
|
|
2834
|
+
append: vi.fn(),
|
|
2835
|
+
withFormat: vi.fn().mockReturnValue({
|
|
2836
|
+
load: async () => ({ messages: [] }),
|
|
2837
|
+
append: async (item: Item) => {
|
|
2838
|
+
await appendGate;
|
|
2839
|
+
stored.set(item.message.id, item);
|
|
2840
|
+
},
|
|
2841
|
+
delete: async (items: Item[]) => {
|
|
2842
|
+
for (const item of items) stored.delete(item.message.id);
|
|
2843
|
+
},
|
|
2844
|
+
}),
|
|
2845
|
+
};
|
|
2846
|
+
let listener = () => {};
|
|
2847
|
+
let isRunning = false;
|
|
2848
|
+
let messages: ThreadMessage[] = [];
|
|
2849
|
+
const deletionRuntimeRef = {
|
|
2850
|
+
current: {
|
|
2851
|
+
thread: {
|
|
2852
|
+
getState: () => ({ isRunning, messages }),
|
|
2853
|
+
subscribe: (next: () => void) => {
|
|
2854
|
+
listener = next;
|
|
2855
|
+
return () => {};
|
|
2856
|
+
},
|
|
2857
|
+
},
|
|
2858
|
+
} as unknown as AssistantRuntime,
|
|
2859
|
+
};
|
|
2860
|
+
const hook = renderHook(
|
|
2861
|
+
({ toolArtifacts }) =>
|
|
2862
|
+
useExternalHistory(
|
|
2863
|
+
deletionRuntimeRef,
|
|
2864
|
+
adapter,
|
|
2865
|
+
() => [],
|
|
2866
|
+
persistenceStorageFormat,
|
|
2867
|
+
() => {},
|
|
2868
|
+
() => toolArtifacts,
|
|
2869
|
+
),
|
|
2870
|
+
{ initialProps: { toolArtifacts: new Map<string, unknown>() } },
|
|
2871
|
+
);
|
|
2872
|
+
const user = createUserMessage({
|
|
2873
|
+
id: "user-1",
|
|
2874
|
+
role: "user",
|
|
2875
|
+
parts: ["prompt"],
|
|
2876
|
+
});
|
|
2877
|
+
|
|
2878
|
+
await act(async () => {
|
|
2879
|
+
messages = [user];
|
|
2880
|
+
isRunning = true;
|
|
2881
|
+
listener();
|
|
2882
|
+
});
|
|
2883
|
+
const deletion = hook.result.current.deleteMessage("user-1");
|
|
2884
|
+
hook.rerender({ toolArtifacts: new Map() });
|
|
2885
|
+
await act(async () => {
|
|
2886
|
+
releaseAppend();
|
|
2887
|
+
await deletion;
|
|
2888
|
+
messages = [];
|
|
2889
|
+
listener();
|
|
2890
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
expect(stored.has("user-1")).toBe(false);
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
const createDeletionRace = async () => {
|
|
2897
|
+
const { append, deleteItems, deleteMessage, flush, load, step } =
|
|
2898
|
+
createPersistenceHarness(false);
|
|
2899
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
2900
|
+
await flush();
|
|
2901
|
+
const stored = new Set<string>();
|
|
2902
|
+
let releaseReply!: () => void;
|
|
2903
|
+
const replyGate = new Promise<void>((resolve) => {
|
|
2904
|
+
releaseReply = resolve;
|
|
2905
|
+
});
|
|
2906
|
+
append.mockImplementation(async (item) => {
|
|
2907
|
+
if (item.message.id === "reply-a") await replyGate;
|
|
2908
|
+
stored.add(item.message.id);
|
|
2909
|
+
});
|
|
2910
|
+
deleteItems.mockImplementation(async (items) => {
|
|
2911
|
+
for (const item of items) stored.delete(item.message.id);
|
|
2912
|
+
});
|
|
2913
|
+
const promptA = createUserMessage({
|
|
2914
|
+
id: "prompt-a",
|
|
2915
|
+
role: "user",
|
|
2916
|
+
parts: [],
|
|
2917
|
+
});
|
|
2918
|
+
const replyAInner: InnerMessage = { id: "reply-a", parts: ["a"] };
|
|
2919
|
+
const replyA = createAssistantMessage(
|
|
2920
|
+
{ type: "complete", reason: "stop" },
|
|
2921
|
+
[replyAInner],
|
|
2922
|
+
"reply-a",
|
|
2923
|
+
);
|
|
2924
|
+
const promptB = createUserMessage({
|
|
2925
|
+
id: "prompt-b",
|
|
2926
|
+
role: "user",
|
|
2927
|
+
parts: [],
|
|
2928
|
+
});
|
|
2929
|
+
const replyBInner: InnerMessage = { id: "reply-b", parts: ["b"] };
|
|
2930
|
+
|
|
2931
|
+
await step({
|
|
2932
|
+
isRunning: true,
|
|
2933
|
+
messages: [
|
|
2934
|
+
promptA,
|
|
2935
|
+
createAssistantMessage({ type: "running" }, [replyAInner], "reply-a"),
|
|
2936
|
+
],
|
|
2937
|
+
});
|
|
2938
|
+
await step({ isRunning: false, messages: [promptA, replyA] });
|
|
2939
|
+
await flush();
|
|
2940
|
+
const deletion = deleteMessage("prompt-a");
|
|
2941
|
+
|
|
2942
|
+
return {
|
|
2943
|
+
stored,
|
|
2944
|
+
startNextRun: () =>
|
|
2945
|
+
step({
|
|
2946
|
+
isRunning: true,
|
|
2947
|
+
messages: [
|
|
2948
|
+
promptA,
|
|
2949
|
+
replyA,
|
|
2950
|
+
promptB,
|
|
2951
|
+
createAssistantMessage(
|
|
2952
|
+
{ type: "running" },
|
|
2953
|
+
[replyBInner],
|
|
2954
|
+
"reply-b",
|
|
2955
|
+
),
|
|
2956
|
+
],
|
|
2957
|
+
}),
|
|
2958
|
+
settleNextRun: async () => {
|
|
2959
|
+
await step({
|
|
2960
|
+
isRunning: false,
|
|
2961
|
+
messages: [
|
|
2962
|
+
promptA,
|
|
2963
|
+
replyA,
|
|
2964
|
+
promptB,
|
|
2965
|
+
createAssistantMessage(
|
|
2966
|
+
{ type: "complete", reason: "stop" },
|
|
2967
|
+
[replyBInner],
|
|
2968
|
+
"reply-b",
|
|
2969
|
+
),
|
|
2970
|
+
],
|
|
2971
|
+
});
|
|
2972
|
+
await flush();
|
|
2973
|
+
},
|
|
2974
|
+
finishDeletion: async () => {
|
|
2975
|
+
releaseReply();
|
|
2976
|
+
await act(async () => {
|
|
2977
|
+
await deletion;
|
|
2978
|
+
});
|
|
2979
|
+
await flush();
|
|
2980
|
+
},
|
|
2981
|
+
};
|
|
2982
|
+
};
|
|
2983
|
+
|
|
2984
|
+
it("keeps a prompt deleted when the next run starts before its deletion lands", async () => {
|
|
2985
|
+
const race = await createDeletionRace();
|
|
2986
|
+
|
|
2987
|
+
await race.startNextRun();
|
|
2988
|
+
await race.finishDeletion();
|
|
2989
|
+
|
|
2990
|
+
expect([...race.stored]).toEqual(["reply-a", "prompt-b"]);
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
it("keeps a prompt deleted when the next run settles before its deletion lands", async () => {
|
|
2994
|
+
const race = await createDeletionRace();
|
|
2995
|
+
|
|
2996
|
+
await race.startNextRun();
|
|
2997
|
+
await race.settleNextRun();
|
|
2998
|
+
await race.finishDeletion();
|
|
2999
|
+
|
|
3000
|
+
expect([...race.stored]).toEqual(["reply-a", "prompt-b", "reply-b"]);
|
|
3001
|
+
});
|
|
3002
|
+
|
|
3003
|
+
it("keeps a prompt deleted during its run when the run resumes before the prompt leaves the chat", async () => {
|
|
3004
|
+
const { append, deleteItems, deleteMessage, flush, load, step } =
|
|
3005
|
+
createPersistenceHarness(false);
|
|
3006
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
3007
|
+
await flush();
|
|
3008
|
+
const prompt = createUserMessage({
|
|
3009
|
+
id: "prompt",
|
|
3010
|
+
role: "user",
|
|
3011
|
+
parts: ["prompt"],
|
|
3012
|
+
});
|
|
3013
|
+
|
|
3014
|
+
await step({
|
|
3015
|
+
isRunning: true,
|
|
3016
|
+
messages: [
|
|
3017
|
+
prompt,
|
|
3018
|
+
createAssistantMessage(
|
|
3019
|
+
{ type: "running" },
|
|
3020
|
+
[{ id: "reply", parts: [] }],
|
|
3021
|
+
"reply",
|
|
3022
|
+
),
|
|
3023
|
+
],
|
|
3024
|
+
});
|
|
3025
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
3026
|
+
await act(async () => {
|
|
3027
|
+
await deleteMessage("prompt");
|
|
3028
|
+
});
|
|
3029
|
+
expect(deleteItems).toHaveBeenCalledOnce();
|
|
3030
|
+
await step({ isRunning: false });
|
|
3031
|
+
await step({ isRunning: true });
|
|
3032
|
+
await flush();
|
|
3033
|
+
|
|
3034
|
+
expect(append).toHaveBeenCalledOnce();
|
|
3035
|
+
});
|
|
3036
|
+
|
|
3037
|
+
it("stores a message that reuses a deleted id once the deleted one has left the chat", async () => {
|
|
3038
|
+
const { append, deleteItems, deleteMessage, flush, load, runCycle, step } =
|
|
3039
|
+
createPersistenceHarness(false);
|
|
3040
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
3041
|
+
await flush();
|
|
3042
|
+
await runCycle([
|
|
3043
|
+
createAssistantMessage(
|
|
3044
|
+
{ type: "complete", reason: "stop" },
|
|
3045
|
+
[{ id: "reused", parts: ["old"] }],
|
|
3046
|
+
"reused",
|
|
3047
|
+
),
|
|
3048
|
+
]);
|
|
3049
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
3050
|
+
await act(async () => {
|
|
3051
|
+
await deleteMessage("reused");
|
|
3052
|
+
});
|
|
3053
|
+
expect(deleteItems).toHaveBeenCalledOnce();
|
|
3054
|
+
await step({ messages: [] });
|
|
3055
|
+
await flush();
|
|
3056
|
+
|
|
3057
|
+
const replacementInner: InnerMessage = { id: "reused", parts: ["new"] };
|
|
3058
|
+
await runCycle([
|
|
3059
|
+
createAssistantMessage(
|
|
3060
|
+
{ type: "complete", reason: "stop" },
|
|
3061
|
+
[replacementInner],
|
|
3062
|
+
"reused",
|
|
3063
|
+
),
|
|
3064
|
+
]);
|
|
3065
|
+
|
|
3066
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
3067
|
+
expect(append).toHaveBeenLastCalledWith({
|
|
3068
|
+
parentId: null,
|
|
3069
|
+
message: replacementInner,
|
|
3070
|
+
});
|
|
3071
|
+
});
|
|
3072
|
+
|
|
3073
|
+
it("releases a deleted message that left the chat before its delete resolved", async () => {
|
|
3074
|
+
const { append, deleteItems, deleteMessage, flush, load, runCycle, step } =
|
|
3075
|
+
createPersistenceHarness(false);
|
|
3076
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
3077
|
+
await flush();
|
|
3078
|
+
await runCycle([
|
|
3079
|
+
createAssistantMessage(
|
|
3080
|
+
{ type: "complete", reason: "stop" },
|
|
3081
|
+
[{ id: "reused", parts: ["old"] }],
|
|
3082
|
+
"reused",
|
|
3083
|
+
),
|
|
3084
|
+
]);
|
|
3085
|
+
await waitFor(() => expect(append).toHaveBeenCalledOnce());
|
|
3086
|
+
let resolveDelete!: () => void;
|
|
3087
|
+
deleteItems.mockImplementationOnce(
|
|
3088
|
+
() =>
|
|
3089
|
+
new Promise<void>((resolve) => {
|
|
3090
|
+
resolveDelete = resolve;
|
|
3091
|
+
}),
|
|
3092
|
+
);
|
|
3093
|
+
const deletion = deleteMessage("reused");
|
|
3094
|
+
await step({ messages: [] });
|
|
3095
|
+
await act(async () => {
|
|
3096
|
+
resolveDelete();
|
|
3097
|
+
await deletion;
|
|
3098
|
+
});
|
|
3099
|
+
await flush();
|
|
3100
|
+
|
|
3101
|
+
const replacementInner: InnerMessage = { id: "reused", parts: ["new"] };
|
|
3102
|
+
await runCycle([
|
|
3103
|
+
createAssistantMessage(
|
|
3104
|
+
{ type: "complete", reason: "stop" },
|
|
3105
|
+
[replacementInner],
|
|
3106
|
+
"reused",
|
|
3107
|
+
),
|
|
3108
|
+
]);
|
|
3109
|
+
|
|
3110
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
3111
|
+
expect(append).toHaveBeenLastCalledWith({
|
|
3112
|
+
parentId: null,
|
|
3113
|
+
message: replacementInner,
|
|
3114
|
+
});
|
|
3115
|
+
});
|
|
3116
|
+
|
|
3117
|
+
it("parents a tool interaction update past a deleted message that is still in the chat", async () => {
|
|
3118
|
+
type Item = { parentId: string | null; message: InnerMessage };
|
|
3119
|
+
const stored = new Map<string, Item>();
|
|
3120
|
+
const toolInteractions = new Map<string, Unstable_ToolInteractionLog>();
|
|
3121
|
+
const {
|
|
3122
|
+
append,
|
|
3123
|
+
update,
|
|
3124
|
+
deleteItems,
|
|
3125
|
+
deleteMessage,
|
|
3126
|
+
flush,
|
|
3127
|
+
load,
|
|
3128
|
+
persistToolInteractions,
|
|
3129
|
+
runCycle,
|
|
3130
|
+
step,
|
|
3131
|
+
} = createPersistenceHarness(true, { toolInteractions });
|
|
3132
|
+
append.mockImplementation(async (item) => {
|
|
3133
|
+
stored.set(item.message.id, item);
|
|
3134
|
+
});
|
|
3135
|
+
update.mockImplementation(async (item) => {
|
|
3136
|
+
stored.set(item.message.id, item);
|
|
3137
|
+
});
|
|
3138
|
+
deleteItems.mockImplementation(async (items) => {
|
|
3139
|
+
for (const item of items) stored.delete(item.message.id);
|
|
3140
|
+
const reply = stored.get("reply");
|
|
3141
|
+
if (reply?.parentId === "prompt")
|
|
3142
|
+
stored.set("reply", { ...reply, parentId: null });
|
|
3143
|
+
});
|
|
3144
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
3145
|
+
await flush();
|
|
3146
|
+
const prompt = createUserMessage({ id: "prompt", role: "user", parts: [] });
|
|
3147
|
+
const reply = Object.assign(
|
|
3148
|
+
createAssistantMessage(
|
|
3149
|
+
{ type: "complete", reason: "stop" },
|
|
3150
|
+
[{ id: "reply", parts: [{ toolCallId: "call-1" }] }],
|
|
3151
|
+
"reply",
|
|
3152
|
+
),
|
|
3153
|
+
{
|
|
3154
|
+
content: [
|
|
3155
|
+
{
|
|
3156
|
+
type: "tool-call" as const,
|
|
3157
|
+
toolCallId: "call-1",
|
|
3158
|
+
toolName: "weather",
|
|
3159
|
+
args: {},
|
|
3160
|
+
argsText: "{}",
|
|
3161
|
+
result: undefined,
|
|
3162
|
+
isError: false,
|
|
3163
|
+
},
|
|
3164
|
+
],
|
|
3165
|
+
},
|
|
3166
|
+
);
|
|
3167
|
+
|
|
3168
|
+
await runCycle([prompt, reply]);
|
|
3169
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
3170
|
+
await flush();
|
|
3171
|
+
await act(async () => {
|
|
3172
|
+
await deleteMessage("prompt");
|
|
3173
|
+
});
|
|
3174
|
+
await step({ isRunning: false });
|
|
3175
|
+
await flush();
|
|
3176
|
+
toolInteractions.set("call-1", {
|
|
3177
|
+
entries: [{ type: "human-response", occurredAt: 2, payload: "yes" }],
|
|
3178
|
+
});
|
|
3179
|
+
await act(async () => {
|
|
3180
|
+
await persistToolInteractions("reply");
|
|
3181
|
+
});
|
|
3182
|
+
|
|
3183
|
+
expect(update).toHaveBeenCalledOnce();
|
|
3184
|
+
expect(stored.get("reply")?.parentId).toBeNull();
|
|
3185
|
+
});
|
|
3186
|
+
|
|
3187
|
+
it("stores a new prompt after an earlier reply that failed to store", async () => {
|
|
3188
|
+
const consoleError = vi
|
|
3189
|
+
.spyOn(console, "error")
|
|
3190
|
+
.mockImplementation(() => {});
|
|
3191
|
+
onTestFinished(() => consoleError.mockRestore());
|
|
3192
|
+
const userAInner: InnerMessage = { id: "user-a", role: "user", parts: [] };
|
|
3193
|
+
const replyAInner: InnerMessage = { id: "reply-a", parts: ["a"] };
|
|
3194
|
+
const userBInner: InnerMessage = { id: "user-b", role: "user", parts: [] };
|
|
3195
|
+
const replyBInner: InnerMessage = { id: "reply-b", parts: ["b"] };
|
|
3196
|
+
const userA = createUserMessage(userAInner);
|
|
3197
|
+
const replyA = createAssistantMessage(
|
|
3198
|
+
{ type: "complete", reason: "stop" },
|
|
3199
|
+
[replyAInner],
|
|
3200
|
+
"reply-a",
|
|
3201
|
+
);
|
|
3202
|
+
const userB = createUserMessage(userBInner);
|
|
3203
|
+
const { append, flush, load, step } = createPersistenceHarness(false);
|
|
3204
|
+
await waitFor(() => expect(load).toHaveBeenCalledOnce());
|
|
3205
|
+
await flush();
|
|
3206
|
+
append.mockImplementation(async (item) => {
|
|
3207
|
+
if (item.message.id === "reply-a" && append.mock.calls.length === 2)
|
|
3208
|
+
throw new Error("temporary storage failure");
|
|
3209
|
+
});
|
|
3210
|
+
|
|
3211
|
+
await step({
|
|
3212
|
+
isRunning: true,
|
|
3213
|
+
messages: [
|
|
3214
|
+
userA,
|
|
3215
|
+
createAssistantMessage({ type: "running" }, [replyAInner], "reply-a"),
|
|
3216
|
+
],
|
|
3217
|
+
});
|
|
3218
|
+
await step({ isRunning: false, messages: [userA, replyA] });
|
|
3219
|
+
await flush();
|
|
3220
|
+
await step({
|
|
3221
|
+
isRunning: true,
|
|
3222
|
+
messages: [
|
|
3223
|
+
userA,
|
|
3224
|
+
replyA,
|
|
3225
|
+
userB,
|
|
3226
|
+
createAssistantMessage({ type: "running" }, [replyBInner], "reply-b"),
|
|
3227
|
+
],
|
|
3228
|
+
});
|
|
3229
|
+
await flush();
|
|
3230
|
+
await step({
|
|
3231
|
+
isRunning: false,
|
|
3232
|
+
messages: [
|
|
3233
|
+
userA,
|
|
3234
|
+
replyA,
|
|
3235
|
+
userB,
|
|
3236
|
+
createAssistantMessage(
|
|
3237
|
+
{ type: "complete", reason: "stop" },
|
|
3238
|
+
[replyBInner],
|
|
3239
|
+
"reply-b",
|
|
3240
|
+
),
|
|
3241
|
+
],
|
|
3242
|
+
});
|
|
3243
|
+
await flush();
|
|
3244
|
+
|
|
3245
|
+
expect(append.mock.calls.map(([item]) => item)).toEqual([
|
|
3246
|
+
{ parentId: null, message: userAInner },
|
|
3247
|
+
{ parentId: "user-a", message: replyAInner },
|
|
3248
|
+
{ parentId: "user-a", message: replyAInner },
|
|
3249
|
+
{ parentId: "reply-a", message: userBInner },
|
|
3250
|
+
{ parentId: "user-b", message: replyBInner },
|
|
3251
|
+
]);
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
it("retries a failed append on the next persistence pass", async () => {
|
|
3255
|
+
const consoleError = vi
|
|
3256
|
+
.spyOn(console, "error")
|
|
3257
|
+
.mockImplementation(() => {});
|
|
3258
|
+
onTestFinished(() => consoleError.mockRestore());
|
|
3259
|
+
const { append, runCycle, flush } = createPersistenceHarness(false);
|
|
3260
|
+
const innerMessage = { id: "inner-a", parts: ["final"] };
|
|
3261
|
+
const message = createAssistantMessage(
|
|
3262
|
+
{ type: "complete", reason: "stop" },
|
|
3263
|
+
[innerMessage],
|
|
3264
|
+
);
|
|
3265
|
+
append.mockRejectedValueOnce(new Error("temporary storage failure"));
|
|
3266
|
+
|
|
3267
|
+
await runCycle([message]);
|
|
3268
|
+
await flush();
|
|
3269
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3270
|
+
|
|
3271
|
+
await runCycle([message]);
|
|
3272
|
+
await flush();
|
|
3273
|
+
expect(append).toHaveBeenCalledTimes(2);
|
|
3274
|
+
expect(append).toHaveBeenLastCalledWith({
|
|
3275
|
+
parentId: null,
|
|
3276
|
+
message: innerMessage,
|
|
3277
|
+
});
|
|
3278
|
+
|
|
3279
|
+
await runCycle([message]);
|
|
3280
|
+
await flush();
|
|
3281
|
+
expect(append).toHaveBeenCalledTimes(2);
|
|
3282
|
+
});
|
|
3283
|
+
|
|
3284
|
+
it("does not replay appends that succeeded before a mid-batch failure", async () => {
|
|
3285
|
+
const consoleError = vi
|
|
3286
|
+
.spyOn(console, "error")
|
|
3287
|
+
.mockImplementation(() => {});
|
|
3288
|
+
onTestFinished(() => consoleError.mockRestore());
|
|
3289
|
+
const { append, runCycle, flush } = createPersistenceHarness(false);
|
|
3290
|
+
const first = { id: "inner-1", parts: ["first"] };
|
|
3291
|
+
const second = { id: "inner-2", parts: ["second"] };
|
|
3292
|
+
const message = createAssistantMessage(
|
|
3293
|
+
{ type: "complete", reason: "stop" },
|
|
3294
|
+
[first, second],
|
|
3295
|
+
);
|
|
3296
|
+
append.mockImplementationOnce(async () => {});
|
|
3297
|
+
append.mockRejectedValueOnce(new Error("temporary storage failure"));
|
|
3298
|
+
|
|
3299
|
+
await runCycle([message]);
|
|
3300
|
+
await flush();
|
|
3301
|
+
expect(append).toHaveBeenCalledTimes(2);
|
|
3302
|
+
|
|
3303
|
+
await runCycle([message]);
|
|
3304
|
+
await flush();
|
|
3305
|
+
expect(append).toHaveBeenCalledTimes(3);
|
|
3306
|
+
expect(append.mock.calls.map((c) => c[0].message.id)).toEqual([
|
|
3307
|
+
"inner-1",
|
|
3308
|
+
"inner-2",
|
|
3309
|
+
"inner-2",
|
|
3310
|
+
]);
|
|
3311
|
+
});
|
|
3312
|
+
|
|
3313
|
+
it("persists assistant messages awaiting tool approval when the adapter supports update", async () => {
|
|
3314
|
+
const { append, runCycle } = createPersistenceHarness(true);
|
|
3315
|
+
const innerMessage = { id: "inner-a", parts: ["pending"] };
|
|
3316
|
+
|
|
3317
|
+
await runCycle([
|
|
3318
|
+
createAssistantMessage(
|
|
3319
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3320
|
+
[innerMessage],
|
|
3321
|
+
),
|
|
3322
|
+
]);
|
|
3323
|
+
|
|
3324
|
+
await waitFor(() =>
|
|
3325
|
+
expect(append).toHaveBeenCalledWith({
|
|
3326
|
+
parentId: null,
|
|
3327
|
+
message: innerMessage,
|
|
3328
|
+
}),
|
|
3329
|
+
);
|
|
3330
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3331
|
+
});
|
|
3332
|
+
|
|
3333
|
+
it("keeps paused messages unpersisted when the adapter lacks update", async () => {
|
|
3334
|
+
const { append, runCycle, flush } = createPersistenceHarness(false);
|
|
3335
|
+
const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
|
|
3336
|
+
|
|
3337
|
+
await runCycle([
|
|
3338
|
+
createAssistantMessage(
|
|
3339
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3340
|
+
[{ id: "inner-a", parts: ["pending"] }],
|
|
3341
|
+
),
|
|
3342
|
+
]);
|
|
3343
|
+
|
|
3344
|
+
await flush();
|
|
3345
|
+
expect(append).not.toHaveBeenCalled();
|
|
3346
|
+
|
|
3347
|
+
await runCycle([
|
|
3348
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3349
|
+
finalInnerMessage,
|
|
3350
|
+
]),
|
|
3351
|
+
]);
|
|
3352
|
+
|
|
3353
|
+
await waitFor(() =>
|
|
3354
|
+
expect(append).toHaveBeenCalledWith({
|
|
3355
|
+
parentId: null,
|
|
3356
|
+
message: finalInnerMessage,
|
|
3357
|
+
}),
|
|
3358
|
+
);
|
|
3359
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3360
|
+
});
|
|
3361
|
+
|
|
3362
|
+
it("updates the previously persisted message when its run completes", async () => {
|
|
3363
|
+
const { append, update, runCycle } = createPersistenceHarness(true);
|
|
3364
|
+
const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
|
|
3365
|
+
const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
|
|
3366
|
+
|
|
3367
|
+
await runCycle([
|
|
3368
|
+
createAssistantMessage(
|
|
3369
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3370
|
+
[pendingInnerMessage],
|
|
3371
|
+
),
|
|
3372
|
+
]);
|
|
3373
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3374
|
+
|
|
3375
|
+
await runCycle([
|
|
3376
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3377
|
+
finalInnerMessage,
|
|
3378
|
+
]),
|
|
3379
|
+
]);
|
|
3380
|
+
|
|
3381
|
+
await waitFor(() =>
|
|
3382
|
+
expect(update).toHaveBeenCalledWith(
|
|
3383
|
+
{ parentId: null, message: finalInnerMessage },
|
|
3384
|
+
"inner-a",
|
|
3385
|
+
),
|
|
3386
|
+
);
|
|
3387
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3388
|
+
});
|
|
3389
|
+
|
|
3390
|
+
it("appends continuation inner messages after approval", async () => {
|
|
3391
|
+
const { append, update, runCycle } = createPersistenceHarness(true);
|
|
3392
|
+
const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
|
|
3393
|
+
const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
|
|
3394
|
+
const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
|
|
3395
|
+
|
|
3396
|
+
await runCycle([
|
|
3397
|
+
createAssistantMessage(
|
|
3398
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3399
|
+
[pendingInnerMessage],
|
|
3400
|
+
),
|
|
3401
|
+
]);
|
|
3402
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3403
|
+
|
|
3404
|
+
await runCycle([
|
|
3405
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3406
|
+
finalInnerMessage,
|
|
3407
|
+
continuationInnerMessage,
|
|
3408
|
+
]),
|
|
3409
|
+
]);
|
|
3410
|
+
|
|
3411
|
+
await waitFor(() =>
|
|
3412
|
+
expect(append).toHaveBeenCalledWith({
|
|
3413
|
+
parentId: "inner-a",
|
|
3414
|
+
message: continuationInnerMessage,
|
|
3415
|
+
}),
|
|
3416
|
+
);
|
|
3417
|
+
expect(append.mock.calls.map(([item]) => item.message.id)).toEqual([
|
|
3418
|
+
"inner-a",
|
|
3419
|
+
"inner-b",
|
|
3420
|
+
]);
|
|
3421
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
3422
|
+
expect(update).toHaveBeenCalledWith(
|
|
3423
|
+
{ parentId: null, message: finalInnerMessage },
|
|
3424
|
+
"inner-a",
|
|
3425
|
+
);
|
|
3426
|
+
});
|
|
3427
|
+
|
|
3428
|
+
it("defers run telemetry until the paused message completes", async () => {
|
|
3429
|
+
const { reportTelemetry, runCycle, flush } = createPersistenceHarness(true);
|
|
3430
|
+
const pendingInnerMessage = { id: "inner-a", parts: ["pending"] };
|
|
3431
|
+
const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
|
|
3432
|
+
const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
|
|
3433
|
+
|
|
3434
|
+
await runCycle([
|
|
3435
|
+
createAssistantMessage(
|
|
3436
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3437
|
+
[pendingInnerMessage],
|
|
3438
|
+
),
|
|
3439
|
+
]);
|
|
3440
|
+
await flush();
|
|
3441
|
+
expect(reportTelemetry).not.toHaveBeenCalled();
|
|
3442
|
+
|
|
3443
|
+
await runCycle([
|
|
3444
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3445
|
+
finalInnerMessage,
|
|
3446
|
+
continuationInnerMessage,
|
|
3447
|
+
]),
|
|
3448
|
+
]);
|
|
3449
|
+
await flush();
|
|
3450
|
+
|
|
3451
|
+
expect(reportTelemetry).toHaveBeenCalledTimes(1);
|
|
3452
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
3453
|
+
[
|
|
3454
|
+
{ parentId: null, message: finalInnerMessage },
|
|
3455
|
+
{ parentId: "inner-a", message: continuationInnerMessage },
|
|
3456
|
+
],
|
|
3457
|
+
expect.any(Object),
|
|
3458
|
+
);
|
|
3459
|
+
expect(reportTelemetry.mock.calls[0]![1]).toMatchObject({
|
|
3460
|
+
message: expect.objectContaining({
|
|
3461
|
+
id: "assistant-a",
|
|
3462
|
+
status: { type: "complete", reason: "stop" },
|
|
3463
|
+
}),
|
|
3464
|
+
});
|
|
3465
|
+
});
|
|
3466
|
+
|
|
3467
|
+
it("reports a run that failed before any assistant message", async () => {
|
|
3468
|
+
const { append, reportTelemetry, runCycle, flush } =
|
|
3469
|
+
createPersistenceHarness(true);
|
|
3470
|
+
const failed = createAssistantMessage(
|
|
3471
|
+
{
|
|
3472
|
+
type: "incomplete",
|
|
3473
|
+
reason: "error",
|
|
3474
|
+
error: { code: "AI_APICallError", message: "upstream failed" },
|
|
3475
|
+
},
|
|
3476
|
+
[],
|
|
3477
|
+
);
|
|
3478
|
+
|
|
3479
|
+
await runCycle([failed]);
|
|
3480
|
+
await flush();
|
|
3481
|
+
|
|
3482
|
+
expect(append).not.toHaveBeenCalled();
|
|
3483
|
+
expect(reportTelemetry).toHaveBeenCalledTimes(1);
|
|
3484
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
3485
|
+
[],
|
|
3486
|
+
expect.objectContaining({ message: failed }),
|
|
3487
|
+
);
|
|
3488
|
+
});
|
|
3489
|
+
|
|
3490
|
+
it("restores deferred telemetry for reloaded paused messages", async () => {
|
|
3491
|
+
const { append, update, reportTelemetry, load, runCycle, flush } =
|
|
3492
|
+
createPersistenceHarness(true, {
|
|
3493
|
+
loadMessages: {
|
|
3494
|
+
messages: [
|
|
3495
|
+
{
|
|
3496
|
+
parentId: null,
|
|
3497
|
+
message: { id: "inner-a", parts: ["pending"] },
|
|
3498
|
+
},
|
|
3499
|
+
],
|
|
3500
|
+
},
|
|
3501
|
+
toThreadMessages: (msgs) => [
|
|
3502
|
+
createAssistantMessage(
|
|
3503
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3504
|
+
msgs,
|
|
3505
|
+
),
|
|
3506
|
+
],
|
|
3507
|
+
});
|
|
3508
|
+
|
|
3509
|
+
await waitFor(() => expect(load).toHaveBeenCalled());
|
|
3510
|
+
await flush();
|
|
3511
|
+
|
|
3512
|
+
const finalInnerMessage = { id: "inner-a", parts: ["pending", "final"] };
|
|
3513
|
+
const continuationInnerMessage = { id: "inner-b", parts: ["answer"] };
|
|
3514
|
+
|
|
3515
|
+
await runCycle([
|
|
3516
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3517
|
+
finalInnerMessage,
|
|
3518
|
+
continuationInnerMessage,
|
|
3519
|
+
]),
|
|
3520
|
+
]);
|
|
3521
|
+
await flush();
|
|
3522
|
+
|
|
3523
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3524
|
+
expect(append).toHaveBeenCalledWith({
|
|
3525
|
+
parentId: "inner-a",
|
|
3526
|
+
message: continuationInnerMessage,
|
|
3527
|
+
});
|
|
3528
|
+
expect(update).toHaveBeenCalledWith(
|
|
3529
|
+
{ parentId: null, message: finalInnerMessage },
|
|
3530
|
+
"inner-a",
|
|
3531
|
+
);
|
|
3532
|
+
expect(reportTelemetry).toHaveBeenCalledTimes(1);
|
|
3533
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
3534
|
+
[
|
|
3535
|
+
{ parentId: null, message: finalInnerMessage },
|
|
3536
|
+
{ parentId: "inner-a", message: continuationInnerMessage },
|
|
3537
|
+
],
|
|
3538
|
+
expect.any(Object),
|
|
3539
|
+
);
|
|
3540
|
+
});
|
|
3541
|
+
|
|
3542
|
+
it("serializes overlapping persistence passes", async () => {
|
|
3543
|
+
const { append, runCycle, flush } = createPersistenceHarness(true);
|
|
3544
|
+
let releaseFirstAppend!: () => void;
|
|
3545
|
+
const firstAppend = new Promise<void>((resolve) => {
|
|
3546
|
+
releaseFirstAppend = resolve;
|
|
3547
|
+
});
|
|
3548
|
+
append.mockImplementationOnce(() => firstAppend);
|
|
3549
|
+
|
|
3550
|
+
const message = createAssistantMessage(
|
|
3551
|
+
{ type: "complete", reason: "stop" },
|
|
3552
|
+
[{ id: "inner-a", parts: ["answer"] }],
|
|
3553
|
+
);
|
|
3554
|
+
|
|
3555
|
+
await runCycle([message]);
|
|
3556
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3557
|
+
|
|
3558
|
+
await runCycle([message]);
|
|
3559
|
+
await flush();
|
|
3560
|
+
const callsWhileFirstAppendWasPending = append.mock.calls.length;
|
|
3561
|
+
|
|
3562
|
+
releaseFirstAppend();
|
|
3563
|
+
await flush();
|
|
3564
|
+
await flush();
|
|
3565
|
+
|
|
3566
|
+
expect(callsWhileFirstAppendWasPending).toBe(1);
|
|
3567
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3568
|
+
});
|
|
3569
|
+
|
|
3570
|
+
it("preserves telemetry timing for runs queued behind a pending append", async () => {
|
|
3571
|
+
const { append, reportTelemetry, step, flush } =
|
|
3572
|
+
createPersistenceHarness(false);
|
|
3573
|
+
let releaseFirstAppend!: () => void;
|
|
3574
|
+
const firstAppend = new Promise<void>((resolve) => {
|
|
3575
|
+
releaseFirstAppend = resolve;
|
|
3576
|
+
});
|
|
3577
|
+
append.mockImplementationOnce(() => firstAppend);
|
|
3578
|
+
|
|
3579
|
+
let now = 0;
|
|
3580
|
+
const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now);
|
|
3581
|
+
const firstMessage = createAssistantMessage(
|
|
3582
|
+
{ type: "complete", reason: "stop" },
|
|
3583
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3584
|
+
"assistant-a",
|
|
3585
|
+
);
|
|
3586
|
+
const secondMessage = createAssistantMessage(
|
|
3587
|
+
{ type: "complete", reason: "stop" },
|
|
3588
|
+
[{ id: "inner-b", parts: ["second"] }],
|
|
3589
|
+
"assistant-b",
|
|
3590
|
+
);
|
|
3591
|
+
const thirdMessage = createAssistantMessage(
|
|
3592
|
+
{ type: "complete", reason: "stop" },
|
|
3593
|
+
[{ id: "inner-c", parts: ["third"] }],
|
|
3594
|
+
"assistant-c",
|
|
3595
|
+
);
|
|
3596
|
+
|
|
3597
|
+
try {
|
|
3598
|
+
await step({ isRunning: true, messages: [firstMessage] });
|
|
3599
|
+
now = 10;
|
|
3600
|
+
await step({ isRunning: false });
|
|
3601
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3602
|
+
|
|
3603
|
+
now = 100;
|
|
3604
|
+
await step({
|
|
3605
|
+
isRunning: true,
|
|
3606
|
+
messages: [firstMessage, secondMessage],
|
|
3607
|
+
});
|
|
3608
|
+
now = 110;
|
|
3609
|
+
await step({ isRunning: false });
|
|
3610
|
+
await flush();
|
|
3611
|
+
|
|
3612
|
+
now = 200;
|
|
3613
|
+
await step({
|
|
3614
|
+
isRunning: true,
|
|
3615
|
+
messages: [firstMessage, secondMessage, thirdMessage],
|
|
3616
|
+
});
|
|
3617
|
+
now = 230;
|
|
3618
|
+
await step({ isRunning: false });
|
|
3619
|
+
await flush();
|
|
3620
|
+
|
|
3621
|
+
releaseFirstAppend();
|
|
3622
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(3));
|
|
3623
|
+
|
|
3624
|
+
const durations = Object.fromEntries(
|
|
3625
|
+
reportTelemetry.mock.calls.map(([items, options]) => [
|
|
3626
|
+
items[0]?.message.id,
|
|
3627
|
+
options.durationMs,
|
|
3628
|
+
]),
|
|
3629
|
+
);
|
|
3630
|
+
expect(durations).toEqual({
|
|
3631
|
+
"inner-a": 10,
|
|
3632
|
+
"inner-b": 10,
|
|
3633
|
+
"inner-c": 30,
|
|
3634
|
+
});
|
|
3635
|
+
} finally {
|
|
3636
|
+
releaseFirstAppend();
|
|
3637
|
+
nowSpy.mockRestore();
|
|
3638
|
+
}
|
|
3639
|
+
});
|
|
3640
|
+
|
|
3641
|
+
it("finishes queued persistence passes after unmount", async () => {
|
|
3642
|
+
const { append, runCycle, flush, unmount } =
|
|
3643
|
+
createPersistenceHarness(false);
|
|
3644
|
+
let releaseFirstAppend!: () => void;
|
|
3645
|
+
const firstAppend = new Promise<void>((resolve) => {
|
|
3646
|
+
releaseFirstAppend = resolve;
|
|
3647
|
+
});
|
|
3648
|
+
append.mockImplementationOnce(() => firstAppend);
|
|
3649
|
+
|
|
3650
|
+
const firstMessage = createAssistantMessage(
|
|
3651
|
+
{ type: "complete", reason: "stop" },
|
|
3652
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3653
|
+
"assistant-a",
|
|
3654
|
+
);
|
|
3655
|
+
const secondMessage = createAssistantMessage(
|
|
3656
|
+
{ type: "complete", reason: "stop" },
|
|
3657
|
+
[{ id: "inner-b", parts: ["second"] }],
|
|
3658
|
+
"assistant-b",
|
|
3659
|
+
);
|
|
3660
|
+
|
|
3661
|
+
await runCycle([firstMessage]);
|
|
3662
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3663
|
+
|
|
3664
|
+
await runCycle([firstMessage, secondMessage]);
|
|
3665
|
+
await flush();
|
|
3666
|
+
unmount();
|
|
3667
|
+
|
|
3668
|
+
releaseFirstAppend();
|
|
3669
|
+
await flush();
|
|
3670
|
+
await flush();
|
|
3671
|
+
|
|
3672
|
+
expect(append.mock.calls.map(([item]) => item.message.id)).toEqual([
|
|
3673
|
+
"inner-a",
|
|
3674
|
+
"inner-b",
|
|
3675
|
+
]);
|
|
3676
|
+
});
|
|
3677
|
+
|
|
3678
|
+
it("serializes deletion after pending persistence passes", async () => {
|
|
3679
|
+
const { append, deleteItems, deleteMessage, runCycle, flush } =
|
|
3680
|
+
createPersistenceHarness(false);
|
|
3681
|
+
const events: string[] = [];
|
|
3682
|
+
let releaseFirstAppend!: () => void;
|
|
3683
|
+
const firstAppend = new Promise<void>((resolve) => {
|
|
3684
|
+
releaseFirstAppend = resolve;
|
|
3685
|
+
});
|
|
3686
|
+
append.mockImplementation(async (item) => {
|
|
3687
|
+
events.push(`append:${item.message.id}`);
|
|
3688
|
+
});
|
|
3689
|
+
append.mockImplementationOnce(async (item) => {
|
|
3690
|
+
events.push(`append:${item.message.id}:start`);
|
|
3691
|
+
await firstAppend;
|
|
3692
|
+
events.push(`append:${item.message.id}:end`);
|
|
3693
|
+
});
|
|
3694
|
+
deleteItems.mockImplementation(async (items) => {
|
|
3695
|
+
events.push(`delete:${items[0]?.message.id}`);
|
|
3696
|
+
});
|
|
3697
|
+
|
|
3698
|
+
const firstMessage = createAssistantMessage(
|
|
3699
|
+
{ type: "complete", reason: "stop" },
|
|
3700
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3701
|
+
"assistant-a",
|
|
3702
|
+
);
|
|
3703
|
+
const secondMessage = createAssistantMessage(
|
|
3704
|
+
{ type: "complete", reason: "stop" },
|
|
3705
|
+
[{ id: "inner-b", parts: ["second"] }],
|
|
3706
|
+
"assistant-b",
|
|
3707
|
+
);
|
|
3708
|
+
|
|
3709
|
+
try {
|
|
3710
|
+
await runCycle([firstMessage]);
|
|
3711
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3712
|
+
|
|
3713
|
+
await runCycle([firstMessage, secondMessage]);
|
|
3714
|
+
await flush();
|
|
3715
|
+
const deletion = deleteMessage("assistant-a");
|
|
3716
|
+
await flush();
|
|
3717
|
+
expect(deleteItems).not.toHaveBeenCalled();
|
|
3718
|
+
|
|
3719
|
+
releaseFirstAppend();
|
|
3720
|
+
await deletion;
|
|
3721
|
+
|
|
3722
|
+
expect(events).toEqual([
|
|
3723
|
+
"append:inner-a:start",
|
|
3724
|
+
"append:inner-a:end",
|
|
3725
|
+
"append:inner-b",
|
|
3726
|
+
"delete:inner-a",
|
|
3727
|
+
]);
|
|
3728
|
+
} finally {
|
|
3729
|
+
releaseFirstAppend();
|
|
3730
|
+
}
|
|
3731
|
+
});
|
|
3732
|
+
|
|
3733
|
+
it("uses the requested deletion snapshot after runtime state changes", async () => {
|
|
3734
|
+
const { append, deleteItems, deleteMessage, runCycle, flush, step } =
|
|
3735
|
+
createPersistenceHarness(false);
|
|
3736
|
+
let releaseAppend!: () => void;
|
|
3737
|
+
const pendingAppend = new Promise<void>((resolve) => {
|
|
3738
|
+
releaseAppend = resolve;
|
|
3739
|
+
});
|
|
3740
|
+
append.mockImplementationOnce(() => pendingAppend);
|
|
3741
|
+
|
|
3742
|
+
const message = createAssistantMessage(
|
|
3743
|
+
{ type: "complete", reason: "stop" },
|
|
3744
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3745
|
+
"assistant-a",
|
|
3746
|
+
);
|
|
3747
|
+
|
|
3748
|
+
try {
|
|
3749
|
+
await runCycle([message]);
|
|
3750
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
3751
|
+
|
|
3752
|
+
const deletion = deleteMessage("assistant-a");
|
|
3753
|
+
await step({ messages: [] });
|
|
3754
|
+
await flush();
|
|
3755
|
+
expect(deleteItems).not.toHaveBeenCalled();
|
|
3756
|
+
|
|
3757
|
+
releaseAppend();
|
|
3758
|
+
await deletion;
|
|
3759
|
+
|
|
3760
|
+
expect(deleteItems).toHaveBeenCalledWith([
|
|
3761
|
+
{
|
|
3762
|
+
parentId: null,
|
|
3763
|
+
message: { id: "inner-a", parts: ["first"] },
|
|
3764
|
+
},
|
|
3765
|
+
]);
|
|
3766
|
+
} finally {
|
|
3767
|
+
releaseAppend();
|
|
3768
|
+
}
|
|
3769
|
+
});
|
|
3770
|
+
|
|
3771
|
+
it("preserves the history adapter receiver during deletion", async () => {
|
|
3772
|
+
const { deleteItems, formattedAdapter, deleteMessage, step } =
|
|
3773
|
+
createPersistenceHarness(false);
|
|
3774
|
+
const message = createAssistantMessage(
|
|
3775
|
+
{ type: "complete", reason: "stop" },
|
|
3776
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3777
|
+
"assistant-a",
|
|
3778
|
+
);
|
|
3779
|
+
|
|
3780
|
+
await step({ messages: [message] });
|
|
3781
|
+
await deleteMessage("assistant-a");
|
|
3782
|
+
|
|
3783
|
+
expect(deleteItems).toHaveBeenCalledOnce();
|
|
3784
|
+
expect(deleteItems.mock.contexts[0]).toBe(formattedAdapter);
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
it("reports a failed append as a persistence error once", async () => {
|
|
3788
|
+
const { append, reportTelemetry, runCycle, flush } =
|
|
3789
|
+
createPersistenceHarness(false);
|
|
3790
|
+
const failure = new Error("temporary storage failure");
|
|
3791
|
+
append.mockRejectedValueOnce(failure);
|
|
3792
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3793
|
+
onTestFinished(() => errorSpy.mockRestore());
|
|
3794
|
+
const message = createAssistantMessage(
|
|
3795
|
+
{ type: "complete", reason: "stop" },
|
|
3796
|
+
[{ id: "inner-a", parts: ["answer"] }],
|
|
3797
|
+
);
|
|
3798
|
+
|
|
3799
|
+
await runCycle([message]);
|
|
3800
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
|
|
3801
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
3802
|
+
[{ parentId: null, message: { id: "inner-a", parts: ["answer"] } }],
|
|
3803
|
+
expect.objectContaining({ message, persistenceError: failure }),
|
|
3804
|
+
);
|
|
3805
|
+
|
|
3806
|
+
await runCycle([message]);
|
|
3807
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
3808
|
+
await flush();
|
|
3809
|
+
expect(reportTelemetry).toHaveBeenCalledTimes(1);
|
|
3810
|
+
});
|
|
3811
|
+
|
|
3812
|
+
it("reports messages after a failed append without storing them", async () => {
|
|
3813
|
+
const { append, reportTelemetry, runCycle, flush } =
|
|
3814
|
+
createPersistenceHarness(false);
|
|
3815
|
+
const failure = new Error("temporary storage failure");
|
|
3816
|
+
append.mockRejectedValueOnce(failure);
|
|
3817
|
+
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
3818
|
+
onTestFinished(() => errorSpy.mockRestore());
|
|
3819
|
+
const first = createAssistantMessage(
|
|
3820
|
+
{ type: "complete", reason: "stop" },
|
|
3821
|
+
[{ id: "inner-a", parts: ["first"] }],
|
|
3822
|
+
"assistant-a",
|
|
3823
|
+
);
|
|
3824
|
+
const second = createAssistantMessage(
|
|
3825
|
+
{ type: "complete", reason: "stop" },
|
|
3826
|
+
[{ id: "inner-b", parts: ["second"] }],
|
|
3827
|
+
"assistant-b",
|
|
3828
|
+
);
|
|
3829
|
+
|
|
3830
|
+
await runCycle([first, second]);
|
|
3831
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(2));
|
|
3832
|
+
await flush();
|
|
3833
|
+
|
|
3834
|
+
expect(append).toHaveBeenCalledTimes(1);
|
|
3835
|
+
expect(reportTelemetry.mock.calls.map(([, options]) => options)).toEqual([
|
|
3836
|
+
expect.objectContaining({ message: first, persistenceError: failure }),
|
|
3837
|
+
expect.objectContaining({ message: second, persistenceError: failure }),
|
|
3838
|
+
]);
|
|
3839
|
+
});
|
|
3840
|
+
|
|
3841
|
+
it("reports a failed update of a settled message as a persistence error", async () => {
|
|
3842
|
+
const { update, reportTelemetry, runCycle, flush } =
|
|
3843
|
+
createPersistenceHarness(true);
|
|
3844
|
+
const failure = new Error("temporary storage failure");
|
|
3845
|
+
update.mockRejectedValueOnce(failure);
|
|
3846
|
+
const pending = createAssistantMessage(
|
|
3847
|
+
{ type: "requires-action", reason: "tool-calls" },
|
|
3848
|
+
[{ id: "inner-a", parts: ["pending"] }],
|
|
3849
|
+
);
|
|
3850
|
+
const settled = createAssistantMessage(
|
|
3851
|
+
{ type: "complete", reason: "stop" },
|
|
3852
|
+
[{ id: "inner-a", parts: ["pending", "final"] }],
|
|
3853
|
+
);
|
|
3854
|
+
|
|
3855
|
+
await runCycle([pending]);
|
|
3856
|
+
await flush();
|
|
3857
|
+
await runCycle([settled]);
|
|
3858
|
+
await waitFor(() => expect(reportTelemetry).toHaveBeenCalledTimes(1));
|
|
3859
|
+
|
|
3860
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
3861
|
+
expect(reportTelemetry).toHaveBeenCalledWith(
|
|
3862
|
+
expect.any(Array),
|
|
3863
|
+
expect.objectContaining({ message: settled, persistenceError: failure }),
|
|
3864
|
+
);
|
|
3865
|
+
});
|
|
3866
|
+
|
|
3867
|
+
it("does not reappend a stored inner message whose outer message was filtered", async () => {
|
|
3868
|
+
const innerMessage = { id: "inner-a", parts: ["stored"] };
|
|
3869
|
+
const { append, load, runCycle, flush } = createPersistenceHarness(false, {
|
|
3870
|
+
loadMessages: {
|
|
3871
|
+
messages: [{ parentId: null, message: innerMessage }],
|
|
3872
|
+
},
|
|
3873
|
+
toThreadMessages: () => [],
|
|
3874
|
+
});
|
|
3875
|
+
|
|
3876
|
+
await waitFor(() => expect(load).toHaveBeenCalled());
|
|
3877
|
+
await flush();
|
|
3878
|
+
|
|
3879
|
+
await runCycle([
|
|
3880
|
+
createAssistantMessage({ type: "complete", reason: "stop" }, [
|
|
3881
|
+
innerMessage,
|
|
3882
|
+
]),
|
|
3883
|
+
]);
|
|
3884
|
+
await flush();
|
|
3885
|
+
|
|
3886
|
+
expect(append).not.toHaveBeenCalled();
|
|
3887
|
+
});
|
|
3888
|
+
|
|
3889
|
+
it("skips unchanged persisted messages on later runs", async () => {
|
|
3890
|
+
const { append, update, reportTelemetry, runCycle, flush } =
|
|
3891
|
+
createPersistenceHarness(true);
|
|
3892
|
+
const completeStatus: ThreadAssistantMessage["status"] = {
|
|
3893
|
+
type: "complete",
|
|
3894
|
+
reason: "stop",
|
|
3895
|
+
};
|
|
3896
|
+
const old = createAssistantMessage(
|
|
3897
|
+
completeStatus,
|
|
3898
|
+
[{ id: "inner-old", parts: ["old"] }],
|
|
3899
|
+
"old",
|
|
3900
|
+
);
|
|
3901
|
+
const active = createAssistantMessage(
|
|
3902
|
+
completeStatus,
|
|
3903
|
+
[{ id: "inner-active", parts: ["initial"] }],
|
|
3904
|
+
"active",
|
|
3905
|
+
);
|
|
3906
|
+
const tail = createAssistantMessage(
|
|
3907
|
+
completeStatus,
|
|
3908
|
+
[{ id: "inner-tail", parts: ["tail"] }],
|
|
3909
|
+
"tail",
|
|
3910
|
+
);
|
|
3911
|
+
|
|
3912
|
+
await runCycle([old, active, tail]);
|
|
3913
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
|
|
3914
|
+
|
|
3915
|
+
append.mockClear();
|
|
3916
|
+
update.mockClear();
|
|
3917
|
+
reportTelemetry.mockClear();
|
|
3918
|
+
|
|
3919
|
+
const changedActive = createAssistantMessage(
|
|
3920
|
+
completeStatus,
|
|
3921
|
+
[{ id: "inner-active", parts: ["completed"] }],
|
|
3922
|
+
"active",
|
|
3923
|
+
);
|
|
3924
|
+
await runCycle([old, changedActive, tail]);
|
|
3925
|
+
await flush();
|
|
3926
|
+
|
|
3927
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
3928
|
+
expect(update).toHaveBeenCalledWith(
|
|
3929
|
+
{
|
|
3930
|
+
parentId: "inner-old",
|
|
3931
|
+
message: { id: "inner-active", parts: ["completed"] },
|
|
3932
|
+
},
|
|
3933
|
+
"inner-active",
|
|
3934
|
+
);
|
|
3935
|
+
expect(append).not.toHaveBeenCalled();
|
|
3936
|
+
expect(reportTelemetry).not.toHaveBeenCalled();
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
it("skips updates for a persisted message restored by a branch switch", async () => {
|
|
3940
|
+
const { append, update, runCycle, flush } = createPersistenceHarness(true);
|
|
3941
|
+
const completeStatus: ThreadAssistantMessage["status"] = {
|
|
3942
|
+
type: "complete",
|
|
3943
|
+
reason: "stop",
|
|
3944
|
+
};
|
|
3945
|
+
const createUserMessage = (
|
|
3946
|
+
id: string,
|
|
3947
|
+
inner: InnerMessage,
|
|
3948
|
+
): ThreadMessage => {
|
|
3949
|
+
const message: ThreadMessage = {
|
|
3950
|
+
id,
|
|
3951
|
+
role: "user",
|
|
3952
|
+
content: [{ type: "text", text: "hi" }],
|
|
3953
|
+
attachments: [],
|
|
3954
|
+
createdAt: new Date(),
|
|
3955
|
+
metadata: { custom: {} },
|
|
3956
|
+
};
|
|
3957
|
+
bindExternalStoreMessage(message, [inner]);
|
|
3958
|
+
return message;
|
|
3959
|
+
};
|
|
3960
|
+
const branchA = () => [
|
|
3961
|
+
createUserMessage("user-a", { id: "inner-user-a", parts: ["question"] }),
|
|
3962
|
+
createAssistantMessage(
|
|
3963
|
+
completeStatus,
|
|
3964
|
+
[{ id: "inner-assistant-a", parts: ["answer"] }],
|
|
3965
|
+
"assistant-a",
|
|
3966
|
+
),
|
|
3967
|
+
];
|
|
3968
|
+
|
|
3969
|
+
await runCycle(branchA());
|
|
3970
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
3971
|
+
|
|
3972
|
+
await runCycle([
|
|
3973
|
+
createUserMessage("user-b", { id: "inner-user-b", parts: ["edited"] }),
|
|
3974
|
+
createAssistantMessage(
|
|
3975
|
+
completeStatus,
|
|
3976
|
+
[{ id: "inner-assistant-b", parts: ["answer"] }],
|
|
3977
|
+
"assistant-b",
|
|
3978
|
+
),
|
|
3979
|
+
]);
|
|
3980
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(4));
|
|
3981
|
+
|
|
3982
|
+
append.mockClear();
|
|
3983
|
+
update.mockClear();
|
|
3984
|
+
|
|
3985
|
+
await runCycle([
|
|
3986
|
+
...branchA(),
|
|
3987
|
+
createUserMessage("user-c", { id: "inner-user-c", parts: ["follow-up"] }),
|
|
3988
|
+
createAssistantMessage(
|
|
3989
|
+
completeStatus,
|
|
3990
|
+
[{ id: "inner-assistant-c", parts: ["answer"] }],
|
|
3991
|
+
"assistant-c",
|
|
3992
|
+
),
|
|
3993
|
+
]);
|
|
3994
|
+
await flush();
|
|
3995
|
+
|
|
3996
|
+
expect(update).not.toHaveBeenCalled();
|
|
3997
|
+
expect(append.mock.calls.map(([item]) => item.message.id)).toEqual([
|
|
3998
|
+
"inner-user-c",
|
|
3999
|
+
"inner-assistant-c",
|
|
4000
|
+
]);
|
|
4001
|
+
});
|
|
4002
|
+
|
|
4003
|
+
it("absorbs agentic flickers without losing change detection", async () => {
|
|
4004
|
+
const { append, update, reportTelemetry, runCycle, flush, step } =
|
|
4005
|
+
createPersistenceHarness(true);
|
|
4006
|
+
const completeStatus: ThreadAssistantMessage["status"] = {
|
|
4007
|
+
type: "complete",
|
|
4008
|
+
reason: "stop",
|
|
4009
|
+
};
|
|
4010
|
+
const old = createAssistantMessage(
|
|
4011
|
+
completeStatus,
|
|
4012
|
+
[{ id: "inner-old", parts: ["old"] }],
|
|
4013
|
+
"old",
|
|
4014
|
+
);
|
|
4015
|
+
const active = createAssistantMessage(
|
|
4016
|
+
completeStatus,
|
|
4017
|
+
[{ id: "inner-active", parts: ["initial"] }],
|
|
4018
|
+
"active",
|
|
4019
|
+
);
|
|
4020
|
+
const tail = createAssistantMessage(
|
|
4021
|
+
completeStatus,
|
|
4022
|
+
[{ id: "inner-tail", parts: ["tail"] }],
|
|
4023
|
+
"tail",
|
|
4024
|
+
);
|
|
4025
|
+
|
|
4026
|
+
await runCycle([old, active, tail]);
|
|
4027
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(3));
|
|
4028
|
+
|
|
4029
|
+
append.mockClear();
|
|
4030
|
+
update.mockClear();
|
|
4031
|
+
reportTelemetry.mockClear();
|
|
4032
|
+
|
|
4033
|
+
const changedActive = createAssistantMessage(
|
|
4034
|
+
completeStatus,
|
|
4035
|
+
[{ id: "inner-active", parts: ["completed"] }],
|
|
4036
|
+
"active",
|
|
4037
|
+
);
|
|
4038
|
+
await step({ isRunning: true });
|
|
4039
|
+
await step({ messages: [old, changedActive, tail] });
|
|
4040
|
+
await step({ isRunning: false });
|
|
4041
|
+
await step({ isRunning: true });
|
|
4042
|
+
await step({ isRunning: false });
|
|
4043
|
+
await flush();
|
|
4044
|
+
|
|
4045
|
+
await waitFor(() => expect(update).toHaveBeenCalledTimes(1));
|
|
4046
|
+
expect(update).toHaveBeenCalledWith(
|
|
4047
|
+
{
|
|
4048
|
+
parentId: "inner-old",
|
|
4049
|
+
message: { id: "inner-active", parts: ["completed"] },
|
|
4050
|
+
},
|
|
4051
|
+
"inner-active",
|
|
4052
|
+
);
|
|
4053
|
+
expect(append).not.toHaveBeenCalled();
|
|
4054
|
+
expect(reportTelemetry).not.toHaveBeenCalled();
|
|
4055
|
+
});
|
|
4056
|
+
|
|
4057
|
+
it("persists idle-time changes on the next run stop", async () => {
|
|
4058
|
+
const { append, update, reportTelemetry, runCycle, flush, step } =
|
|
4059
|
+
createPersistenceHarness(true);
|
|
4060
|
+
const initialActive = createAssistantMessage(
|
|
4061
|
+
{ type: "complete", reason: "stop" },
|
|
4062
|
+
[{ id: "inner-a", parts: ["initial"] }],
|
|
4063
|
+
"active",
|
|
4064
|
+
);
|
|
4065
|
+
|
|
4066
|
+
await runCycle([initialActive]);
|
|
4067
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
4068
|
+
|
|
4069
|
+
append.mockClear();
|
|
4070
|
+
update.mockClear();
|
|
4071
|
+
reportTelemetry.mockClear();
|
|
4072
|
+
|
|
4073
|
+
const changedActive = createAssistantMessage(
|
|
4074
|
+
{ type: "complete", reason: "stop" },
|
|
4075
|
+
[{ id: "inner-a", parts: ["initial", "tool-result"] }],
|
|
4076
|
+
"active",
|
|
4077
|
+
);
|
|
4078
|
+
await step({ messages: [changedActive] });
|
|
4079
|
+
|
|
4080
|
+
await runCycle([changedActive]);
|
|
4081
|
+
await flush();
|
|
4082
|
+
|
|
4083
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
4084
|
+
expect(update).toHaveBeenCalledWith(
|
|
4085
|
+
{
|
|
4086
|
+
parentId: null,
|
|
4087
|
+
message: { id: "inner-a", parts: ["initial", "tool-result"] },
|
|
4088
|
+
},
|
|
4089
|
+
"inner-a",
|
|
4090
|
+
);
|
|
4091
|
+
expect(append).not.toHaveBeenCalled();
|
|
4092
|
+
});
|
|
4093
|
+
|
|
4094
|
+
it("retries a failed update on the next run", async () => {
|
|
4095
|
+
const { append, update, reportTelemetry, runCycle, flush } =
|
|
4096
|
+
createPersistenceHarness(true);
|
|
4097
|
+
const initialActive = createAssistantMessage(
|
|
4098
|
+
{ type: "complete", reason: "stop" },
|
|
4099
|
+
[{ id: "inner-a", parts: ["initial"] }],
|
|
4100
|
+
"active",
|
|
4101
|
+
);
|
|
4102
|
+
|
|
4103
|
+
await runCycle([initialActive]);
|
|
4104
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(1));
|
|
4105
|
+
|
|
4106
|
+
append.mockClear();
|
|
4107
|
+
update.mockClear();
|
|
4108
|
+
reportTelemetry.mockClear();
|
|
4109
|
+
update.mockRejectedValueOnce(new Error("boom"));
|
|
4110
|
+
|
|
4111
|
+
const changedActive = createAssistantMessage(
|
|
4112
|
+
{ type: "complete", reason: "stop" },
|
|
4113
|
+
[{ id: "inner-a", parts: ["changed"] }],
|
|
4114
|
+
"active",
|
|
4115
|
+
);
|
|
4116
|
+
const changedMessages = [changedActive];
|
|
4117
|
+
|
|
4118
|
+
await runCycle(changedMessages);
|
|
4119
|
+
await flush();
|
|
4120
|
+
await runCycle(changedMessages);
|
|
4121
|
+
await flush();
|
|
4122
|
+
|
|
4123
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
4124
|
+
expect(update).toHaveBeenNthCalledWith(
|
|
4125
|
+
1,
|
|
4126
|
+
{
|
|
4127
|
+
parentId: null,
|
|
4128
|
+
message: { id: "inner-a", parts: ["changed"] },
|
|
4129
|
+
},
|
|
4130
|
+
"inner-a",
|
|
4131
|
+
);
|
|
4132
|
+
expect(update).toHaveBeenNthCalledWith(
|
|
4133
|
+
2,
|
|
4134
|
+
{
|
|
4135
|
+
parentId: null,
|
|
4136
|
+
message: { id: "inner-a", parts: ["changed"] },
|
|
4137
|
+
},
|
|
4138
|
+
"inner-a",
|
|
4139
|
+
);
|
|
4140
|
+
expect(append).not.toHaveBeenCalled();
|
|
4141
|
+
});
|
|
4142
|
+
|
|
4143
|
+
it("detects changes on non-assistant messages", async () => {
|
|
4144
|
+
const { append, update, runCycle, flush } = createPersistenceHarness(true);
|
|
4145
|
+
const makeUserMessage = (inner: InnerMessage): ThreadMessage => {
|
|
4146
|
+
const message: ThreadMessage = {
|
|
4147
|
+
id: "user-1",
|
|
4148
|
+
role: "user",
|
|
4149
|
+
content: [{ type: "text", text: "hi" }],
|
|
4150
|
+
attachments: [],
|
|
4151
|
+
createdAt: new Date(),
|
|
4152
|
+
metadata: { custom: {} },
|
|
4153
|
+
};
|
|
4154
|
+
bindExternalStoreMessage(message, [inner]);
|
|
4155
|
+
return message;
|
|
4156
|
+
};
|
|
4157
|
+
const assistant = createAssistantMessage(
|
|
4158
|
+
{ type: "complete", reason: "stop" },
|
|
4159
|
+
[{ id: "inner-a", parts: ["answer"] }],
|
|
4160
|
+
);
|
|
4161
|
+
|
|
4162
|
+
await runCycle([
|
|
4163
|
+
makeUserMessage({ id: "inner-user", parts: ["hi"] }),
|
|
4164
|
+
assistant,
|
|
4165
|
+
]);
|
|
4166
|
+
await waitFor(() => expect(append).toHaveBeenCalledTimes(2));
|
|
4167
|
+
|
|
4168
|
+
append.mockClear();
|
|
4169
|
+
update.mockClear();
|
|
4170
|
+
|
|
4171
|
+
await runCycle([
|
|
4172
|
+
makeUserMessage({ id: "inner-user", parts: ["hi", "attachment"] }),
|
|
4173
|
+
assistant,
|
|
4174
|
+
]);
|
|
4175
|
+
await flush();
|
|
4176
|
+
|
|
4177
|
+
expect(update).toHaveBeenCalledTimes(1);
|
|
4178
|
+
expect(update).toHaveBeenCalledWith(
|
|
4179
|
+
{
|
|
4180
|
+
parentId: null,
|
|
4181
|
+
message: { id: "inner-user", parts: ["hi", "attachment"] },
|
|
4182
|
+
},
|
|
4183
|
+
"inner-user",
|
|
4184
|
+
);
|
|
4185
|
+
expect(append).not.toHaveBeenCalled();
|
|
4186
|
+
});
|
|
4187
|
+
});
|