@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.
- package/README.md +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/api/compaction.cjs +289 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +281 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/context-checkpoint.cjs +54 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/context-checkpoint.mjs +51 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/getters.cjs +2 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/getters.mjs +2 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +285 -89
- package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +285 -89
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +6 -3
- package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +6 -3
- package/dist/packages/stack/src/plugins/ai-chat/api/serializers.cjs +1 -0
- package/dist/packages/stack/src/plugins/ai-chat/api/serializers.mjs +1 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +62 -17
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +62 -17
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.cjs +22 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.mjs +22 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.cjs +30 -0
- package/dist/packages/stack/src/plugins/ai-chat/client/context-checkpoint.mjs +27 -0
- package/dist/packages/stack/src/plugins/ai-chat/db.cjs +8 -0
- package/dist/packages/stack/src/plugins/ai-chat/db.mjs +8 -0
- package/dist/packages/stack/src/plugins/ai-chat/history.cjs +47 -0
- package/dist/packages/stack/src/plugins/ai-chat/history.mjs +45 -0
- package/dist/packages/stack/src/plugins/ai-chat/schemas.cjs +2 -0
- package/dist/packages/stack/src/plugins/ai-chat/schemas.mjs +2 -0
- package/dist/packages/ui/src/components/markdown-content.cjs +9 -9
- package/dist/packages/ui/src/components/markdown-content.mjs +9 -9
- package/dist/plugins/ai-chat/api/index.d.cts +2 -2
- package/dist/plugins/ai-chat/api/index.d.mts +2 -2
- package/dist/plugins/ai-chat/api/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
- package/dist/plugins/ai-chat/client/index.d.cts +3 -3
- package/dist/plugins/ai-chat/client/index.d.mts +3 -3
- package/dist/plugins/ai-chat/client/index.d.ts +3 -3
- package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
- package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +2 -2
- package/dist/plugins/blog/client/index.d.mts +2 -2
- package/dist/plugins/blog/client/index.d.ts +2 -2
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/api/index.d.cts +1 -1
- package/dist/plugins/cms/api/index.d.mts +1 -1
- package/dist/plugins/cms/api/index.d.ts +1 -1
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/form-builder/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/form-builder/query-keys.d.cts +1 -1
- package/dist/plugins/form-builder/query-keys.d.mts +1 -1
- package/dist/plugins/form-builder/query-keys.d.ts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ui-builder/client/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/index.d.ts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
- package/dist/shared/{stack.D2y4gl03.d.mts → stack.94kN3pEK.d.mts} +5 -5
- package/dist/shared/{stack.DBH1Bjko.d.ts → stack.B2EYZyh_.d.ts} +16 -2
- package/dist/shared/{stack.rmWRtSjs.d.cts → stack.BTStHVTy.d.cts} +5 -5
- package/dist/shared/{stack.CZ1h_T4k.d.mts → stack.BggTHKlo.d.mts} +90 -90
- package/dist/shared/{stack.DzJgoNqJ.d.cts → stack.Cei2T2gc.d.cts} +2 -0
- package/dist/shared/{stack.DiYg7Jw1.d.cts → stack.CtrnoQgH.d.cts} +90 -90
- package/dist/shared/{stack.CHrtGKLz.d.mts → stack.DZRZAzcx.d.mts} +2 -0
- package/dist/shared/{stack.CnDMBj4z.d.ts → stack.DfrRYOIl.d.ts} +5 -5
- package/dist/shared/{stack.B7Mj74BQ.d.mts → stack.DivuAnNI.d.mts} +16 -2
- package/dist/shared/{stack.qMp2VzNw.d.ts → stack.byjorQi5.d.ts} +2 -0
- package/dist/shared/{stack.Dgt3MCtW.d.cts → stack.fF273jws.d.cts} +16 -2
- package/dist/shared/{stack.DQpyA3Cq.d.ts → stack.lPKwd_v0.d.ts} +90 -90
- package/package.json +1 -1
- package/src/__tests__/markdown-link-navigation.test.tsx +29 -0
- package/src/plugins/ai-chat/__tests__/authorization.test.ts +56 -30
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +25 -0
- package/src/plugins/ai-chat/__tests__/client-sweep.test.tsx +101 -0
- package/src/plugins/ai-chat/__tests__/compaction.test.ts +272 -0
- package/src/plugins/ai-chat/__tests__/persisted-compaction.test.ts +819 -0
- package/src/plugins/ai-chat/api/compaction.ts +377 -0
- package/src/plugins/ai-chat/api/context-checkpoint.ts +66 -0
- package/src/plugins/ai-chat/api/getters.ts +2 -0
- package/src/plugins/ai-chat/api/operations.ts +380 -93
- package/src/plugins/ai-chat/api/plugin.ts +15 -4
- package/src/plugins/ai-chat/api/serializers.ts +1 -0
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +97 -21
- package/src/plugins/ai-chat/client/components/chat-message.tsx +32 -2
- package/src/plugins/ai-chat/client/context-checkpoint.ts +47 -0
- package/src/plugins/ai-chat/client/localization/index.ts +2 -0
- package/src/plugins/ai-chat/db.ts +8 -0
- package/src/plugins/ai-chat/history.ts +68 -0
- package/src/plugins/ai-chat/schemas.ts +2 -0
- package/src/plugins/ai-chat/types.ts +2 -0
- package/dist/shared/{stack.lsbdKRj7.d.mts → stack.B8PG21zX.d.mts} +16 -16
- package/dist/shared/{stack.CGmh-Lpe.d.ts → stack.BuxUQqL8.d.ts} +12 -12
- package/dist/shared/{stack.DtZgEX84.d.cts → stack.CKc_Bais.d.cts} +6 -6
- package/dist/shared/{stack.DtZgEX84.d.mts → stack.CKc_Bais.d.mts} +6 -6
- package/dist/shared/{stack.DtZgEX84.d.ts → stack.CKc_Bais.d.ts} +6 -6
- package/dist/shared/{stack.zch70Loj.d.cts → stack.CX83YNuS.d.cts} +12 -12
- package/dist/shared/{stack.BMlTZnZp.d.ts → stack.CyalJ0j6.d.ts} +16 -16
- package/dist/shared/{stack.BV0uNV4f.d.cts → stack.D6Pg-ufX.d.cts} +16 -16
- 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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(() =>
|
|
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(
|
|
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).
|
|
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
|
|
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 =
|
|
2002
|
+
const secondFinish = completions[1];
|
|
1977
2003
|
|
|
1978
|
-
await firstFinish?.(
|
|
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?.(
|
|
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
|
+
});
|