@btst/stack 4.5.0 → 4.6.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 (99) hide show
  1. package/README.md +2 -2
  2. package/dist/components/markdown/index.d.cts +4 -2
  3. package/dist/components/markdown/index.d.mts +4 -2
  4. package/dist/components/markdown/index.d.ts +4 -2
  5. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.cjs +102 -15
  6. package/dist/packages/stack/src/plugins/ai-chat/api/compaction.mjs +102 -15
  7. package/dist/packages/stack/src/plugins/ai-chat/api/operations.cjs +8 -4
  8. package/dist/packages/stack/src/plugins/ai-chat/api/operations.mjs +8 -4
  9. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.cjs +5 -0
  10. package/dist/packages/stack/src/plugins/ai-chat/api/plugin.mjs +5 -0
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +3 -2
  12. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +3 -2
  13. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +0 -1
  14. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +0 -1
  15. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.cjs +7 -1
  16. package/dist/packages/stack/src/plugins/blog/client/components/pages/post-page.internal.mjs +7 -1
  17. package/dist/packages/stack/src/plugins/blog/client/components/shared/markdown-content.cjs +6 -1
  18. package/dist/packages/stack/src/plugins/blog/client/components/shared/markdown-content.mjs +6 -1
  19. package/dist/packages/ui/src/components/markdown-content.cjs +23 -3
  20. package/dist/packages/ui/src/components/markdown-content.mjs +25 -5
  21. package/dist/plugins/ai-chat/api/index.d.cts +2 -2
  22. package/dist/plugins/ai-chat/api/index.d.mts +2 -2
  23. package/dist/plugins/ai-chat/api/index.d.ts +2 -2
  24. package/dist/plugins/ai-chat/client/hooks/index.d.cts +2 -2
  25. package/dist/plugins/ai-chat/client/hooks/index.d.mts +2 -2
  26. package/dist/plugins/ai-chat/client/hooks/index.d.ts +2 -2
  27. package/dist/plugins/ai-chat/client/index.d.cts +1 -1
  28. package/dist/plugins/ai-chat/client/index.d.mts +1 -1
  29. package/dist/plugins/ai-chat/client/index.d.ts +1 -1
  30. package/dist/plugins/ai-chat/query-keys.d.cts +1 -1
  31. package/dist/plugins/ai-chat/query-keys.d.mts +1 -1
  32. package/dist/plugins/ai-chat/query-keys.d.ts +1 -1
  33. package/dist/plugins/blog/api/index.d.cts +2 -2
  34. package/dist/plugins/blog/api/index.d.mts +2 -2
  35. package/dist/plugins/blog/api/index.d.ts +2 -2
  36. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  37. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  38. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  39. package/dist/plugins/blog/client/index.d.cts +4 -2
  40. package/dist/plugins/blog/client/index.d.mts +4 -2
  41. package/dist/plugins/blog/client/index.d.ts +4 -2
  42. package/dist/plugins/blog/query-keys.d.cts +2 -2
  43. package/dist/plugins/blog/query-keys.d.mts +2 -2
  44. package/dist/plugins/blog/query-keys.d.ts +2 -2
  45. package/dist/plugins/cms/api/index.d.cts +1 -1
  46. package/dist/plugins/cms/api/index.d.mts +1 -1
  47. package/dist/plugins/cms/api/index.d.ts +1 -1
  48. package/dist/plugins/cms/query-keys.d.cts +1 -1
  49. package/dist/plugins/cms/query-keys.d.mts +1 -1
  50. package/dist/plugins/cms/query-keys.d.ts +1 -1
  51. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  52. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  53. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  54. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  55. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  56. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  57. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  58. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  59. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  60. package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
  61. package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
  62. package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
  63. package/dist/plugins/ui-builder/client/index.d.cts +1 -1
  64. package/dist/plugins/ui-builder/client/index.d.mts +1 -1
  65. package/dist/plugins/ui-builder/client/index.d.ts +1 -1
  66. package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
  67. package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
  68. package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
  69. package/dist/shared/{stack.BTStHVTy.d.cts → stack.BGzgFx3R.d.cts} +5 -5
  70. package/dist/shared/{stack.CtrnoQgH.d.cts → stack.BOZRaVpu.d.cts} +35 -35
  71. package/dist/shared/{stack.94kN3pEK.d.mts → stack.CjCSDfWy.d.mts} +5 -5
  72. package/dist/shared/{stack.B2EYZyh_.d.ts → stack.DDQTbADz.d.ts} +4 -2
  73. package/dist/shared/{stack.lPKwd_v0.d.ts → stack.DlBoNqYu.d.ts} +35 -35
  74. package/dist/shared/{stack.DfrRYOIl.d.ts → stack.Dpg_JjIo.d.ts} +5 -5
  75. package/dist/shared/{stack.BggTHKlo.d.mts → stack.Dvy7FmaA.d.mts} +35 -35
  76. package/dist/shared/{stack.fF273jws.d.cts → stack.IY-ngxuw.d.cts} +4 -2
  77. package/dist/shared/{stack.DivuAnNI.d.mts → stack.mU9iMF2j.d.mts} +4 -2
  78. package/package.json +1 -1
  79. package/src/plugins/ai-chat/__tests__/chat-input-authorization.test.tsx +42 -0
  80. package/src/plugins/ai-chat/__tests__/compaction.test.ts +79 -19
  81. package/src/plugins/ai-chat/__tests__/persisted-compaction.test.ts +129 -0
  82. package/src/plugins/ai-chat/api/compaction.ts +137 -13
  83. package/src/plugins/ai-chat/api/operations.ts +18 -4
  84. package/src/plugins/ai-chat/api/plugin.ts +10 -1
  85. package/src/plugins/ai-chat/client/components/chat-input.tsx +3 -3
  86. package/src/plugins/ai-chat/client/components/chat-interface.tsx +0 -1
  87. package/src/plugins/blog/__tests__/introduction-slot.test.tsx +86 -0
  88. package/src/plugins/blog/client/components/pages/post-page.internal.tsx +4 -1
  89. package/src/plugins/blog/client/components/shared/markdown-content.tsx +8 -1
  90. package/src/plugins/blog/client/overrides.ts +3 -0
  91. package/dist/shared/{stack.CyalJ0j6.d.ts → stack.BMlTZnZp.d.ts} +16 -16
  92. package/dist/shared/{stack.D6Pg-ufX.d.cts → stack.BV0uNV4f.d.cts} +16 -16
  93. package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
  94. package/dist/shared/{stack.CKc_Bais.d.cts → stack.Csi-4Mgz.d.cts} +7 -7
  95. package/dist/shared/{stack.CKc_Bais.d.mts → stack.Csi-4Mgz.d.mts} +7 -7
  96. package/dist/shared/{stack.CKc_Bais.d.ts → stack.Csi-4Mgz.d.ts} +7 -7
  97. package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
  98. package/dist/shared/{stack.B8PG21zX.d.mts → stack.lsbdKRj7.d.mts} +16 -16
  99. package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
@@ -2,7 +2,7 @@ import * as _btst_stack_plugins_client from '@btst/stack/plugins/client';
2
2
  import { createApiClient } from '@btst/stack/plugins/client';
3
3
  import * as zod from 'zod';
4
4
  import { z } from 'zod';
5
- import { c as createPostSchema, u as updatePostSchema } from './stack.CKc_Bais.mjs';
5
+ import { c as createPostSchema, u as updatePostSchema } from './stack.Csi-4Mgz.mjs';
6
6
  import { P as Post, T as Tag, S as SerializedPost, a as SerializedTag } from './stack.Dsatjh0B.mjs';
7
7
  import * as _btst_stack_plugins_api from '@btst/stack/plugins/api';
8
8
  import { DeepReadonly, OperationContext, OperationErrorContext } from '@btst/stack/plugins/api';
@@ -89,12 +89,6 @@ declare const NextPreviousPostsQuerySchema: z.ZodObject<{
89
89
  date: z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>;
90
90
  }, z.core.$strip>;
91
91
  declare const CreatePostOperationInputSchema: z.ZodObject<{
92
- slug: z.ZodOptional<z.ZodString>;
93
- title: z.ZodString;
94
- content: z.ZodString;
95
- excerpt: z.ZodString;
96
- image: z.ZodOptional<z.ZodString>;
97
- published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
98
92
  tags: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
99
93
  name: z.ZodString;
100
94
  }, z.core.$strip>, z.ZodObject<{
@@ -102,6 +96,12 @@ declare const CreatePostOperationInputSchema: z.ZodObject<{
102
96
  name: z.ZodString;
103
97
  slug: z.ZodString;
104
98
  }, z.core.$strip>]>>>>;
99
+ slug: z.ZodOptional<z.ZodString>;
100
+ title: z.ZodString;
101
+ content: z.ZodString;
102
+ excerpt: z.ZodString;
103
+ image: z.ZodOptional<z.ZodString>;
104
+ published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
105
105
  publishedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
106
106
  createdAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
107
107
  updatedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
@@ -109,12 +109,6 @@ declare const CreatePostOperationInputSchema: z.ZodObject<{
109
109
  declare const UpdatePostOperationInputSchema: z.ZodObject<{
110
110
  id: z.ZodString;
111
111
  data: z.ZodObject<{
112
- slug: z.ZodString;
113
- title: z.ZodString;
114
- content: z.ZodString;
115
- excerpt: z.ZodString;
116
- image: z.ZodOptional<z.ZodString>;
117
- published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
118
112
  tags: z.ZodDefault<z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodObject<{
119
113
  name: z.ZodString;
120
114
  }, z.core.$strip>, z.ZodObject<{
@@ -122,6 +116,12 @@ declare const UpdatePostOperationInputSchema: z.ZodObject<{
122
116
  name: z.ZodString;
123
117
  slug: z.ZodString;
124
118
  }, z.core.$strip>]>>>>;
119
+ slug: z.ZodString;
120
+ title: z.ZodString;
121
+ content: z.ZodString;
122
+ excerpt: z.ZodString;
123
+ image: z.ZodOptional<z.ZodString>;
124
+ published: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
125
125
  publishedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
126
126
  createdAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
127
127
  updatedAt: z.ZodOptional<z.ZodPipe<z.ZodCoercedDate<unknown>, z.ZodTransform<string, Date>>>;
@@ -386,9 +386,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
386
386
  title: string;
387
387
  content: string;
388
388
  excerpt: string;
389
- slug?: string | undefined;
390
- image?: string | undefined;
391
- published?: boolean | undefined;
392
389
  tags?: ({
393
390
  name: string;
394
391
  } | {
@@ -396,6 +393,9 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
396
393
  name: string;
397
394
  slug: string;
398
395
  })[] | undefined;
396
+ slug?: string | undefined;
397
+ image?: string | undefined;
398
+ published?: boolean | undefined;
399
399
  publishedAt?: unknown;
400
400
  createdAt?: unknown;
401
401
  updatedAt?: unknown;
@@ -403,9 +403,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
403
403
  title: string;
404
404
  content: string;
405
405
  excerpt: string;
406
- slug?: string | undefined;
407
- image?: string | undefined;
408
- published?: boolean | undefined;
409
406
  tags?: ({
410
407
  name: string;
411
408
  } | {
@@ -413,6 +410,9 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
413
410
  name: string;
414
411
  slug: string;
415
412
  })[] | undefined;
413
+ slug?: string | undefined;
414
+ image?: string | undefined;
415
+ published?: boolean | undefined;
416
416
  publishedAt?: unknown;
417
417
  createdAt?: unknown;
418
418
  updatedAt?: unknown;
@@ -448,8 +448,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
448
448
  title: string;
449
449
  content: string;
450
450
  excerpt: string;
451
- image?: string | undefined;
452
- published?: boolean | undefined;
453
451
  tags?: ({
454
452
  name: string;
455
453
  } | {
@@ -457,6 +455,8 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
457
455
  name: string;
458
456
  slug: string;
459
457
  })[] | undefined;
458
+ image?: string | undefined;
459
+ published?: boolean | undefined;
460
460
  publishedAt?: unknown;
461
461
  createdAt?: unknown;
462
462
  updatedAt?: unknown;
@@ -465,8 +465,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
465
465
  title: string;
466
466
  content: string;
467
467
  excerpt: string;
468
- image?: string | undefined;
469
- published?: boolean | undefined;
470
468
  tags?: ({
471
469
  name: string;
472
470
  } | {
@@ -474,6 +472,8 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
474
472
  name: string;
475
473
  slug: string;
476
474
  })[] | undefined;
475
+ image?: string | undefined;
476
+ published?: boolean | undefined;
477
477
  publishedAt?: unknown;
478
478
  createdAt?: unknown;
479
479
  updatedAt?: unknown;
@@ -627,12 +627,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
627
627
  total: number;
628
628
  }, false>;
629
629
  readonly createPost: _btst_stack_plugins_api.Operation<zod.ZodObject<{
630
- slug: zod.ZodOptional<zod.ZodString>;
631
- title: zod.ZodString;
632
- content: zod.ZodString;
633
- excerpt: zod.ZodString;
634
- image: zod.ZodOptional<zod.ZodString>;
635
- published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
636
630
  tags: zod.ZodDefault<zod.ZodOptional<zod.ZodArray<zod.ZodUnion<readonly [zod.ZodObject<{
637
631
  name: zod.ZodString;
638
632
  }, zod_v4_core.$strip>, zod.ZodObject<{
@@ -640,6 +634,12 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
640
634
  name: zod.ZodString;
641
635
  slug: zod.ZodString;
642
636
  }, zod_v4_core.$strip>]>>>>;
637
+ slug: zod.ZodOptional<zod.ZodString>;
638
+ title: zod.ZodString;
639
+ content: zod.ZodString;
640
+ excerpt: zod.ZodString;
641
+ image: zod.ZodOptional<zod.ZodString>;
642
+ published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
643
643
  publishedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
644
644
  createdAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
645
645
  updatedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
@@ -673,12 +673,6 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
673
673
  readonly updatePost: _btst_stack_plugins_api.Operation<zod.ZodObject<{
674
674
  id: zod.ZodString;
675
675
  data: zod.ZodObject<{
676
- slug: zod.ZodString;
677
- title: zod.ZodString;
678
- content: zod.ZodString;
679
- excerpt: zod.ZodString;
680
- image: zod.ZodOptional<zod.ZodString>;
681
- published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
682
676
  tags: zod.ZodDefault<zod.ZodOptional<zod.ZodArray<zod.ZodUnion<readonly [zod.ZodObject<{
683
677
  name: zod.ZodString;
684
678
  }, zod_v4_core.$strip>, zod.ZodObject<{
@@ -686,6 +680,12 @@ declare const blogBackendPlugin: (options?: BlogBackendOptions) => _btst_stack_p
686
680
  name: zod.ZodString;
687
681
  slug: zod.ZodString;
688
682
  }, zod_v4_core.$strip>]>>>>;
683
+ slug: zod.ZodString;
684
+ title: zod.ZodString;
685
+ content: zod.ZodString;
686
+ excerpt: zod.ZodString;
687
+ image: zod.ZodOptional<zod.ZodString>;
688
+ published: zod.ZodDefault<zod.ZodOptional<zod.ZodBoolean>>;
689
689
  publishedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
690
690
  createdAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
691
691
  updatedAt: zod.ZodOptional<zod.ZodPipe<zod.ZodCoercedDate<unknown>, zod.ZodTransform<string, Date>>>;
@@ -57,10 +57,12 @@ interface AiChatPageContentConfig {
57
57
  description?: string;
58
58
  }
59
59
 
60
- /** Opt-in context compaction for text-only conversations. */
60
+ /** Opt-in context compaction for conversations containing text and images. */
61
61
  interface AiChatCompactionConfig {
62
62
  /** Actual context window. Configure the model's output limit at or below 20% of this. */
63
63
  contextWindowTokens: number;
64
+ /** Estimated tokens per image, excluding encoded bytes. Default: 4096. Increase for models with higher image token costs. */
65
+ imageTokenEstimate?: number;
64
66
  }
65
67
 
66
68
  type AiChatAccess = "authorized" | "public";
@@ -230,7 +232,7 @@ interface AiChatBackendConfig {
230
232
  */
231
233
  access?: AiChatAccess;
232
234
  systemPrompt?: string;
233
- /** Automatically summarize older context in text chats; off by default. */
235
+ /** Automatically summarize older text and image context; off by default. */
234
236
  compaction?: AiChatCompactionConfig;
235
237
  tools?: Record<string, Tool>;
236
238
  /** Enable the standard readPage server tool for full page content. Reserves the readPage tool name. */
@@ -57,10 +57,12 @@ interface AiChatPageContentConfig {
57
57
  description?: string;
58
58
  }
59
59
 
60
- /** Opt-in context compaction for text-only conversations. */
60
+ /** Opt-in context compaction for conversations containing text and images. */
61
61
  interface AiChatCompactionConfig {
62
62
  /** Actual context window. Configure the model's output limit at or below 20% of this. */
63
63
  contextWindowTokens: number;
64
+ /** Estimated tokens per image, excluding encoded bytes. Default: 4096. Increase for models with higher image token costs. */
65
+ imageTokenEstimate?: number;
64
66
  }
65
67
 
66
68
  type AiChatAccess = "authorized" | "public";
@@ -230,7 +232,7 @@ interface AiChatBackendConfig {
230
232
  */
231
233
  access?: AiChatAccess;
232
234
  systemPrompt?: string;
233
- /** Automatically summarize older context in text chats; off by default. */
235
+ /** Automatically summarize older text and image context; off by default. */
234
236
  compaction?: AiChatCompactionConfig;
235
237
  tools?: Record<string, Tool>;
236
238
  /** Enable the standard readPage server tool for full page content. Reserves the readPage tool name. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@btst/stack",
3
- "version": "4.5.0",
3
+ "version": "4.6.0",
4
4
  "description": "A composable, plugin-based library for building full-stack applications.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,48 @@ afterEach(async () => {
37
37
  });
38
38
 
39
39
  describe("AI Chat attachment authorization", () => {
40
+ it("uses the configured upload transport in public mode without requiring a saved-chat identity", async () => {
41
+ const uploadFile = vi.fn(async () => "https://files/public.png");
42
+ const onFilesAttached = vi.fn();
43
+ await act(async () => {
44
+ root.render(
45
+ <StackProvider
46
+ stack={createTestClientStack({
47
+ aiChat: aiChatClientPlugin({ mode: "public" }),
48
+ })}
49
+ overrides={{ aiChat: { uploadFile, allowedFileTypes: ["image"] } }}
50
+ >
51
+ <ChatInput
52
+ handleInputChange={() => {}}
53
+ handleSubmit={() => {}}
54
+ isLoading={false}
55
+ attachedFiles={[]}
56
+ onFilesAttached={onFilesAttached}
57
+ />
58
+ </StackProvider>,
59
+ );
60
+ });
61
+ const input =
62
+ container.querySelector<HTMLInputElement>('input[type="file"]');
63
+ expect(input).toBeTruthy();
64
+ Object.defineProperty(input!, "files", {
65
+ configurable: true,
66
+ value: [new File(["image"], "public.png", { type: "image/png" })],
67
+ });
68
+ await act(async () => {
69
+ input!.dispatchEvent(new Event("change", { bubbles: true }));
70
+ await Promise.resolve();
71
+ });
72
+ expect(uploadFile).toHaveBeenCalledOnce();
73
+ expect(onFilesAttached).toHaveBeenCalledWith([
74
+ {
75
+ url: "https://files/public.png",
76
+ mediaType: "image/png",
77
+ filename: "public.png",
78
+ },
79
+ ]);
80
+ });
81
+
40
82
  it("checks the selected MIME type before calling the upload transport", async () => {
41
83
  const observedMediaTypes: string[][] = [];
42
84
  const authorization = defineAuthorization({
@@ -56,6 +56,85 @@ describe("public context compaction", () => {
56
56
  expect(summarize).toHaveBeenCalledOnce();
57
57
  });
58
58
 
59
+ it("counts image content separately from inline bytes and keeps tool output as text", () => {
60
+ const inline = {
61
+ role: "user",
62
+ parts: [
63
+ {
64
+ type: "file",
65
+ mediaType: "image/png",
66
+ url: "data:image/png;base64," + "A".repeat(100_000),
67
+ },
68
+ ],
69
+ };
70
+ const remote = {
71
+ role: "user",
72
+ parts: [
73
+ {
74
+ type: "file",
75
+ mediaType: "image/png",
76
+ url: "https://example.org/image.png",
77
+ },
78
+ ],
79
+ };
80
+ expect(estimateContextTokens([inline])).toBe(
81
+ estimateContextTokens([remote]),
82
+ );
83
+ expect(estimateContextTokens([inline])).toBeGreaterThanOrEqual(4096);
84
+ const toolOutput = {
85
+ role: "tool",
86
+ content: [
87
+ { type: "tool-result", output: { type: "json", value: inline } },
88
+ ],
89
+ };
90
+ expect(estimateContextTokens([toolOutput])).toBeGreaterThan(30_000);
91
+ });
92
+
93
+ it("sends older images as vision input when summarizing instead of serialized bytes", async () => {
94
+ const png =
95
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jr1sAAAAASUVORK5CYII=";
96
+ const original = [
97
+ message("u1", "user", "Remember the number in this image"),
98
+ ];
99
+ original[0]!.parts.push({ type: "file", mediaType: "image/png", url: png });
100
+ const model = new MockLanguageModelV2({
101
+ doGenerate: async ({ prompt }) => ({
102
+ content: [
103
+ {
104
+ type: "text",
105
+ text: JSON.stringify(prompt).includes('"type":"file"')
106
+ ? "The image shows verification number 917."
107
+ : "The user wants to remember the image number.",
108
+ },
109
+ ],
110
+ finishReason: "stop",
111
+ usage: { inputTokens: 100, outputTokens: 20, totalTokens: 120 },
112
+ warnings: [],
113
+ }),
114
+ });
115
+ const before = structuredClone(original);
116
+ const summary = await createSummarizer(
117
+ model,
118
+ 16_384,
119
+ )({ messages: original });
120
+ expect(summary).toContain("917");
121
+ expect(
122
+ model.doGenerateCalls.some((call) =>
123
+ call.prompt.some(
124
+ (message) =>
125
+ message.role === "user" &&
126
+ message.content.some(
127
+ (part) => part.type === "file" && part.mediaType === "image/png",
128
+ ),
129
+ ),
130
+ ),
131
+ ).toBe(true);
132
+ expect(JSON.stringify(model.doGenerateCalls)).not.toContain(
133
+ "data:image/png;base64",
134
+ );
135
+ expect(original).toEqual(before);
136
+ });
137
+
59
138
  it("reuses a checkpoint only while all summarized messages are unchanged", () => {
60
139
  const checkpoint = captureContextCheckpoint(history, "summary", "a1");
61
140
  expect(contextRequest(history, checkpoint)).toEqual({
@@ -219,25 +298,6 @@ describe("public context compaction", () => {
219
298
  body: JSON.stringify(body),
220
299
  }),
221
300
  );
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
301
  const response = await send({ messages: history });
242
302
  expect(response.status).toBe(200);
243
303
  const text = await response.text();
@@ -77,6 +77,7 @@ function backend(
77
77
  return [
78
78
  aiChat.stream.start.when(owns),
79
79
  aiChat.message.send.when(owns),
80
+ aiChat.attachment.send.when(owns),
80
81
  aiChat.message.retry.when(owns),
81
82
  aiChat.message.edit.when(owns),
82
83
  aiChat.tool.activate.when(owns),
@@ -162,6 +163,134 @@ async function seed(app: ReturnType<typeof backend>["app"]) {
162
163
  }
163
164
 
164
165
  describe("persisted context compaction", () => {
166
+ it.each([undefined, 1, "application/pdf"])(
167
+ "rejects unsupported attachment media type %s as a client error",
168
+ async (mediaType) => {
169
+ const { app, languageModel } = backend();
170
+ const response = await app.handler(
171
+ new Request("http://localhost/api/chat", {
172
+ method: "POST",
173
+ headers: { "content-type": "application/json" },
174
+ body: JSON.stringify({
175
+ messages: [
176
+ {
177
+ id: "bad-file",
178
+ role: "user",
179
+ parts: [
180
+ { type: "file", mediaType, url: "https://example.org/file" },
181
+ ],
182
+ },
183
+ ],
184
+ }),
185
+ }),
186
+ );
187
+ expect(response.status).toBe(400);
188
+ expect(languageModel.doGenerateCalls).toHaveLength(0);
189
+ expect(languageModel.doStreamCalls).toHaveLength(0);
190
+ },
191
+ );
192
+
193
+ it.each(["public", "authorized"] as const)(
194
+ "keeps images usable across compaction and resume in %s chats",
195
+ async (access) => {
196
+ const { app, languageModel, errors } = backend(model(), {
197
+ access,
198
+ compaction: { contextWindowTokens: 16_384 },
199
+ });
200
+ const png =
201
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jr1sAAAAASUVORK5CYII=";
202
+ const first = text(
203
+ "image-1",
204
+ "user",
205
+ "Remember this image. " + "research ".repeat(2500),
206
+ );
207
+ first.parts.push({ type: "file", mediaType: "image/png", url: png });
208
+ const request = (body: unknown) =>
209
+ app.handler(
210
+ new Request("http://localhost/api/chat", {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: JSON.stringify(body),
214
+ }),
215
+ );
216
+ const firstResponse = await request({ messages: [first] });
217
+ expect(firstResponse.status).toBe(200);
218
+ expect(await firstResponse.text()).toContain("ORCHID-917");
219
+ expect(languageModel.doGenerateCalls).toHaveLength(0);
220
+ const id = firstResponse.headers.get("X-Conversation-Id");
221
+ const previous =
222
+ access === "authorized"
223
+ ? await resume(app, id!)
224
+ : [first, text("a1", "assistant", "ORCHID-917")];
225
+ const second = text(
226
+ "image-2",
227
+ "user",
228
+ "Compare the images. " + "context ".repeat(750),
229
+ );
230
+ second.parts.push({ type: "file", mediaType: "image/png", url: png });
231
+ const messages = [...previous, second];
232
+ const response = await request({
233
+ messages,
234
+ ...(id ? { conversationId: id } : {}),
235
+ });
236
+ const stream = await response.text();
237
+ expect(response.status).toBe(200);
238
+ expect(stream).toContain("ORCHID-917");
239
+ expect(stream).toContain("data-context-status");
240
+ const visionCalls = languageModel.doGenerateCalls.filter((call) =>
241
+ call.prompt.some(
242
+ (message) =>
243
+ message.role === "user" &&
244
+ message.content.some(
245
+ (part) => part.type === "file" && part.mediaType === "image/png",
246
+ ),
247
+ ),
248
+ );
249
+ expect(visionCalls).toHaveLength(1);
250
+ const liveImages = languageModel.doStreamCalls
251
+ .at(-1)!
252
+ .prompt.flatMap((message) =>
253
+ message.role === "user"
254
+ ? message.content.filter((part) => part.type === "file")
255
+ : [],
256
+ );
257
+ expect(liveImages).toHaveLength(1);
258
+ const summaries = languageModel.doGenerateCalls.length;
259
+ let continuation: Record<string, unknown>;
260
+ if (access === "authorized") {
261
+ const saved = await resume(app, id!);
262
+ expect(saved[0]!.parts).toEqual(first.parts);
263
+ expect(saved[2]!.parts).toEqual(second.parts);
264
+ continuation = {
265
+ conversationId: id,
266
+ messages: [...saved, text("u3", "user", "Repeat it")],
267
+ };
268
+ } else {
269
+ expect(messages[0]!.parts).toEqual(first.parts);
270
+ const line = stream
271
+ .split("\n")
272
+ .find((line) => line.includes('"data-context-checkpoint"'))!;
273
+ const checkpoint = JSON.parse(line.slice(6)).data;
274
+ const cut =
275
+ messages.findIndex(
276
+ (message) => message.id === checkpoint.throughMessageId,
277
+ ) + 1;
278
+ continuation = {
279
+ contextSummary: checkpoint.summary,
280
+ messages: [
281
+ ...messages.slice(cut),
282
+ text("a2", "assistant", "ORCHID-917"),
283
+ text("u3", "user", "Repeat it"),
284
+ ],
285
+ };
286
+ }
287
+ const continued = await request(continuation);
288
+ expect(await continued.text()).toContain("ORCHID-917");
289
+ expect(languageModel.doGenerateCalls).toHaveLength(summaries);
290
+ expect(errors).not.toHaveBeenCalled();
291
+ },
292
+ );
293
+
165
294
  it("persists successful responses without content and calls the completion hook", async () => {
166
295
  const languageModel = model();
167
296
  languageModel.doStream = async () => ({