@btst/stack 4.4.0 → 4.5.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.
Files changed (92) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.cjs +64 -24
  3. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +65 -25
  4. package/dist/packages/stack/src/plugins/ai-chat/api/context-checkpoint.cjs +54 -0
  5. package/dist/packages/stack/src/plugins/ai-chat/api/context-checkpoint.mjs +51 -0
  6. package/dist/packages/stack/src/plugins/ai-chat/api/getters.cjs +2 -0
  7. package/dist/packages/stack/src/plugins/ai-chat/api/getters.mjs +2 -0
  8. package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +269 -93
  9. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +270 -94
  10. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +3 -5
  11. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +3 -5
  12. package/dist/packages/stack/src/plugins/ai-chat/api/serializers.cjs +1 -0
  13. package/dist/packages/stack/src/plugins/ai-chat/api/serializers.mjs +1 -0
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +26 -16
  15. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +26 -16
  16. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.cjs +22 -2
  17. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.mjs +22 -2
  18. package/dist/packages/stack/src/plugins/ai-chat/db.cjs +8 -0
  19. package/dist/packages/stack/src/plugins/ai-chat/db.mjs +8 -0
  20. package/dist/packages/stack/src/plugins/ai-chat/history.cjs +47 -0
  21. package/dist/packages/stack/src/plugins/ai-chat/history.mjs +45 -0
  22. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  23. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  24. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  25. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  26. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  27. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  28. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  29. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  30. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  31. package/dist/plugins/ai-chat/client/index.d.cts +3 -3
  32. package/dist/plugins/ai-chat/client/index.d.mts +3 -3
  33. package/dist/plugins/ai-chat/client/index.d.ts +3 -3
  34. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  35. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  36. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  37. package/dist/plugins/blog/api/index.d.cts +2 -2
  38. package/dist/plugins/blog/api/index.d.mts +2 -2
  39. package/dist/plugins/blog/api/index.d.ts +2 -2
  40. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  41. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  42. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  43. package/dist/plugins/blog/client/index.d.cts +2 -2
  44. package/dist/plugins/blog/client/index.d.mts +2 -2
  45. package/dist/plugins/blog/client/index.d.ts +2 -2
  46. package/dist/plugins/blog/query-keys.d.cts +2 -2
  47. package/dist/plugins/blog/query-keys.d.mts +2 -2
  48. package/dist/plugins/blog/query-keys.d.ts +2 -2
  49. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  50. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  51. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  52. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  53. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  54. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  55. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  56. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  57. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  58. package/dist/shared/{stack.CjCSDfWy.d.mts → stack.94kN3pEK.d.mts} +5 -5
  59. package/dist/shared/{stack.C4zspX0T.d.ts → stack.B2EYZyh_.d.ts} +5 -2
  60. package/dist/shared/{stack.BGzgFx3R.d.cts → stack.BTStHVTy.d.cts} +5 -5
  61. package/dist/shared/{stack.DhZtP2JZ.d.mts → stack.BggTHKlo.d.mts} +37 -37
  62. package/dist/shared/{stack.DDQlE2Pm.d.cts → stack.Cei2T2gc.d.cts} +1 -0
  63. package/dist/shared/{stack.DNw6Hv5o.d.cts → stack.CtrnoQgH.d.cts} +37 -37
  64. package/dist/shared/{stack.tAUeRAtd.d.mts → stack.DZRZAzcx.d.mts} +1 -0
  65. package/dist/shared/{stack.Dpg_JjIo.d.ts → stack.DfrRYOIl.d.ts} +5 -5
  66. package/dist/shared/{stack.Ps3MDhTC.d.mts → stack.DivuAnNI.d.mts} +5 -2
  67. package/dist/shared/{stack.DlehgNZZ.d.ts → stack.byjorQi5.d.ts} +1 -0
  68. package/dist/shared/{stack.ymWsP4HZ.d.cts → stack.fF273jws.d.cts} +5 -2
  69. package/dist/shared/{stack.Clil75lW.d.ts → stack.lPKwd_v0.d.ts} +37 -37
  70. package/package.json +1 -1
  71. package/src/plugins/ai-chat/__tests__/authorization.test.ts +56 -30
  72. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +25 -0
  73. package/src/plugins/ai-chat/__tests__/client-sweep.test.tsx +101 -0
  74. package/src/plugins/ai-chat/__tests__/persisted-compaction.test.ts +819 -0
  75. package/src/plugins/ai-chat/api/compaction.ts +107 -40
  76. package/src/plugins/ai-chat/api/context-checkpoint.ts +66 -0
  77. package/src/plugins/ai-chat/api/getters.ts +2 -0
  78. package/src/plugins/ai-chat/api/operations.ts +361 -99
  79. package/src/plugins/ai-chat/api/plugin.ts +5 -8
  80. package/src/plugins/ai-chat/api/serializers.ts +1 -0
  81. package/src/plugins/ai-chat/client/components/chat-interface.tsx +43 -22
  82. package/src/plugins/ai-chat/client/components/chat-message.tsx +32 -2
  83. package/src/plugins/ai-chat/client/localization/index.ts +1 -0
  84. package/src/plugins/ai-chat/db.ts +8 -0
  85. package/src/plugins/ai-chat/history.ts +68 -0
  86. package/src/plugins/ai-chat/types.ts +2 -0
  87. package/dist/shared/{stack.CGmh-Lpe.d.ts → stack.BuxUQqL8.d.ts} +12 -12
  88. package/dist/shared/{stack.Csi-4Mgz.d.cts → stack.CKc_Bais.d.cts} +7 -7
  89. package/dist/shared/{stack.Csi-4Mgz.d.mts → stack.CKc_Bais.d.mts} +7 -7
  90. package/dist/shared/{stack.Csi-4Mgz.d.ts → stack.CKc_Bais.d.ts} +7 -7
  91. package/dist/shared/{stack.zch70Loj.d.cts → stack.CX83YNuS.d.cts} +12 -12
  92. package/dist/shared/{stack.Doy2_wgl.d.mts → stack.Egr3RWhA.d.mts} +12 -12
@@ -0,0 +1,819 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import { MockLanguageModelV2 } from "ai/test";
4
+ import {
5
+ lastAssistantMessageIsCompleteWithToolCalls,
6
+ simulateReadableStream,
7
+ tool,
8
+ type UIMessage,
9
+ } from "ai";
10
+ import { z } from "zod";
11
+ import { createBackendStack } from "../../../api";
12
+ import { defineAuthorization } from "../../../authorization";
13
+ import { createServerAuth } from "../../../authorization/server";
14
+ import { aiChatBackendPlugin } from "../api";
15
+ import { aiChatPermissions } from "../permissions";
16
+ import { persistedHistory } from "../history";
17
+ import type { AiChatBackendConfig } from "../api/plugin";
18
+ import type { Conversation, Message } from "../types";
19
+
20
+ const text = (
21
+ id: string,
22
+ role: "user" | "assistant",
23
+ value: string,
24
+ ): UIMessage => ({
25
+ id,
26
+ role,
27
+ parts: [{ type: "text", text: value }],
28
+ });
29
+
30
+ function model() {
31
+ return new MockLanguageModelV2({
32
+ doGenerate: async () => ({
33
+ content: [
34
+ { type: "text", text: "The user's verification code is ORCHID-917." },
35
+ ],
36
+ finishReason: "stop",
37
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
38
+ warnings: [],
39
+ }),
40
+ doStream: async () => ({
41
+ stream: simulateReadableStream({
42
+ chunks: [
43
+ { type: "text-start" as const, id: "answer" },
44
+ { type: "text-delta" as const, id: "answer", delta: "ORCHID-917" },
45
+ { type: "text-end" as const, id: "answer" },
46
+ {
47
+ type: "finish" as const,
48
+ finishReason: "stop" as const,
49
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
50
+ },
51
+ ],
52
+ initialDelayInMs: null,
53
+ chunkDelayInMs: null,
54
+ }),
55
+ }),
56
+ });
57
+ }
58
+
59
+ function backend(
60
+ languageModel = model(),
61
+ config: Partial<AiChatBackendConfig> = {},
62
+ ) {
63
+ const after = vi.fn();
64
+ const errors = vi.fn();
65
+ const authorization = defineAuthorization({
66
+ identity: z.object({ id: z.string() }),
67
+ permissions: [aiChatPermissions] as const,
68
+ rules: ({ aiChat }) => {
69
+ const owns = ({
70
+ identity,
71
+ facts,
72
+ }: {
73
+ identity: { id: string } | null;
74
+ facts: { ownerId?: string };
75
+ }) =>
76
+ Boolean(identity && (!facts.ownerId || facts.ownerId === identity.id));
77
+ return [
78
+ aiChat.stream.start.when(owns),
79
+ aiChat.message.send.when(owns),
80
+ aiChat.message.retry.when(owns),
81
+ aiChat.message.edit.when(owns),
82
+ aiChat.tool.activate.when(owns),
83
+ aiChat.conversation.create.when(({ identity }) => identity !== null),
84
+ aiChat.conversation.read.when(({ identity, facts }) =>
85
+ facts.scope === "collection"
86
+ ? identity !== null
87
+ : owns({ identity, facts }),
88
+ ),
89
+ ];
90
+ },
91
+ });
92
+ const app = createBackendStack({
93
+ basePath: "/api",
94
+ plugins: {
95
+ aiChat: aiChatBackendPlugin({
96
+ model: languageModel,
97
+ compaction: { contextWindowTokens: 4096 },
98
+ ...config,
99
+ hooks: { onAfterChat: after, onErrorChat: errors, ...config.hooks },
100
+ }),
101
+ },
102
+ adapter: (db) => createMemoryAdapter(db)({}),
103
+ auth: createServerAuth({
104
+ authorization,
105
+ getIdentity: ({ request }) => ({
106
+ id: request.headers.get("x-user-id") ?? "owner",
107
+ }),
108
+ }),
109
+ });
110
+ return { app, languageModel, after, errors };
111
+ }
112
+
113
+ async function resume(
114
+ app: ReturnType<typeof backend>["app"],
115
+ id: string,
116
+ ): Promise<UIMessage[]> {
117
+ const saved = await app.trusted.aiChat.getConversation({ id });
118
+ return saved.messages.map((message) => ({
119
+ id: message.id,
120
+ role: message.role as UIMessage["role"],
121
+ parts: JSON.parse(message.content),
122
+ }));
123
+ }
124
+
125
+ const finish = {
126
+ type: "finish" as const,
127
+ finishReason: "stop" as const,
128
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
129
+ };
130
+
131
+ async function seed(app: ReturnType<typeof backend>["app"]) {
132
+ const now = new Date("2026-01-01T00:00:00Z");
133
+ const conversation = await app.adapter.create<Conversation>({
134
+ model: "conversation",
135
+ data: {
136
+ userId: "owner",
137
+ title: "Long chat",
138
+ createdAt: now,
139
+ updatedAt: now,
140
+ },
141
+ });
142
+ const messages = [
143
+ text("u1", "user", "My code is ORCHID-917. " + "research ".repeat(1800)),
144
+ text("a1", "assistant", "I will remember your code."),
145
+ text("u2", "user", "Keep going"),
146
+ text("a2", "assistant", "Ready."),
147
+ ];
148
+ for (const [index, message] of messages.entries()) {
149
+ await app.adapter.create<Message>({
150
+ model: "message",
151
+ forceAllowId: true,
152
+ data: {
153
+ id: message.id,
154
+ conversationId: conversation.id,
155
+ role: message.role,
156
+ content: JSON.stringify(message.parts),
157
+ createdAt: new Date(now.getTime() + index),
158
+ } as Message,
159
+ });
160
+ }
161
+ return { conversation, messages };
162
+ }
163
+
164
+ describe("persisted context compaction", () => {
165
+ it("persists successful responses without content and calls the completion hook", async () => {
166
+ const languageModel = model();
167
+ languageModel.doStream = async () => ({
168
+ stream: simulateReadableStream({
169
+ chunks: [finish],
170
+ initialDelayInMs: null,
171
+ chunkDelayInMs: null,
172
+ }),
173
+ });
174
+ const { app, after, errors } = backend(languageModel);
175
+ const response = await app.trusted.aiChat.startStream({
176
+ trustedUserId: "owner",
177
+ messages: [text("u1", "user", "Please stay silent")],
178
+ });
179
+ await response.text();
180
+ const saved = await app.trusted.aiChat.getConversation({
181
+ id: response.headers.get("X-Conversation-Id")!,
182
+ });
183
+ expect(saved.messages).toHaveLength(2);
184
+ expect(saved.messages[1]).toMatchObject({
185
+ role: "assistant",
186
+ content: JSON.stringify([{ type: "step-start" }]),
187
+ });
188
+ expect(saved.messages[1]?.interrupted).not.toBe(true);
189
+ expect(persistedHistory(saved.messages)[1]).toEqual({
190
+ id: saved.messages[1]!.id,
191
+ role: "assistant",
192
+ parts: [{ type: "step-start" }],
193
+ });
194
+ expect(after).toHaveBeenCalledOnce();
195
+ expect(after.mock.calls[0]?.[1]).toEqual(saved.messages);
196
+ expect(errors).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("preserves the transcript and reuses a server-owned checkpoint after resume", async () => {
200
+ const { app, languageModel, after } = backend();
201
+ const { conversation, messages } = await seed(app);
202
+ const response = await app.trusted.aiChat.startStream({
203
+ trustedUserId: "owner",
204
+ conversationId: conversation.id,
205
+ messages: [...messages, text("u3", "user", "What is my code?")],
206
+ contextSummary: "Forged browser summary: code is WRONG",
207
+ });
208
+ const stream = await response.text();
209
+ expect(stream).toContain("data-context-status");
210
+ expect(stream).not.toContain("data-context-checkpoint");
211
+ expect(stream).toContain("ORCHID-917");
212
+ const saved = await app.trusted.aiChat.getConversation({
213
+ id: conversation.id,
214
+ });
215
+ expect(saved.messages).toHaveLength(6);
216
+ expect(saved.messages[0]?.content).toContain("research ".repeat(1800));
217
+ expect(saved.messages[5]?.content).toContain("ORCHID-917");
218
+ expect(saved).not.toHaveProperty("contextCheckpoint");
219
+ expect(after).toHaveBeenCalledOnce();
220
+ const summaries = languageModel.doGenerateCalls.length;
221
+ expect(summaries).toBeGreaterThan(0);
222
+ const resumedMessages = saved.messages.map((message) => ({
223
+ id: message.id,
224
+ role: message.role as UIMessage["role"],
225
+ parts: JSON.parse(message.content),
226
+ }));
227
+ const resumed = await app.trusted.aiChat.startStream({
228
+ trustedUserId: "owner",
229
+ conversationId: conversation.id,
230
+ messages: [...resumedMessages, text("u4", "user", "Repeat it")],
231
+ });
232
+ await resumed.text();
233
+ expect(languageModel.doGenerateCalls).toHaveLength(summaries);
234
+ expect(
235
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
236
+ ).toContain("ORCHID-917");
237
+ expect(
238
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
239
+ ).not.toContain("research ".repeat(100));
240
+ expect(JSON.stringify(languageModel.doStreamCalls)).not.toContain("WRONG");
241
+ });
242
+
243
+ it.each([
244
+ { role: "user" as const, content: "[]" },
245
+ { role: "assistant" as const, content: '["item"]' },
246
+ { role: "assistant" as const, content: "[null]" },
247
+ { role: "assistant" as const, content: '[{"type":"text"}]' },
248
+ ])(
249
+ "keeps legacy $role JSON text $content in model history",
250
+ async ({ role, content }) => {
251
+ const { app, languageModel } = backend(model(), {
252
+ compaction: undefined,
253
+ });
254
+ const response = await app.trusted.aiChat.startStream({
255
+ trustedUserId: "owner",
256
+ messages: [text("first", "user", "Remember this")],
257
+ });
258
+ await response.text();
259
+ const id = response.headers.get("X-Conversation-Id")!;
260
+ const saved = await app.trusted.aiChat.getConversation({ id });
261
+ const index = role === "user" ? 0 : 1;
262
+ await app.adapter.update({
263
+ model: "message",
264
+ where: [{ field: "id", value: saved.messages[index]!.id }],
265
+ update: { content },
266
+ });
267
+ const restored = await app.trusted.aiChat.getConversation({ id });
268
+ await (
269
+ await app.trusted.aiChat.startStream({
270
+ trustedUserId: "owner",
271
+ conversationId: id,
272
+ messages: [
273
+ ...persistedHistory(restored.messages),
274
+ text("next", "user", "Continue"),
275
+ ],
276
+ })
277
+ ).text();
278
+ expect(languageModel.doStreamCalls.at(-1)?.prompt).toContainEqual(
279
+ expect.objectContaining({
280
+ role,
281
+ content: expect.arrayContaining([
282
+ expect.objectContaining({ type: "text", text: content }),
283
+ ]),
284
+ }),
285
+ );
286
+ },
287
+ );
288
+
289
+ it("restores legacy empty assistant replies without adding literal brackets to model history", async () => {
290
+ const { app, languageModel } = backend(model(), { compaction: undefined });
291
+ const { conversation } = await seed(app);
292
+ await app.adapter.update({
293
+ model: "message",
294
+ where: [{ field: "id", value: "a2" }],
295
+ update: { content: "[]" },
296
+ });
297
+ const saved = await app.trusted.aiChat.getConversation({
298
+ id: conversation.id,
299
+ });
300
+ const history = persistedHistory(saved.messages);
301
+ expect(history.at(-1)?.parts).toEqual([]);
302
+ await (
303
+ await app.trusted.aiChat.startStream({
304
+ trustedUserId: "owner",
305
+ conversationId: conversation.id,
306
+ messages: [...history, text("u3", "user", "Continue")],
307
+ })
308
+ ).text();
309
+ expect(
310
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
311
+ ).not.toContain('"text":"[]"');
312
+ });
313
+
314
+ it("keeps a completed checkpoint when answer generation fails", async () => {
315
+ const languageModel = model();
316
+ const goodStream = languageModel.doStream;
317
+ languageModel.doStream = async () => {
318
+ throw new Error("Provider unavailable");
319
+ };
320
+ const { app, after, errors } = backend(languageModel);
321
+ const { conversation, messages } = await seed(app);
322
+ const input = {
323
+ trustedUserId: "owner",
324
+ conversationId: conversation.id,
325
+ messages: [...messages, text("u3", "user", "What is my code?")],
326
+ };
327
+ await (await app.trusted.aiChat.startStream(input)).text();
328
+ expect(after).not.toHaveBeenCalled();
329
+ expect(errors).toHaveBeenCalledOnce();
330
+ const summaries = languageModel.doGenerateCalls.length;
331
+ languageModel.doStream = goodStream;
332
+ await (
333
+ await app.trusted.aiChat.startStream({
334
+ ...input,
335
+ messages: await resume(app, conversation.id),
336
+ })
337
+ ).text();
338
+ expect(languageModel.doGenerateCalls).toHaveLength(summaries);
339
+ expect(after).toHaveBeenCalledOnce();
340
+ expect((await resume(app, conversation.id)).at(-1)?.parts).toContainEqual({
341
+ type: "text",
342
+ text: "ORCHID-917",
343
+ state: "done",
344
+ });
345
+ });
346
+
347
+ it("retains a checkpoint for edits after its boundary and rebuilds after editing covered history", async () => {
348
+ const { app, languageModel } = backend();
349
+ const { conversation, messages } = await seed(app);
350
+ const send = (messages: UIMessage[]) =>
351
+ app.trusted.aiChat.startStream({
352
+ trustedUserId: "owner",
353
+ conversationId: conversation.id,
354
+ messages,
355
+ });
356
+ await (
357
+ await send([...messages, text("u3", "user", "What is my code?")])
358
+ ).text();
359
+ const summaries = languageModel.doGenerateCalls.length;
360
+ const saved = await resume(app, conversation.id);
361
+ await (
362
+ await send([
363
+ ...saved.slice(0, -2),
364
+ text("edit", "user", "Repeat the code instead"),
365
+ ])
366
+ ).text();
367
+ expect(languageModel.doGenerateCalls).toHaveLength(summaries);
368
+ await (
369
+ await send([
370
+ text(
371
+ "edit-first",
372
+ "user",
373
+ "My NEW code is LILAC. " + "changed ".repeat(900),
374
+ ),
375
+ ])
376
+ ).text();
377
+ expect(
378
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
379
+ ).not.toContain("ORCHID-917");
380
+ const changed = await resume(app, conversation.id);
381
+ expect(changed[0]?.parts).toEqual([
382
+ { type: "text", text: "My NEW code is LILAC. " + "changed ".repeat(900) },
383
+ ]);
384
+ await (
385
+ await send([
386
+ ...changed,
387
+ text("next", "user", "Continue " + "context ".repeat(900)),
388
+ ])
389
+ ).text();
390
+ expect(languageModel.doGenerateCalls.length).toBeGreaterThan(summaries);
391
+ });
392
+
393
+ it("persists complete server tool results and uses them after reload", async () => {
394
+ const languageModel = model();
395
+ const goodStream = languageModel.doStream;
396
+ let calls = 0;
397
+ languageModel.doStream = async (options) =>
398
+ ++calls === 1
399
+ ? {
400
+ stream: simulateReadableStream({
401
+ chunks: [
402
+ {
403
+ type: "tool-call" as const,
404
+ toolCallId: "lookup-1",
405
+ toolName: "lookup",
406
+ input: "{}",
407
+ },
408
+ { ...finish, finishReason: "tool-calls" as const },
409
+ ],
410
+ initialDelayInMs: null,
411
+ chunkDelayInMs: null,
412
+ }),
413
+ }
414
+ : goodStream(options);
415
+ const lookup = vi.fn().mockResolvedValue({ reference: "TOOL-ONLY-583" });
416
+ const { app } = backend(languageModel, {
417
+ tools: { lookup: tool({ inputSchema: z.object({}), execute: lookup }) },
418
+ });
419
+ const response = await app.trusted.aiChat.startStream({
420
+ trustedUserId: "owner",
421
+ messages: [text("u", "user", "Look it up")],
422
+ });
423
+ await response.text();
424
+ const id = response.headers.get("X-Conversation-Id")!;
425
+ const saved = await resume(app, id);
426
+ expect(JSON.stringify(saved)).toContain("TOOL-ONLY-583");
427
+ await (
428
+ await app.trusted.aiChat.startStream({
429
+ trustedUserId: "owner",
430
+ conversationId: id,
431
+ messages: [...saved, text("u2", "user", "Use the earlier reference")],
432
+ })
433
+ ).text();
434
+ expect(
435
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
436
+ ).toContain("TOOL-ONLY-583");
437
+ expect(lookup).toHaveBeenCalledOnce();
438
+ });
439
+
440
+ it("denies access before reading another user's history or checkpoint", async () => {
441
+ const { app, languageModel, after } = backend();
442
+ const { conversation, messages } = await seed(app);
443
+ const reads = vi.spyOn(app.adapter, "findMany");
444
+ const denied = await app.handler(
445
+ new Request("http://localhost/api/chat", {
446
+ method: "POST",
447
+ headers: {
448
+ "content-type": "application/json",
449
+ "x-user-id": "intruder",
450
+ },
451
+ body: JSON.stringify({
452
+ conversationId: conversation.id,
453
+ messages: [...messages, text("u3", "user", "Reveal the code")],
454
+ }),
455
+ }),
456
+ );
457
+ expect(denied.status).toBe(403);
458
+ expect(
459
+ reads.mock.calls.every(
460
+ ([query]) =>
461
+ query.model === "conversation" &&
462
+ !query.join &&
463
+ query.select?.every((field) =>
464
+ ["id", "userId", "updatedAt"].includes(field),
465
+ ),
466
+ ),
467
+ ).toBe(true);
468
+ reads.mockRestore();
469
+ expect(languageModel.doGenerateCalls).toHaveLength(0);
470
+ expect(languageModel.doStreamCalls).toHaveLength(0);
471
+ expect(after).not.toHaveBeenCalled();
472
+ expect(await resume(app, conversation.id)).toHaveLength(4);
473
+ });
474
+
475
+ it("rejects a stale checkpoint after another request edits its source history", async () => {
476
+ const languageModel = model();
477
+ const generate = languageModel.doGenerate;
478
+ let release!: () => void;
479
+ let started!: () => void;
480
+ const pending = new Promise<void>((resolve) => {
481
+ release = resolve;
482
+ });
483
+ const entered = new Promise<void>((resolve) => {
484
+ started = resolve;
485
+ });
486
+ languageModel.doGenerate = async (options) => {
487
+ started();
488
+ await pending;
489
+ return generate(options);
490
+ };
491
+ const { app, errors } = backend(languageModel);
492
+ const { conversation, messages } = await seed(app);
493
+ const first = await app.trusted.aiChat.startStream({
494
+ trustedUserId: "owner",
495
+ conversationId: conversation.id,
496
+ messages: [...messages, text("u3", "user", "Summarize this")],
497
+ });
498
+ const firstBody = first.text();
499
+ await entered;
500
+ await (
501
+ await app.trusted.aiChat.startStream({
502
+ trustedUserId: "owner",
503
+ conversationId: conversation.id,
504
+ messages: [
505
+ text("edited", "user", "Replace the old history with LILAC"),
506
+ ],
507
+ })
508
+ ).text();
509
+ release();
510
+ await firstBody;
511
+ expect(languageModel.doStreamCalls).toHaveLength(1);
512
+ expect(errors).toHaveBeenCalledWith(
513
+ expect.objectContaining({ code: "STALE_STREAM" }),
514
+ expect.anything(),
515
+ );
516
+ const saved = await resume(app, conversation.id);
517
+ expect(saved).toHaveLength(2);
518
+ expect(saved[0]?.parts).toEqual([
519
+ { type: "text", text: "Replace the old history with LILAC" },
520
+ ]);
521
+ await (
522
+ await app.trusted.aiChat.startStream({
523
+ trustedUserId: "owner",
524
+ conversationId: conversation.id,
525
+ messages: [...saved, text("u4", "user", "Continue")],
526
+ })
527
+ ).text();
528
+ expect(
529
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
530
+ ).not.toContain("Earlier conversation summary");
531
+ });
532
+
533
+ it("saves partial text and completed tools on abort, keeps the checkpoint, and excludes unfinished calls on resume", async () => {
534
+ const languageModel = model();
535
+ const goodStream = languageModel.doStream;
536
+ let started!: () => void;
537
+ const entered = new Promise<void>((resolve) => {
538
+ started = resolve;
539
+ });
540
+ let calls = 0;
541
+ languageModel.doStream = async ({ abortSignal }) => {
542
+ if (++calls === 1)
543
+ return {
544
+ stream: simulateReadableStream({
545
+ chunks: [
546
+ {
547
+ type: "tool-call" as const,
548
+ toolCallId: "finished-tool",
549
+ toolName: "lookup",
550
+ input: "{}",
551
+ },
552
+ { ...finish, finishReason: "tool-calls" as const },
553
+ ],
554
+ initialDelayInMs: null,
555
+ chunkDelayInMs: null,
556
+ }),
557
+ };
558
+ return {
559
+ stream: new ReadableStream({
560
+ start(controller) {
561
+ controller.enqueue({ type: "text-start", id: "partial" });
562
+ controller.enqueue({
563
+ type: "text-delta",
564
+ id: "partial",
565
+ delta: "Partial explanation",
566
+ providerMetadata: {
567
+ openai: { itemId: "expired-item-id" },
568
+ other: { keep: true },
569
+ },
570
+ });
571
+ controller.enqueue({
572
+ type: "tool-input-start",
573
+ id: "unfinished-tool",
574
+ toolName: "lookup",
575
+ });
576
+ controller.enqueue({
577
+ type: "tool-input-delta",
578
+ id: "unfinished-tool",
579
+ delta: "{",
580
+ });
581
+ abortSignal?.addEventListener(
582
+ "abort",
583
+ () => controller.error(new DOMException("Aborted", "AbortError")),
584
+ { once: true },
585
+ );
586
+ started();
587
+ },
588
+ }),
589
+ };
590
+ };
591
+ const { app, after } = backend(languageModel, {
592
+ tools: {
593
+ lookup: tool({
594
+ inputSchema: z.object({}),
595
+ execute: async () => ({ reference: "KEPT-TOOL-RESULT" }),
596
+ }),
597
+ },
598
+ });
599
+ const { conversation, messages } = await seed(app);
600
+ const controller = new AbortController();
601
+ const response = await app
602
+ .forRequest(
603
+ new Request("http://localhost/api/chat", { signal: controller.signal }),
604
+ )
605
+ .operations.aiChat.startStream({
606
+ conversationId: conversation.id,
607
+ messages: [...messages, text("u3", "user", "Use the tool and explain")],
608
+ });
609
+ const reader = response.body!.getReader();
610
+ await entered;
611
+ let received = "";
612
+ while (!received.includes('"toolCallId":"unfinished-tool"')) {
613
+ const { value, done } = await reader.read();
614
+ if (done) throw new Error("Stream finished before the pending tool call");
615
+ received += new TextDecoder().decode(value);
616
+ }
617
+ controller.abort();
618
+ while (!(await reader.read()).done) {
619
+ /* Drain completion persistence. */
620
+ }
621
+ const saved = await app.trusted.aiChat.getConversation({
622
+ id: conversation.id,
623
+ });
624
+ expect(saved.messages.at(-1)?.interrupted).toBe(true);
625
+ expect(saved.messages.at(-1)?.content).toContain("Partial explanation");
626
+ expect(saved.messages.at(-1)?.content).toContain("KEPT-TOOL-RESULT");
627
+ expect(after).not.toHaveBeenCalled();
628
+ const summaries = languageModel.doGenerateCalls.length;
629
+ languageModel.doStream = goodStream;
630
+ await (
631
+ await app.trusted.aiChat.startStream({
632
+ trustedUserId: "owner",
633
+ conversationId: conversation.id,
634
+ messages: [
635
+ ...(await resume(app, conversation.id)),
636
+ text("u4", "user", "Continue from there"),
637
+ ],
638
+ })
639
+ ).text();
640
+ expect(languageModel.doGenerateCalls).toHaveLength(summaries);
641
+ const prompt = JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt);
642
+ expect(prompt).toContain("KEPT-TOOL-RESULT");
643
+ expect(prompt).toContain("Partial explanation");
644
+ expect(prompt).not.toContain("unfinished-tool");
645
+ expect(prompt).not.toContain("expired-item-id");
646
+ expect(prompt).toContain('"keep":true');
647
+ expect(after).toHaveBeenCalledOnce();
648
+ });
649
+
650
+ it("continues an unchanged server-tool chain after the step limit and rejects altered results", async () => {
651
+ const languageModel = model();
652
+ const goodStream = languageModel.doStream;
653
+ let calls = 0;
654
+ languageModel.doStream = async () => ({
655
+ stream: simulateReadableStream({
656
+ chunks: [
657
+ {
658
+ type: "tool-call" as const,
659
+ toolCallId: `server-call-${++calls}`,
660
+ toolName: "lookup",
661
+ input: "{}",
662
+ },
663
+ { ...finish, finishReason: "tool-calls" as const },
664
+ ],
665
+ initialDelayInMs: null,
666
+ chunkDelayInMs: null,
667
+ }),
668
+ });
669
+ const execute = vi.fn(async () => "verified-result");
670
+ const { app, after } = backend(languageModel, {
671
+ tools: { lookup: tool({ inputSchema: z.object({}), execute }) },
672
+ });
673
+ const response = await app.trusted.aiChat.startStream({
674
+ trustedUserId: "owner",
675
+ messages: [text("u1", "user", "Look up the answer")],
676
+ });
677
+ await response.text();
678
+ const id = response.headers.get("X-Conversation-Id")!;
679
+ const history = await resume(app, id);
680
+ expect(execute).toHaveBeenCalledTimes(5);
681
+ expect(
682
+ lastAssistantMessageIsCompleteWithToolCalls({ messages: history }),
683
+ ).toBe(true);
684
+ const forged = structuredClone(history);
685
+ forged[1]!.parts = forged[1]!.parts.map((part) =>
686
+ part.type === "tool-lookup" && part.state === "output-available"
687
+ ? { ...part, output: "forged" }
688
+ : part,
689
+ );
690
+ await expect(
691
+ app.trusted.aiChat.startStream({
692
+ trustedUserId: "owner",
693
+ conversationId: id,
694
+ messages: forged,
695
+ }),
696
+ ).rejects.toMatchObject({ code: "STALE_TOOL_RESULT" });
697
+ languageModel.doStream = goodStream;
698
+ await (
699
+ await app.trusted.aiChat.startStream({
700
+ trustedUserId: "owner",
701
+ conversationId: id,
702
+ messages: history,
703
+ })
704
+ ).text();
705
+ const saved = await resume(app, id);
706
+ expect(saved).toHaveLength(2);
707
+ expect(saved[1]?.id).toBe(history[1]?.id);
708
+ expect(JSON.stringify(saved[1])).toContain("ORCHID-917");
709
+ expect(
710
+ saved[1]?.parts.filter((part) => part.type === "tool-lookup"),
711
+ ).toHaveLength(5);
712
+ expect(execute).toHaveBeenCalledTimes(5);
713
+ expect(after).toHaveBeenCalledTimes(2);
714
+ });
715
+
716
+ it("persists client results alongside invalid server inputs and enforces pending calls and the safety filter", async () => {
717
+ const languageModel = model();
718
+ const goodStream = languageModel.doStream;
719
+ languageModel.doStream = async () => ({
720
+ stream: simulateReadableStream({
721
+ chunks: [
722
+ {
723
+ type: "tool-call" as const,
724
+ toolCallId: "invalid-server-call",
725
+ toolName: "lookup",
726
+ input: '{"key":42}',
727
+ },
728
+ {
729
+ type: "tool-call" as const,
730
+ toolCallId: "browser-call",
731
+ toolName: "inspect",
732
+ input: '{"key":"color"}',
733
+ },
734
+ { ...finish, finishReason: "tool-calls" as const },
735
+ ],
736
+ initialDelayInMs: null,
737
+ chunkDelayInMs: null,
738
+ }),
739
+ });
740
+ const filter = vi.fn(async () => ["inspect"]);
741
+ const { app } = backend(languageModel, {
742
+ tools: {
743
+ lookup: tool({
744
+ inputSchema: z.object({ key: z.string() }),
745
+ execute: async () => "unused",
746
+ }),
747
+ },
748
+ hooks: { onBeforeActivateTools: filter },
749
+ enablePageTools: true,
750
+ clientToolSchemas: {
751
+ inspect: tool({ inputSchema: z.object({ key: z.string() }) }),
752
+ },
753
+ });
754
+ const response = await app.trusted.aiChat.startStream({
755
+ trustedUserId: "owner",
756
+ availableTools: ["inspect"],
757
+ messages: [text("u1", "user", "Read the browser color")],
758
+ });
759
+ await response.text();
760
+ const id = response.headers.get("X-Conversation-Id")!;
761
+ const history = await resume(app, id);
762
+ expect(history[1]?.parts).toContainEqual(
763
+ expect.objectContaining({
764
+ type: "tool-lookup",
765
+ state: "output-error",
766
+ rawInput: { key: 42 },
767
+ }),
768
+ );
769
+ const completed = structuredClone(history);
770
+ completed[1]!.parts = completed[1]!.parts.map((part) =>
771
+ part.type === "tool-inspect"
772
+ ? {
773
+ ...part,
774
+ state: "output-available",
775
+ output: "ultramarine",
776
+ errorText: undefined,
777
+ }
778
+ : part,
779
+ );
780
+ const forged = structuredClone(completed);
781
+ forged[1]!.parts = forged[1]!.parts.map((part) =>
782
+ "toolCallId" in part ? { ...part, toolCallId: "invented" } : part,
783
+ );
784
+ await expect(
785
+ app.trusted.aiChat.startStream({
786
+ trustedUserId: "owner",
787
+ conversationId: id,
788
+ availableTools: ["inspect"],
789
+ messages: forged,
790
+ }),
791
+ ).rejects.toMatchObject({ code: "STALE_TOOL_RESULT" });
792
+ filter.mockResolvedValue([]);
793
+ await expect(
794
+ app.trusted.aiChat.startStream({
795
+ trustedUserId: "owner",
796
+ conversationId: id,
797
+ availableTools: ["inspect"],
798
+ messages: completed,
799
+ }),
800
+ ).rejects.toMatchObject({ code: "STALE_TOOL_RESULT" });
801
+ filter.mockResolvedValue(["inspect"]);
802
+ languageModel.doStream = goodStream;
803
+ await (
804
+ await app.trusted.aiChat.startStream({
805
+ trustedUserId: "owner",
806
+ conversationId: id,
807
+ availableTools: ["inspect"],
808
+ messages: completed,
809
+ })
810
+ ).text();
811
+ const saved = await resume(app, id);
812
+ expect(saved).toHaveLength(2);
813
+ expect(JSON.stringify(saved)).toContain("ultramarine");
814
+ expect(saved[1]?.id).toBe(history[1]?.id);
815
+ expect(
816
+ JSON.stringify(languageModel.doStreamCalls.at(-1)?.prompt),
817
+ ).toContain("ultramarine");
818
+ });
819
+ });