@btst/stack 4.3.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 (120) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.cjs +289 -0
  3. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +281 -0
  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 +285 -89
  9. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +285 -89
  10. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +6 -3
  11. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +6 -3
  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 +62 -17
  15. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +62 -17
  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/client/context-checkpoint.cjs +30 -0
  19. package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.mjs +27 -0
  20. package/dist/packages/stack/src/plugins/ai-chat/db.cjs +8 -0
  21. package/dist/packages/stack/src/plugins/ai-chat/db.mjs +8 -0
  22. package/dist/packages/stack/src/plugins/ai-chat/history.cjs +47 -0
  23. package/dist/packages/stack/src/plugins/ai-chat/history.mjs +45 -0
  24. package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +2 -0
  25. package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +2 -0
  26. package/dist/packages/ui/src/components/markdown-content.cjs +9 -9
  27. package/dist/packages/ui/src/components/markdown-content.mjs +9 -9
  28. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  29. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  30. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  31. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  32. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  33. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  34. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  35. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  36. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  37. package/dist/plugins/ai-chat/client/index.d.cts +3 -3
  38. package/dist/plugins/ai-chat/client/index.d.mts +3 -3
  39. package/dist/plugins/ai-chat/client/index.d.ts +3 -3
  40. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  41. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  42. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  43. package/dist/plugins/blog/api/index.d.cts +2 -2
  44. package/dist/plugins/blog/api/index.d.mts +2 -2
  45. package/dist/plugins/blog/api/index.d.ts +2 -2
  46. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  47. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  48. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  49. package/dist/plugins/blog/client/index.d.cts +2 -2
  50. package/dist/plugins/blog/client/index.d.mts +2 -2
  51. package/dist/plugins/blog/client/index.d.ts +2 -2
  52. package/dist/plugins/blog/query-keys.d.cts +2 -2
  53. package/dist/plugins/blog/query-keys.d.mts +2 -2
  54. package/dist/plugins/blog/query-keys.d.ts +2 -2
  55. package/dist/plugins/cms/api/index.d.cts +1 -1
  56. package/dist/plugins/cms/api/index.d.mts +1 -1
  57. package/dist/plugins/cms/api/index.d.ts +1 -1
  58. package/dist/plugins/cms/query-keys.d.cts +1 -1
  59. package/dist/plugins/cms/query-keys.d.mts +1 -1
  60. package/dist/plugins/cms/query-keys.d.ts +1 -1
  61. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  62. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  63. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  64. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  65. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  66. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  67. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  68. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  69. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  70. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  71. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  72. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  73. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  74. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  75. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  76. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  77. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  78. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  79. package/dist/shared/{stack.D2y4gl03.d.mts → stack.94kN3pEK.d.mts} +5 -5
  80. package/dist/shared/{stack.DBH1Bjko.d.ts → stack.B2EYZyh_.d.ts} +16 -2
  81. package/dist/shared/{stack.rmWRtSjs.d.cts → stack.BTStHVTy.d.cts} +5 -5
  82. package/dist/shared/{stack.CZ1h_T4k.d.mts → stack.BggTHKlo.d.mts} +90 -90
  83. package/dist/shared/{stack.DzJgoNqJ.d.cts → stack.Cei2T2gc.d.cts} +2 -0
  84. package/dist/shared/{stack.DiYg7Jw1.d.cts → stack.CtrnoQgH.d.cts} +90 -90
  85. package/dist/shared/{stack.CHrtGKLz.d.mts → stack.DZRZAzcx.d.mts} +2 -0
  86. package/dist/shared/{stack.CnDMBj4z.d.ts → stack.DfrRYOIl.d.ts} +5 -5
  87. package/dist/shared/{stack.B7Mj74BQ.d.mts → stack.DivuAnNI.d.mts} +16 -2
  88. package/dist/shared/{stack.qMp2VzNw.d.ts → stack.byjorQi5.d.ts} +2 -0
  89. package/dist/shared/{stack.Dgt3MCtW.d.cts → stack.fF273jws.d.cts} +16 -2
  90. package/dist/shared/{stack.DQpyA3Cq.d.ts → stack.lPKwd_v0.d.ts} +90 -90
  91. package/package.json +1 -1
  92. package/src/__tests__/markdown-link-navigation.test.tsx +29 -0
  93. package/src/plugins/ai-chat/__tests__/authorization.test.ts +56 -30
  94. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +25 -0
  95. package/src/plugins/ai-chat/__tests__/client-sweep.test.tsx +101 -0
  96. package/src/plugins/ai-chat/__tests__/compaction.test.ts +272 -0
  97. package/src/plugins/ai-chat/__tests__/persisted-compaction.test.ts +819 -0
  98. package/src/plugins/ai-chat/api/compaction.ts +377 -0
  99. package/src/plugins/ai-chat/api/context-checkpoint.ts +66 -0
  100. package/src/plugins/ai-chat/api/getters.ts +2 -0
  101. package/src/plugins/ai-chat/api/operations.ts +380 -93
  102. package/src/plugins/ai-chat/api/plugin.ts +15 -4
  103. package/src/plugins/ai-chat/api/serializers.ts +1 -0
  104. package/src/plugins/ai-chat/client/components/chat-interface.tsx +97 -21
  105. package/src/plugins/ai-chat/client/components/chat-message.tsx +32 -2
  106. package/src/plugins/ai-chat/client/context-checkpoint.ts +47 -0
  107. package/src/plugins/ai-chat/client/localization/index.ts +2 -0
  108. package/src/plugins/ai-chat/db.ts +8 -0
  109. package/src/plugins/ai-chat/history.ts +68 -0
  110. package/src/plugins/ai-chat/schemas.ts +2 -0
  111. package/src/plugins/ai-chat/types.ts +2 -0
  112. package/dist/shared/{stack.lsbdKRj7.d.mts → stack.B8PG21zX.d.mts} +16 -16
  113. package/dist/shared/{stack.CGmh-Lpe.d.ts → stack.BuxUQqL8.d.ts} +12 -12
  114. package/dist/shared/{stack.DtZgEX84.d.cts → stack.CKc_Bais.d.cts} +6 -6
  115. package/dist/shared/{stack.DtZgEX84.d.mts → stack.CKc_Bais.d.mts} +6 -6
  116. package/dist/shared/{stack.DtZgEX84.d.ts → stack.CKc_Bais.d.ts} +6 -6
  117. package/dist/shared/{stack.zch70Loj.d.cts → stack.CX83YNuS.d.cts} +12 -12
  118. package/dist/shared/{stack.BMlTZnZp.d.ts → stack.CyalJ0j6.d.ts} +16 -16
  119. package/dist/shared/{stack.BV0uNV4f.d.cts → stack.D6Pg-ufX.d.cts} +16 -16
  120. package/dist/shared/{stack.Doy2_wgl.d.mts → stack.Egr3RWhA.d.mts} +12 -12
@@ -14,18 +14,53 @@ import {
14
14
  import { aiChatPermissions } from "../permissions";
15
15
  import type { Conversation, Message } from "../types";
16
16
 
17
- const { streamText } = vi.hoisted(() => ({
17
+ const { streamText, completions } = vi.hoisted(() => ({
18
+ completions: [] as Array<
19
+ NonNullable<
20
+ Parameters<typeof import("ai").createUIMessageStream>[0]["onFinish"]
21
+ >
22
+ >,
18
23
  streamText: vi.fn(() => ({
24
+ toUIMessageStream: () =>
25
+ new ReadableStream({
26
+ start(controller) {
27
+ controller.close();
28
+ },
29
+ }),
19
30
  toUIMessageStreamResponse: () => new Response("stream", { status: 200 }),
20
31
  })),
21
32
  }));
22
33
 
23
- vi.mock("ai", async (importOriginal) => ({
24
- ...(await importOriginal<typeof import("ai")>()),
25
- convertToModelMessages: (messages: unknown) => messages,
26
- stepCountIs: () => () => false,
27
- streamText,
28
- }));
34
+ vi.mock("ai", async (importOriginal) => {
35
+ const original = await importOriginal<typeof import("ai")>();
36
+ return {
37
+ ...original,
38
+ createUIMessageStream: (
39
+ options: Parameters<typeof original.createUIMessageStream>[0],
40
+ ) => {
41
+ if (options.onFinish) completions.push(options.onFinish);
42
+ return original.createUIMessageStream(options);
43
+ },
44
+ convertToModelMessages: (messages: unknown) => messages,
45
+ stepCountIs: () => () => false,
46
+ streamText,
47
+ };
48
+ });
49
+
50
+ function completion(text: string) {
51
+ const responseMessage = {
52
+ id: "server-response",
53
+ role: "assistant" as const,
54
+ parts: [{ type: "text" as const, text }],
55
+ };
56
+ return {
57
+ responseMessage,
58
+ messages: [responseMessage],
59
+ isAborted: false,
60
+ isContinuation: false,
61
+ finishReason: "stop" as const,
62
+ };
63
+ }
29
64
 
30
65
  type Identity = { id: string; role: "user" | "admin" };
31
66
 
@@ -173,7 +208,10 @@ async function seedMessage(
173
208
  }
174
209
 
175
210
  describe("AI Chat operation authorization", () => {
176
- beforeEach(() => streamText.mockClear());
211
+ beforeEach(() => {
212
+ streamText.mockClear();
213
+ completions.length = 0;
214
+ });
177
215
 
178
216
  it("publishes one executable inventory for every maintained transport", () => {
179
217
  const plugin = aiChatBackendPlugin({ model });
@@ -815,15 +853,7 @@ describe("AI Chat operation authorization", () => {
815
853
  await app
816
854
  .forRequest(request("/chat", { identity: owner }))
817
855
  .operations.aiChat.startStream(messageBody);
818
- const finish = (
819
- streamText.mock.calls as unknown as Array<
820
- [
821
- {
822
- onFinish: (completion: { text: string }) => Promise<void>;
823
- },
824
- ]
825
- >
826
- )[0]?.[0].onFinish;
856
+ const finish = completions[0];
827
857
  expect(finish).toBeDefined();
828
858
  if (!finish) throw new Error("Missing stream completion callback");
829
859
  const adapterConfig = app.adapter.options?.adapterConfig;
@@ -831,7 +861,7 @@ describe("AI Chat operation authorization", () => {
831
861
  if (!adapterConfig) throw new Error("Missing adapter config");
832
862
  adapterConfig.transaction = false;
833
863
 
834
- await finish({ text: "unsafe answer" });
864
+ await finish(completion("unsafe answer"));
835
865
 
836
866
  expect(
837
867
  await app.adapter.count({
@@ -876,6 +906,7 @@ describe("AI Chat operation authorization", () => {
876
906
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
877
907
  permissions: [aiChatPermissions] as const,
878
908
  rules: ({ aiChat }) => [
909
+ aiChat.conversation.read.allow(),
879
910
  aiChat.stream.start.allow(),
880
911
  aiChat.message.retry.when(({ facts }) => {
881
912
  observed.push({ intent: "retry", messageId: facts.messageId });
@@ -959,6 +990,7 @@ describe("AI Chat operation authorization", () => {
959
990
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
960
991
  permissions: [aiChatPermissions] as const,
961
992
  rules: ({ aiChat }) => [
993
+ aiChat.conversation.read.allow(),
962
994
  aiChat.stream.start.allow(),
963
995
  aiChat.message.retry.allow(),
964
996
  aiChat.message.edit.when(() => false),
@@ -1030,6 +1062,7 @@ describe("AI Chat operation authorization", () => {
1030
1062
  identity: z.object({ id: z.string(), role: z.enum(["user", "admin"]) }),
1031
1063
  permissions: [aiChatPermissions] as const,
1032
1064
  rules: ({ aiChat }) => [
1065
+ aiChat.conversation.read.allow(),
1033
1066
  aiChat.stream.start.allow(),
1034
1067
  aiChat.message.send.allow(),
1035
1068
  aiChat.message.retry.when(({ facts }) => observedRetry(facts)),
@@ -1490,7 +1523,7 @@ describe("AI Chat operation authorization", () => {
1490
1523
  expect(before).not.toHaveBeenCalled();
1491
1524
  expect(streamText).not.toHaveBeenCalled();
1492
1525
  expect(await app.adapter.count({ model: "message" })).toBe(0);
1493
- expect(onError).toHaveBeenCalledOnce();
1526
+ expect(onError).not.toHaveBeenCalled();
1494
1527
  });
1495
1528
 
1496
1529
  it("rechecks a missing requested conversation before entering hooks", async () => {
@@ -1962,20 +1995,13 @@ describe("AI Chat operation authorization", () => {
1962
1995
  await app
1963
1996
  .forRequest(request("/first", { identity: owner }))
1964
1997
  .operations.aiChat.startStream(input);
1965
- const streamCalls = streamText.mock.calls as unknown as Array<
1966
- [
1967
- {
1968
- onFinish: (completion: { text: string }) => Promise<void>;
1969
- },
1970
- ]
1971
- >;
1972
- const firstFinish = streamCalls[0]?.[0].onFinish;
1998
+ const firstFinish = completions[0];
1973
1999
  await app
1974
2000
  .forRequest(request("/second", { identity: owner }))
1975
2001
  .operations.aiChat.startStream(input);
1976
- const secondFinish = streamCalls[1]?.[0].onFinish;
2002
+ const secondFinish = completions[1];
1977
2003
 
1978
- await firstFinish?.({ text: "stale answer" });
2004
+ await firstFinish?.(completion("stale answer"));
1979
2005
  expect(
1980
2006
  await app.adapter.count({
1981
2007
  model: "message",
@@ -1984,7 +2010,7 @@ describe("AI Chat operation authorization", () => {
1984
2010
  ).toBe(0);
1985
2011
  expect(onErrorChat).toHaveBeenCalledOnce();
1986
2012
 
1987
- await secondFinish?.({ text: "current answer" });
2013
+ await secondFinish?.(completion("current answer"));
1988
2014
  const assistants = await app.adapter.findMany<Message>({
1989
2015
  model: "message",
1990
2016
  where: [{ field: "role", value: "assistant", operator: "eq" }],
@@ -161,6 +161,31 @@ function query(selector: string): HTMLElement {
161
161
  }
162
162
 
163
163
  describe("ai-chat classNames overrides (issue #36)", () => {
164
+ it("labels interrupted responses and stops unfinished tool indicators", async () => {
165
+ await render(
166
+ <ChatMessage
167
+ message={{
168
+ ...assistantMessage,
169
+ metadata: { interrupted: true },
170
+ parts: [
171
+ ...assistantMessage.parts,
172
+ {
173
+ type: "tool-inspect",
174
+ toolCallId: "pending",
175
+ state: "input-available",
176
+ input: {},
177
+ },
178
+ ],
179
+ }}
180
+ />,
181
+ );
182
+ expect(query('[data-testid="chat-interrupted"]').textContent).toBe(
183
+ "Response interrupted",
184
+ );
185
+ expect(container.textContent).not.toContain("Executing...");
186
+ expect(container.querySelector(".animate-spin")).toBeNull();
187
+ });
188
+
164
189
  it("applies message, userMessage and assistantMessage to a user message", async () => {
165
190
  await render(<ChatMessage message={userMessage} />);
166
191
 
@@ -301,6 +301,107 @@ function menuItem(text: string) {
301
301
  }
302
302
 
303
303
  describe("AI Chat permissions", () => {
304
+ it("shows authorized compaction status and ignores browser checkpoints", async () => {
305
+ let chatOptions: any;
306
+ const messages = [
307
+ { id: "u1", role: "user", parts: [{ type: "text", text: "Hello" }] },
308
+ ];
309
+ mocks.useAiChatIdentityPartition.mockReturnValue({
310
+ id: "owner-1",
311
+ role: "user",
312
+ });
313
+ mocks.useChat.mockImplementation((options) => {
314
+ chatOptions = options;
315
+ return {
316
+ messages,
317
+ status: "streaming",
318
+ error: null,
319
+ sendMessage: vi.fn(),
320
+ setMessages: vi.fn(),
321
+ regenerate: vi.fn(),
322
+ addToolOutput: vi.fn(),
323
+ stop: vi.fn(),
324
+ };
325
+ });
326
+ await render(<ChatInterface id={conversation.id} />);
327
+ await act(async () => {
328
+ chatOptions.transport.prepareSendMessagesRequest({ messages });
329
+ chatOptions.onData({
330
+ type: "data-context-status",
331
+ data: { compacting: true },
332
+ });
333
+ chatOptions.onData({
334
+ type: "data-context-checkpoint",
335
+ data: { summary: "Do not trust this", throughMessageId: "u1" },
336
+ });
337
+ });
338
+ expect(container.textContent).toContain("Summarizing");
339
+ let next: any;
340
+ await act(async () => {
341
+ next = chatOptions.transport.prepareSendMessagesRequest({ messages });
342
+ });
343
+ expect(next.body.messages).toEqual(messages);
344
+ expect(next.body).not.toHaveProperty("contextSummary");
345
+ });
346
+
347
+ it.each([
348
+ { interrupted: true, state: "output-available" },
349
+ { interrupted: false, state: "input-available" },
350
+ { interrupted: false, state: "input-streaming" },
351
+ ])(
352
+ "restores interrupted or abandoned $state tools without a live callback",
353
+ async ({ interrupted, state }) => {
354
+ const setMessages = vi.fn();
355
+ mocks.useChat.mockReturnValue({
356
+ messages: [],
357
+ status: "ready",
358
+ error: null,
359
+ sendMessage: vi.fn(),
360
+ setMessages,
361
+ regenerate: vi.fn(),
362
+ addToolOutput: vi.fn(),
363
+ stop: vi.fn(),
364
+ });
365
+ const parts = [
366
+ { type: "text", text: "Partial response" },
367
+ {
368
+ type: "tool-inspect",
369
+ toolCallId: "call",
370
+ state,
371
+ input: {},
372
+ ...(state === "output-available" ? { output: "saved result" } : {}),
373
+ },
374
+ ];
375
+ mocks.useConversation.mockReturnValue({
376
+ conversation: {
377
+ ...conversation,
378
+ messages: [
379
+ {
380
+ id: "answer",
381
+ conversationId: conversation.id,
382
+ role: "assistant",
383
+ content: JSON.stringify(parts),
384
+ interrupted,
385
+ createdAt: conversation.createdAt,
386
+ },
387
+ ],
388
+ },
389
+ isLoading: false,
390
+ error: null,
391
+ refetch: vi.fn(),
392
+ });
393
+ await render(<ChatInterface id={conversation.id} />);
394
+ expect(setMessages).toHaveBeenCalledWith([
395
+ {
396
+ id: "answer",
397
+ role: "assistant",
398
+ parts,
399
+ metadata: { interrupted: true },
400
+ },
401
+ ]);
402
+ },
403
+ );
404
+
304
405
  it("uses the resolved AI Chat endpoint for the browser stream transport", async () => {
305
406
  const observeRuntime = vi.fn();
306
407
  const stack = createClientStack({
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createMemoryAdapter } from "@btst/adapter-memory";
3
+ import { MockLanguageModelV2 } from "ai/test";
4
+ import { simulateReadableStream, type ModelMessage, type UIMessage } from "ai";
5
+ import { createBackendStack } from "../../../api";
6
+ import { aiChatBackendPlugin } from "../api";
7
+ import {
8
+ compactConversation,
9
+ createStepCompactor,
10
+ createSummarizer,
11
+ estimateContextTokens,
12
+ } from "../api/compaction";
13
+ import {
14
+ captureContextCheckpoint,
15
+ contextRequest,
16
+ } from "../client/context-checkpoint";
17
+
18
+ const message = (
19
+ id: string,
20
+ role: "user" | "assistant",
21
+ text: string,
22
+ ): UIMessage => ({ id, role, parts: [{ type: "text", text }] });
23
+ const history = [
24
+ message("u1", "user", "Remember my goal. " + "research ".repeat(2000)),
25
+ message("a1", "assistant", "https://example.org/source; evidence uncertain"),
26
+ message("u2", "user", "Compare the alternatives"),
27
+ message("a2", "assistant", "A comparison"),
28
+ message("u3", "user", "What was the uncertainty?"),
29
+ ];
30
+
31
+ describe("public context compaction", () => {
32
+ it("uses size rather than message count and leaves the transcript untouched", async () => {
33
+ const summarize = vi
34
+ .fn()
35
+ .mockResolvedValue("Goal, sources, and uncertainty");
36
+ const original = structuredClone(history);
37
+ const result = await compactConversation({
38
+ messages: history,
39
+ budget: 3200,
40
+ overhead: "rules",
41
+ summarize,
42
+ });
43
+ expect(result.throughMessageId).toBe("a1");
44
+ expect(result.messages.map((m) => m.id)).toEqual(["u2", "a2", "u3"]);
45
+ expect(summarize.mock.calls[0]?.[0].messages).toEqual(history.slice(0, 2));
46
+ expect(history).toEqual(original);
47
+ const short = Array.from({ length: 100 }, (_, i) =>
48
+ message(String(i), i % 2 ? "assistant" : "user", "Hi"),
49
+ );
50
+ await compactConversation({
51
+ messages: short,
52
+ budget: 10000,
53
+ overhead: "",
54
+ summarize,
55
+ });
56
+ expect(summarize).toHaveBeenCalledOnce();
57
+ });
58
+
59
+ it("reuses a checkpoint only while all summarized messages are unchanged", () => {
60
+ const checkpoint = captureContextCheckpoint(history, "summary", "a1");
61
+ expect(contextRequest(history, checkpoint)).toEqual({
62
+ messages: history.slice(2),
63
+ contextSummary: "summary",
64
+ });
65
+ const edited = [
66
+ message("u1", "user", "Different goal"),
67
+ ...history.slice(1),
68
+ ];
69
+ expect(contextRequest(edited, checkpoint)).toEqual({ messages: edited });
70
+ expect(contextRequest(history.slice(0, 1), checkpoint)).toEqual({
71
+ messages: history.slice(0, 1),
72
+ });
73
+ expect(contextRequest([], checkpoint)).toEqual({ messages: [] });
74
+ });
75
+
76
+ it("retains tool pairs, recent questions, and system instructions between steps", async () => {
77
+ const messages: ModelMessage[] = [
78
+ { role: "system", content: "Keep original instructions" },
79
+ { role: "user", content: "Read this profile" },
80
+ {
81
+ role: "assistant",
82
+ content: [
83
+ {
84
+ type: "tool-call",
85
+ toolName: "readPage",
86
+ toolCallId: "read-1",
87
+ input: { path: "/one" },
88
+ },
89
+ ],
90
+ },
91
+ {
92
+ role: "tool",
93
+ content: [
94
+ {
95
+ type: "tool-result",
96
+ toolName: "readPage",
97
+ toolCallId: "read-1",
98
+ output: { type: "text", value: "evidence ".repeat(10000) },
99
+ },
100
+ ],
101
+ },
102
+ ];
103
+ const summarize = vi
104
+ .fn()
105
+ .mockResolvedValue(
106
+ "Source /one; preclinical evidence; uncertainty remains.",
107
+ );
108
+ const status = vi.fn();
109
+ const prepare = createStepCompactor({
110
+ budget: 3000,
111
+ overhead: "",
112
+ summarize,
113
+ onCompacting: status,
114
+ });
115
+ const result = await prepare({ messages });
116
+ expect(result.messages[0]).toEqual(messages[0]);
117
+ expect(result.messages[1]).toEqual(messages[1]);
118
+ expect(result.messages[2]).toEqual(messages[2]);
119
+ expect(JSON.stringify(result.messages[3])).toContain(
120
+ '"toolCallId":"read-1"',
121
+ );
122
+ expect(JSON.stringify(result.messages[3])).toContain(
123
+ "preclinical evidence",
124
+ );
125
+ expect(estimateContextTokens(result.messages)).toBeLessThan(3000);
126
+ await prepare({
127
+ messages: [...messages, { role: "assistant", content: "Answer" }],
128
+ });
129
+ expect(summarize).toHaveBeenCalledOnce();
130
+ expect(status.mock.calls).toEqual([[true], [false]]);
131
+ });
132
+
133
+ it("does not discard context when summarization fails", async () => {
134
+ const original = structuredClone(history);
135
+ await expect(
136
+ compactConversation({
137
+ messages: history,
138
+ budget: 3000,
139
+ overhead: "",
140
+ summarize: async () => {
141
+ throw new Error("provider unavailable");
142
+ },
143
+ }),
144
+ ).rejects.toThrow("provider unavailable");
145
+ expect(history).toEqual(original);
146
+ });
147
+
148
+ it("chunks oversized Unicode histories and includes earlier summaries", async () => {
149
+ const model = new MockLanguageModelV2({
150
+ doGenerate: async () => ({
151
+ content: [{ type: "text", text: "Retained facts" }],
152
+ finishReason: "stop",
153
+ usage: { inputTokens: 100, outputTokens: 10, totalTokens: 110 },
154
+ warnings: [],
155
+ }),
156
+ });
157
+ await createSummarizer(model, 4096)({ text: "研究".repeat(4000) });
158
+ expect(model.doGenerateCalls.length).toBeGreaterThan(1);
159
+ for (const call of model.doGenerateCalls)
160
+ expect(estimateContextTokens(call.prompt)).toBeLessThan(4096);
161
+ expect(JSON.stringify(model.doGenerateCalls[1]?.prompt)).toContain(
162
+ "Retained facts",
163
+ );
164
+ });
165
+
166
+ it("streams status/checkpoints and continues with a compact model prompt", async () => {
167
+ const model = new MockLanguageModelV2({
168
+ doGenerate: async () => ({
169
+ content: [
170
+ {
171
+ type: "text",
172
+ text: "Earlier goal and uncertain evidence: https://example.org/source",
173
+ },
174
+ ],
175
+ finishReason: "stop",
176
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
177
+ warnings: [],
178
+ }),
179
+ doStream: async () => ({
180
+ stream: simulateReadableStream({
181
+ chunks: [
182
+ { type: "text-start" as const, id: "t" },
183
+ {
184
+ type: "text-delta" as const,
185
+ id: "t",
186
+ delta: "Continuing the conversation.",
187
+ },
188
+ { type: "text-end" as const, id: "t" },
189
+ {
190
+ type: "finish" as const,
191
+ finishReason: "stop" as const,
192
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
193
+ },
194
+ ],
195
+ initialDelayInMs: null,
196
+ chunkDelayInMs: null,
197
+ }),
198
+ }),
199
+ });
200
+ const before = vi.fn();
201
+ const app = createBackendStack({
202
+ basePath: "/api",
203
+ plugins: {
204
+ aiChat: aiChatBackendPlugin({
205
+ model,
206
+ access: "public",
207
+ systemPrompt: "Original rules",
208
+ compaction: { contextWindowTokens: 4096 },
209
+ hooks: { onBeforeChat: before },
210
+ }),
211
+ },
212
+ adapter: (db) => createMemoryAdapter(db)({}),
213
+ });
214
+ const send = (body: unknown) =>
215
+ app.handler(
216
+ new Request("http://localhost/api/chat", {
217
+ method: "POST",
218
+ headers: { "content-type": "application/json" },
219
+ body: JSON.stringify(body),
220
+ }),
221
+ );
222
+ const attachment = await send({
223
+ messages: [
224
+ {
225
+ id: "file",
226
+ role: "user",
227
+ parts: [
228
+ {
229
+ type: "file",
230
+ mediaType: "image/png",
231
+ url: "https://example.org/image.png",
232
+ },
233
+ ],
234
+ },
235
+ ],
236
+ });
237
+ expect(attachment.status).toBe(400);
238
+ expect(await attachment.text()).toContain("text-only");
239
+ expect(model.doGenerateCalls).toHaveLength(0);
240
+ expect(model.doStreamCalls).toHaveLength(0);
241
+ const response = await send({ messages: history });
242
+ expect(response.status).toBe(200);
243
+ const text = await response.text();
244
+ expect(text).toContain('"data-context-status"');
245
+ expect(text).toContain('"throughMessageId":"a1"');
246
+ expect(text).toContain("Continuing the conversation.");
247
+ expect(before).toHaveBeenCalledOnce();
248
+ expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).toContain(
249
+ "Original rules",
250
+ );
251
+ expect(JSON.stringify(model.doStreamCalls[0]?.prompt)).not.toContain(
252
+ "research ".repeat(100),
253
+ );
254
+ const checkpointLine = text
255
+ .split("\n")
256
+ .find((line) => line.includes('"data-context-checkpoint"'))!;
257
+ const checkpoint = JSON.parse(checkpointLine.slice(6)).data;
258
+ const next = await send({
259
+ messages: history.slice(2),
260
+ contextSummary: checkpoint.summary,
261
+ });
262
+ await next.text();
263
+ expect(JSON.stringify(model.doStreamCalls.at(-1)?.prompt)).toContain(
264
+ "Earlier goal",
265
+ );
266
+ const oversized = await send({
267
+ messages: [message("large", "user", "research ".repeat(4000))],
268
+ });
269
+ expect(await oversized.text()).toContain("Shorten it and retry");
270
+ expect(model.doStreamCalls).toHaveLength(2);
271
+ });
272
+ });