@vellumai/assistant 0.8.11 → 0.8.12-staging.1

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 (219) hide show
  1. package/ARCHITECTURE.md +15 -17
  2. package/README.md +0 -6
  3. package/bun.lock +6 -122
  4. package/node_modules/@vellumai/gateway-client/bun.lock +1 -0
  5. package/node_modules/@vellumai/gateway-client/package.json +3 -1
  6. package/node_modules/@vellumai/gateway-client/src/__tests__/gateway-client.test.ts +1 -1
  7. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +87 -0
  8. package/node_modules/@vellumai/gateway-client/src/index.ts +3 -5
  9. package/openapi.yaml +126 -4
  10. package/package.json +1 -3
  11. package/src/__tests__/adaptive-thinking-repair.test.ts +185 -0
  12. package/src/__tests__/agent-loop-compaction-events.test.ts +7 -6
  13. package/src/__tests__/anthropic-provider.test.ts +129 -0
  14. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -1
  15. package/src/__tests__/btw-routes.test.ts +7 -34
  16. package/src/__tests__/checker.test.ts +6 -12
  17. package/src/__tests__/config-loader-backfill.test.ts +4 -2
  18. package/src/__tests__/config-loader-quarantine-notice.test.ts +167 -0
  19. package/src/__tests__/config-watcher.test.ts +2 -2
  20. package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +1 -1
  21. package/src/__tests__/conversation-error.test.ts +2 -6
  22. package/src/__tests__/conversation-history-web-search.test.ts +8 -0
  23. package/src/__tests__/conversation-title-service.test.ts +2 -1
  24. package/src/__tests__/credential-security-invariants.test.ts +1 -1
  25. package/src/__tests__/disk-pressure-tools.test.ts +1 -1
  26. package/src/__tests__/exploration-drift-hook.test.ts +692 -0
  27. package/src/__tests__/filing-service.test.ts +8 -3
  28. package/src/__tests__/guardian-action-store.test.ts +0 -167
  29. package/src/__tests__/handlers-skills-memory-v2-reseed.test.ts +1 -1
  30. package/src/__tests__/heartbeat-disk-pressure.test.ts +4 -1
  31. package/src/__tests__/heartbeat-service.test.ts +5 -2
  32. package/src/__tests__/identity-intro-cache.test.ts +12 -5
  33. package/src/__tests__/identity-routes.test.ts +16 -57
  34. package/src/__tests__/injector-chain.test.ts +8 -3
  35. package/src/__tests__/injector-config-quarantine-notice.test.ts +115 -0
  36. package/src/__tests__/llm-usage-store.test.ts +11 -0
  37. package/src/__tests__/memory-v2-static-injector.test.ts +22 -0
  38. package/src/__tests__/model-intents.test.ts +1 -1
  39. package/src/__tests__/oauth-cli.test.ts +19 -8
  40. package/src/__tests__/openai-provider.test.ts +34 -0
  41. package/src/__tests__/prechat-onboarding-contract.test.ts +0 -1
  42. package/src/__tests__/recurrence-engine.test.ts +45 -0
  43. package/src/__tests__/schedule-routes.test.ts +34 -0
  44. package/src/__tests__/scheduler-disk-pressure.test.ts +1 -1
  45. package/src/__tests__/script-proxy-conversation-manager.test.ts +10 -5
  46. package/src/__tests__/skill-tool-factory.test.ts +49 -0
  47. package/src/__tests__/subagent-role-registry.test.ts +24 -1
  48. package/src/__tests__/subagent-tools.test.ts +1 -0
  49. package/src/__tests__/system-prompt.test.ts +109 -11
  50. package/src/__tests__/tool-error-hook.test.ts +1 -0
  51. package/src/__tests__/tool-result-spool.test.ts +337 -0
  52. package/src/__tests__/tool-result-truncate-hook.test.ts +1 -0
  53. package/src/__tests__/validate-input.test.ts +95 -1
  54. package/src/__tests__/workspace-migration-098-remove-stale-updates-bulletin-file.test.ts +65 -0
  55. package/src/__tests__/workspace-migration-099-disable-cache-one-shot-callsites.test.ts +139 -0
  56. package/src/__tests__/workspace-release-notes-feature-flag-guard.test.ts +45 -95
  57. package/src/agent/loop.ts +81 -24
  58. package/src/api/events/usage-progress.ts +28 -0
  59. package/src/api/index.ts +6 -0
  60. package/src/background-wake/wake-intent-hooks.test.ts +2 -0
  61. package/src/bundler/app-bundler.ts +25 -42
  62. package/src/calls/call-controller.ts +1 -1
  63. package/src/cli/commands/plugins.ts +248 -15
  64. package/src/cli/lib/__tests__/inspect-plugin.test.ts +318 -0
  65. package/src/cli/lib/__tests__/install-from-github.test.ts +16 -9
  66. package/src/cli/lib/__tests__/plugin-artifact.test.ts +183 -0
  67. package/src/cli/lib/__tests__/plugin-details.test.ts +158 -0
  68. package/src/cli/lib/__tests__/plugin-fingerprint.test.ts +245 -0
  69. package/src/cli/lib/__tests__/upgrade-plugin.test.ts +301 -0
  70. package/src/cli/lib/inspect-plugin.ts +252 -0
  71. package/src/cli/lib/install-from-github.ts +214 -21
  72. package/src/cli/lib/list-installed-plugins.ts +17 -6
  73. package/src/cli/lib/plugin-artifact.ts +103 -0
  74. package/src/cli/lib/plugin-details.ts +18 -1
  75. package/src/cli/lib/plugin-fingerprint.ts +197 -0
  76. package/src/cli/lib/upgrade-plugin.ts +219 -0
  77. package/src/config/bundled-skills/subagent/SKILL.md +2 -0
  78. package/src/config/bundled-skills/subagent/TOOLS.json +8 -2
  79. package/src/config/call-site-defaults.ts +13 -2
  80. package/src/config/feature-flag-registry.json +8 -16
  81. package/src/config/loader.ts +52 -59
  82. package/src/config/schema.ts +0 -2
  83. package/src/config/schemas/__tests__/memory-v2.test.ts +1 -0
  84. package/src/config/schemas/__tests__/memory-v3.test.ts +10 -0
  85. package/src/config/schemas/llm.ts +10 -0
  86. package/src/config/schemas/memory-v2.ts +13 -0
  87. package/src/config/schemas/memory-v3.ts +92 -0
  88. package/src/context/post-turn-tool-result-truncation.ts +32 -18
  89. package/src/context/tool-result-spool.ts +104 -0
  90. package/src/credential-execution/feature-gates.ts +0 -1
  91. package/src/daemon/conversation-agent-loop-handlers.ts +41 -16
  92. package/src/daemon/conversation-error.ts +6 -15
  93. package/src/daemon/conversation.ts +9 -0
  94. package/src/daemon/disk-pressure-policy.ts +0 -1
  95. package/src/daemon/lifecycle.ts +1 -20
  96. package/src/daemon/message-types/conversations.ts +2 -15
  97. package/src/daemon/trust-context.ts +1 -1
  98. package/src/heartbeat/__tests__/heartbeat-service.test.ts +1 -1
  99. package/src/home/__tests__/home-content-refresh.test.ts +114 -0
  100. package/src/home/__tests__/suggested-prompts.test.ts +86 -5
  101. package/src/home/home-content-refresh.ts +43 -31
  102. package/src/home/home-greeting-cache.ts +8 -1
  103. package/src/home/home-greeting.ts +13 -9
  104. package/src/home/suggested-prompts.ts +77 -24
  105. package/src/ipc/routes/trust-rules.test.ts +66 -72
  106. package/src/media/image-credentials.ts +2 -2
  107. package/src/memory/__tests__/compaction-log-store-clickhouse.test.ts +432 -0
  108. package/src/memory/{compaction-log-writer-clickhouse.ts → compaction-log-store-clickhouse.ts} +264 -55
  109. package/src/memory/conversation-attention-store.ts +1 -0
  110. package/src/memory/conversation-bootstrap.ts +18 -9
  111. package/src/memory/conversation-crud.ts +12 -2
  112. package/src/memory/conversation-title-service.ts +53 -9
  113. package/src/memory/delivery-channels.ts +0 -69
  114. package/src/memory/graph/extraction-job.ts +0 -15
  115. package/src/memory/guardian-action-store.ts +1 -376
  116. package/src/memory/llm-usage-store.ts +5 -1
  117. package/src/memory/migrations/181-rename-thread-starters-checkpoints.ts +2 -2
  118. package/src/memory/v2/__tests__/consolidation-job.test.ts +183 -2
  119. package/src/memory/v2/__tests__/injection.test.ts +70 -0
  120. package/src/memory/v2/__tests__/static-context.test.ts +12 -0
  121. package/src/memory/v2/consolidation-job.ts +93 -9
  122. package/src/memory/v2/injection.ts +53 -0
  123. package/src/memory/v2/prompts/consolidation.ts +1 -0
  124. package/src/memory/v2/static-context.ts +13 -1
  125. package/src/memory/v2/sweep-job.ts +1 -1
  126. package/src/memory/v2/types.ts +5 -0
  127. package/src/plugin-api/types.ts +7 -0
  128. package/src/plugins/defaults/exploration-drift/hooks/post-tool-use.ts +300 -0
  129. package/src/plugins/defaults/exploration-drift/package.json +15 -0
  130. package/src/plugins/defaults/index.ts +25 -0
  131. package/src/plugins/defaults/memory-retrieval/injectors.ts +132 -4
  132. package/src/plugins/defaults/memory-v3-shadow/__tests__/card.test.ts +92 -0
  133. package/src/plugins/defaults/memory-v3-shadow/__tests__/carry-integration.test.ts +2 -1
  134. package/src/plugins/defaults/memory-v3-shadow/__tests__/fresh-set.test.ts +52 -0
  135. package/src/plugins/defaults/memory-v3-shadow/__tests__/injection.test.ts +1 -0
  136. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +2 -1
  137. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +136 -5
  138. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +17 -0
  139. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +6 -0
  140. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +5 -1
  141. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +68 -4
  142. package/src/plugins/defaults/memory-v3-shadow/card.ts +49 -5
  143. package/src/plugins/defaults/memory-v3-shadow/fresh-set.ts +59 -0
  144. package/src/plugins/defaults/memory-v3-shadow/injector.ts +4 -2
  145. package/src/plugins/defaults/memory-v3-shadow/learned-edges.test.ts +169 -0
  146. package/src/plugins/defaults/memory-v3-shadow/learned-edges.ts +178 -0
  147. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +115 -26
  148. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +13 -9
  149. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +144 -22
  150. package/src/plugins/defaults/memory-v3-shadow/types.ts +24 -6
  151. package/src/plugins/defaults/title-generate/hooks/stop.ts +13 -0
  152. package/src/plugins/defaults/title-generate/hooks/user-prompt-submit.ts +16 -0
  153. package/src/prompts/cache-boundary.ts +17 -0
  154. package/src/prompts/sections.ts +50 -17
  155. package/src/prompts/system-prompt.ts +12 -4
  156. package/src/prompts/templates/system-sections.ts +22 -0
  157. package/src/providers/anthropic/client.ts +74 -28
  158. package/src/providers/gemini/client.ts +5 -1
  159. package/src/providers/minimax/client.ts +9 -0
  160. package/src/providers/model-intents.ts +2 -2
  161. package/src/providers/openai/chat-completions-provider.ts +2 -1
  162. package/src/providers/openai/responses-provider.ts +5 -1
  163. package/src/providers/retry.ts +8 -0
  164. package/src/providers/types.ts +11 -0
  165. package/src/runtime/AGENTS.md +6 -0
  166. package/src/runtime/__tests__/agent-wake.test.ts +2 -2
  167. package/src/runtime/agent-wake.ts +5 -5
  168. package/src/runtime/background-job-runner.ts +2 -2
  169. package/src/runtime/migrations/__tests__/vbundle-legacy-user-md.test.ts +150 -3
  170. package/src/runtime/migrations/vbundle-import-analyzer.ts +29 -6
  171. package/src/runtime/migrations/vbundle-import-policy.ts +23 -0
  172. package/src/runtime/migrations/vbundle-importer.ts +9 -4
  173. package/src/runtime/migrations/vbundle-streaming-importer.ts +8 -3
  174. package/src/runtime/pre-first-message-gate.ts +1 -1
  175. package/src/runtime/routes/__tests__/conversation-compaction-routes.test.ts +241 -0
  176. package/src/runtime/routes/__tests__/gateway-log-routes.test.ts +97 -185
  177. package/src/runtime/routes/__tests__/home-feed-routes.test.ts +17 -0
  178. package/src/runtime/routes/__tests__/plugins-routes.test.ts +1 -0
  179. package/src/runtime/routes/__tests__/task-routes.test.ts +3 -3
  180. package/src/runtime/routes/btw-routes.ts +0 -14
  181. package/src/runtime/routes/conversation-compaction-routes.ts +86 -19
  182. package/src/runtime/routes/conversation-list-routes.ts +77 -5
  183. package/src/runtime/routes/conversation-management-routes.ts +54 -0
  184. package/src/runtime/routes/gateway-log-routes.ts +14 -64
  185. package/src/runtime/routes/home-feed-routes.ts +10 -0
  186. package/src/runtime/routes/identity-intro-cache.ts +1 -1
  187. package/src/runtime/routes/identity-routes.ts +76 -20
  188. package/src/runtime/routes/inbound-message-handler.ts +0 -36
  189. package/src/runtime/routes/plugins-routes.ts +21 -0
  190. package/src/runtime/routes/schedule-routes.ts +19 -2
  191. package/src/runtime/routes/trust-rules-routes.ts +14 -67
  192. package/src/schedule/recurrence-engine.ts +34 -0
  193. package/src/schedule/scheduler.ts +1 -0
  194. package/src/skills/validate-input.ts +41 -1
  195. package/src/subagent/types.ts +26 -1
  196. package/src/telemetry/types.ts +15 -1
  197. package/src/telemetry/usage-telemetry-reporter.test.ts +6 -1
  198. package/src/telemetry/usage-telemetry-reporter.ts +1 -0
  199. package/src/tools/apps/executors.ts +1 -1
  200. package/src/tools/skills/skill-tool-factory.ts +19 -8
  201. package/src/usage/types.ts +8 -1
  202. package/src/util/platform.ts +16 -0
  203. package/src/watcher/engine.ts +1 -0
  204. package/src/workspace/adaptive-thinking-repair.ts +113 -0
  205. package/src/workspace/migrations/097-enable-adaptive-thinking-managed-profiles.ts +70 -67
  206. package/src/workspace/migrations/098-remove-stale-updates-bulletin-file.ts +31 -0
  207. package/src/workspace/migrations/099-disable-cache-one-shot-callsites.ts +81 -0
  208. package/src/workspace/migrations/registry.ts +4 -0
  209. package/src/__tests__/config-loader-quarantine-bulletin.test.ts +0 -202
  210. package/src/__tests__/conversation-starters-cadence.test.ts +0 -161
  211. package/src/__tests__/guardian-action-followup-executor.test.ts +0 -322
  212. package/src/__tests__/guardian-action-followup-store.test.ts +0 -373
  213. package/src/__tests__/guardian-action-late-reply.test.ts +0 -1083
  214. package/src/__tests__/update-bulletin-job.test.ts +0 -292
  215. package/src/config/schemas/updates.ts +0 -14
  216. package/src/memory/__tests__/compaction-log-writer-clickhouse.test.ts +0 -227
  217. package/src/memory/conversation-starters-cadence.ts +0 -78
  218. package/src/prompts/update-bulletin-job.ts +0 -180
  219. package/src/runtime/guardian-action-followup-executor.ts +0 -306
@@ -1,123 +1,117 @@
1
1
  /**
2
2
  * Unit tests for the trust rule IPC proxy routes.
3
- *
4
- * Covers:
5
- * - trust_rules_list: no params, tool filter, include_all, origin filter
6
- * - error path: non-OK gateway response surfaces body .error message
7
3
  */
8
4
 
9
5
  import { beforeEach, describe, expect, mock, test } from "bun:test";
10
6
 
11
- // ---------------------------------------------------------------------------
12
- // Mocks — must be defined before importing the module under test
13
- // ---------------------------------------------------------------------------
14
-
15
- mock.module("../../config/env.js", () => ({
16
- getGatewayInternalBaseUrl: () => "http://localhost:7822",
17
- }));
18
-
19
- type MockResponse = {
20
- ok: boolean;
21
- status: number;
22
- json: () => Promise<unknown>;
7
+ type IpcCall = {
8
+ method: string;
9
+ params?: Record<string, unknown>;
23
10
  };
24
11
 
25
- let mockFetchResponse: MockResponse = {
26
- ok: true,
27
- status: 200,
28
- json: async () => ({ rules: [] }),
29
- };
30
-
31
- let capturedFetchCalls: Array<{ url: string; init?: RequestInit }> = [];
12
+ let ipcCalls: IpcCall[] = [];
13
+ let ipcResult: unknown = { rules: [] };
14
+ let ipcError: Error | undefined;
32
15
 
33
- const mockFetch = mock(async (url: string, init?: RequestInit) => {
34
- capturedFetchCalls.push({ url, init });
35
- return mockFetchResponse;
36
- });
16
+ const ipcCallPersistentMock = mock(
17
+ async (method: string, params?: Record<string, unknown>) => {
18
+ ipcCalls.push({ method, params });
19
+ if (ipcError) throw ipcError;
20
+ return ipcResult;
21
+ },
22
+ );
37
23
 
38
- global.fetch = mockFetch as unknown as typeof fetch;
39
-
40
- // ---------------------------------------------------------------------------
41
- // Import module under test AFTER mocks are set up
42
- // ---------------------------------------------------------------------------
24
+ mock.module("../gateway-client.js", () => ({
25
+ ipcCallPersistent: ipcCallPersistentMock,
26
+ }));
43
27
 
44
28
  import { ROUTES as trustRuleRoutes } from "../../runtime/routes/trust-rules-routes.js";
45
29
 
46
- // ---------------------------------------------------------------------------
47
- // Helpers
48
- // ---------------------------------------------------------------------------
49
-
50
30
  function findRoute(method: string) {
51
31
  const route = trustRuleRoutes.find((r) => r.operationId === method);
52
32
  if (!route) throw new Error(`Route not found: ${method}`);
53
33
  return route;
54
34
  }
55
35
 
56
- // ---------------------------------------------------------------------------
57
- // Tests
58
- // ---------------------------------------------------------------------------
59
-
60
36
  describe("trustRuleRoutes", () => {
61
37
  beforeEach(() => {
62
- capturedFetchCalls = [];
63
- mockFetchResponse = {
64
- ok: true,
65
- status: 200,
66
- json: async () => ({ rules: [] }),
67
- };
68
- mockFetch.mockClear();
38
+ ipcCalls = [];
39
+ ipcResult = { rules: [] };
40
+ ipcError = undefined;
41
+ ipcCallPersistentMock.mockClear();
69
42
  });
70
43
 
71
44
  describe("trust_rules_list", () => {
72
- test("no params → GET /v1/trust-rules (no query string)", async () => {
45
+ test("no params calls trust_rules_list with empty params", async () => {
73
46
  const route = findRoute("trust_rules_list");
74
47
  await route.handler({ body: {} });
75
48
 
76
- expect(capturedFetchCalls).toHaveLength(1);
77
- expect(capturedFetchCalls[0].url).toBe(
78
- "http://localhost:7822/v1/trust-rules",
79
- );
80
- expect(capturedFetchCalls[0].init).toBeUndefined();
49
+ expect(ipcCalls).toEqual([{ method: "trust_rules_list", params: {} }]);
81
50
  });
82
51
 
83
- test("{ tool: 'bash' } → appends ?tool=bash", async () => {
52
+ test("{ tool: 'bash' } is forwarded", async () => {
84
53
  const route = findRoute("trust_rules_list");
85
54
  await route.handler({ body: { tool: "bash" } });
86
55
 
87
- expect(capturedFetchCalls[0].url).toBe(
88
- "http://localhost:7822/v1/trust-rules?tool=bash",
89
- );
56
+ expect(ipcCalls).toEqual([
57
+ { method: "trust_rules_list", params: { tool: "bash" } },
58
+ ]);
90
59
  });
91
60
 
92
- test("{ include_all: true } → appends ?include_all=true", async () => {
61
+ test("{ include_all: true } is forwarded", async () => {
93
62
  const route = findRoute("trust_rules_list");
94
63
  await route.handler({ body: { include_all: true } });
95
64
 
96
- expect(capturedFetchCalls[0].url).toBe(
97
- "http://localhost:7822/v1/trust-rules?include_all=true",
98
- );
65
+ expect(ipcCalls).toEqual([
66
+ { method: "trust_rules_list", params: { include_all: true } },
67
+ ]);
99
68
  });
100
69
 
101
- test("{ origin: 'user_defined' } → appends ?origin=user_defined", async () => {
70
+ test("{ origin: 'user_defined' } is forwarded", async () => {
102
71
  const route = findRoute("trust_rules_list");
103
72
  await route.handler({ body: { origin: "user_defined" } });
104
73
 
105
- expect(capturedFetchCalls[0].url).toBe(
106
- "http://localhost:7822/v1/trust-rules?origin=user_defined",
107
- );
74
+ expect(ipcCalls).toEqual([
75
+ {
76
+ method: "trust_rules_list",
77
+ params: { origin: "user_defined" },
78
+ },
79
+ ]);
80
+ });
81
+
82
+ test("returns the parsed gateway IPC response", async () => {
83
+ ipcResult = {
84
+ rules: [
85
+ {
86
+ id: "rule-123",
87
+ tool: "bash",
88
+ pattern: "echo hello",
89
+ risk: "low",
90
+ description: "Allow echo hello",
91
+ origin: "user_defined",
92
+ userModified: false,
93
+ deleted: false,
94
+ createdAt: "2026-01-01T00:00:00.000Z",
95
+ updatedAt: "2026-01-01T00:00:00.000Z",
96
+ },
97
+ ],
98
+ };
99
+
100
+ const route = findRoute("trust_rules_list");
101
+ const result = await route.handler({ body: {} });
102
+
103
+ expect(result).toEqual(ipcResult);
108
104
  });
109
105
  });
110
106
 
111
107
  describe("error path", () => {
112
- test("non-OK response surfaces body .error message", async () => {
113
- mockFetchResponse = {
114
- ok: false,
115
- status: 404,
116
- json: async () => ({ error: "Not found" }),
117
- };
108
+ test("gateway IPC error is propagated", async () => {
109
+ ipcError = new Error("Gateway IPC call timed out");
118
110
 
119
111
  const route = findRoute("trust_rules_list");
120
- await expect(route.handler({ body: {} })).rejects.toThrow("Not found");
112
+ await expect(route.handler({ body: {} })).rejects.toThrow(
113
+ "Gateway IPC call timed out",
114
+ );
121
115
  });
122
116
  });
123
117
  });
@@ -66,8 +66,8 @@ export async function resolveImageGenCredentials(opts: {
66
66
  function providerKeyHint(provider: ImageGenProvider): string {
67
67
  switch (provider) {
68
68
  case "gemini":
69
- return "No Gemini API key configured. Please set your Gemini API key in Settings > Models & Services.";
69
+ return "No Gemini API key configured. Please set your Gemini API key in Settings → Models & Services.";
70
70
  case "openai":
71
- return "No OpenAI API key configured. Please set your OpenAI API key in Settings > Models & Services.";
71
+ return "No OpenAI API key configured. Please set your OpenAI API key in Settings → Models & Services.";
72
72
  }
73
73
  }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Tests for the ClickHouse compaction log store.
3
+ *
4
+ * The store is exercised with an injected `fetchImpl` so the tests pin the
5
+ * wire shape: lazy CREATE TABLE on first write, INSERT statements via the
6
+ * `query` URL param with JSONEachRow bodies, the start/end row mappings
7
+ * (sentinels on the start row, full `ContextWindowResult` projection on the
8
+ * end row), and the read path that pairs rows back into per-attempt events.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+
12
+ import type { ContextWindowResult } from "../../plugins/defaults/compaction/window-manager.js";
13
+ import {
14
+ ClickHouseCompactionLogStore,
15
+ type CompactionEndEvent,
16
+ type CompactionStartEvent,
17
+ } from "../compaction-log-store-clickhouse.js";
18
+
19
+ interface CapturedRequest {
20
+ url: URL;
21
+ body: string;
22
+ }
23
+
24
+ function createWriter(overrides?: {
25
+ fetchImpl?: (url: string, init?: RequestInit) => Promise<Response>;
26
+ }): { writer: ClickHouseCompactionLogStore; requests: CapturedRequest[] } {
27
+ const requests: CapturedRequest[] = [];
28
+ const fetchImpl = (overrides?.fetchImpl ??
29
+ (async (url: string, init?: RequestInit) => {
30
+ requests.push({ url: new URL(url), body: String(init?.body ?? "") });
31
+ return new Response("", { status: 200 });
32
+ })) as typeof fetch;
33
+ const writer = new ClickHouseCompactionLogStore(
34
+ { database: "analytics", table: "compaction_logs", user: "writer" },
35
+ {
36
+ resolveUrl: async () => "http://clickhouse.example.com:8123",
37
+ resolvePassword: async () => "secret",
38
+ resolveAssistantId: async () => "assistant-123",
39
+ fetchImpl,
40
+ },
41
+ );
42
+ return { writer, requests };
43
+ }
44
+
45
+ const startEvent: CompactionStartEvent = {
46
+ type: "context_compacting",
47
+ compactionId: "comp-1",
48
+ requestId: "req-1",
49
+ trigger: "budget",
50
+ startedAt: 1000,
51
+ messages: [
52
+ { role: "user", content: [{ type: "text", text: "hi" }] },
53
+ { role: "assistant", content: [{ type: "text", text: "hello" }] },
54
+ ],
55
+ };
56
+
57
+ const result: ContextWindowResult = {
58
+ messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }],
59
+ compacted: true,
60
+ previousEstimatedInputTokens: 900,
61
+ estimatedInputTokens: 300,
62
+ maxInputTokens: 1000,
63
+ thresholdTokens: 850,
64
+ compactedMessages: 10,
65
+ compactedPersistedMessages: 8,
66
+ preservedTailMessages: 2,
67
+ summaryCalls: 1,
68
+ summaryInputTokens: 880,
69
+ summaryOutputTokens: 120,
70
+ summaryModel: "test-model",
71
+ summaryText: "summary text",
72
+ reason: "auto",
73
+ exhausted: false,
74
+ };
75
+
76
+ const endEvent: CompactionEndEvent = {
77
+ type: "compaction_completed",
78
+ compactionId: "comp-1",
79
+ requestId: "req-1",
80
+ trigger: "budget",
81
+ startedAt: 1000,
82
+ finishedAt: 1500,
83
+ ...result,
84
+ };
85
+
86
+ describe("ClickHouseCompactionLogStore", () => {
87
+ test("first write creates the table then inserts", async () => {
88
+ const { writer, requests } = createWriter();
89
+
90
+ await writer.writeStart("conv-1", startEvent);
91
+
92
+ expect(requests.length).toBe(2);
93
+ expect(requests[0]!.body).toContain("CREATE TABLE IF NOT EXISTS");
94
+ expect(requests[0]!.body).toContain("`compaction_logs`");
95
+ expect(requests[0]!.url.searchParams.get("database")).toBe("analytics");
96
+
97
+ const insert = requests[1]!;
98
+ expect(insert.url.searchParams.get("query")).toBe(
99
+ "INSERT INTO `compaction_logs` FORMAT JSONEachRow",
100
+ );
101
+ const row = JSON.parse(insert.body) as Record<string, unknown>;
102
+ expect(row.assistant_id).toBe("assistant-123");
103
+ expect(row.conversation_id).toBe("conv-1");
104
+ expect(row.compaction_id).toBe("comp-1");
105
+ expect(row.request_id).toBe("req-1");
106
+ expect(row.phase).toBe("start");
107
+ expect(row.trigger).toBe("budget");
108
+ expect(row.started_at).toBe(1000);
109
+ expect(row.pre_message_count).toBe(2);
110
+ // End-only fields stay at their sentinels on the start row.
111
+ expect(row.finished_at).toBe(0);
112
+ expect(row.duration_ms).toBe(-1);
113
+ expect(row.estimated_input_tokens).toBe(-1);
114
+ expect(row.summary_model).toBe("");
115
+ });
116
+
117
+ test("table creation happens once across writes", async () => {
118
+ const { writer, requests } = createWriter();
119
+
120
+ await writer.writeStart("conv-1", startEvent);
121
+ await writer.writeEnd("conv-1", endEvent);
122
+
123
+ const ddl = requests.filter((r) => r.body.includes("CREATE TABLE"));
124
+ expect(ddl.length).toBe(1);
125
+ expect(requests.length).toBe(3);
126
+ });
127
+
128
+ test("end row projects the full ContextWindowResult", async () => {
129
+ const { writer, requests } = createWriter();
130
+
131
+ await writer.writeEnd("conv-1", endEvent);
132
+
133
+ const row = JSON.parse(requests[1]!.body) as Record<string, unknown>;
134
+ expect(row.phase).toBe("end");
135
+ expect(row.finished_at).toBe(1500);
136
+ expect(row.duration_ms).toBe(500);
137
+ expect(row.result_message_count).toBe(1);
138
+ expect(row.compacted).toBe(1);
139
+ expect(row.previous_estimated_input_tokens).toBe(900);
140
+ expect(row.estimated_input_tokens).toBe(300);
141
+ expect(row.max_input_tokens).toBe(1000);
142
+ expect(row.threshold_tokens).toBe(850);
143
+ expect(row.compacted_messages).toBe(10);
144
+ expect(row.compacted_persisted_messages).toBe(8);
145
+ expect(row.preserved_tail_messages).toBe(2);
146
+ expect(row.summary_calls).toBe(1);
147
+ expect(row.summary_input_tokens).toBe(880);
148
+ expect(row.summary_output_tokens).toBe(120);
149
+ expect(row.summary_model).toBe("test-model");
150
+ expect(row.summary_failed).toBe(-1);
151
+ expect(row.reason).toBe("auto");
152
+ expect(row.exhausted).toBe(0);
153
+ expect(row.summary_text).toBe("summary text");
154
+ });
155
+
156
+ test("end row maps explicit summaryFailed and omitted optionals", async () => {
157
+ const { writer, requests } = createWriter();
158
+
159
+ await writer.writeEnd("conv-1", {
160
+ ...endEvent,
161
+ compacted: false,
162
+ summaryFailed: true,
163
+ preservedTailMessages: undefined,
164
+ reason: undefined,
165
+ exhausted: true,
166
+ });
167
+
168
+ const row = JSON.parse(requests[1]!.body) as Record<string, unknown>;
169
+ expect(row.compacted).toBe(0);
170
+ expect(row.summary_failed).toBe(1);
171
+ expect(row.preserved_tail_messages).toBe(-1);
172
+ expect(row.reason).toBe("");
173
+ expect(row.exhausted).toBe(1);
174
+ });
175
+
176
+ test("failed table creation retries on the next write", async () => {
177
+ let calls = 0;
178
+ const requests: CapturedRequest[] = [];
179
+ const { writer } = createWriter({
180
+ fetchImpl: async (url: string, init?: RequestInit) => {
181
+ calls++;
182
+ requests.push({ url: new URL(url), body: String(init?.body ?? "") });
183
+ if (calls === 1) return new Response("boom", { status: 500 });
184
+ return new Response("", { status: 200 });
185
+ },
186
+ });
187
+
188
+ await expect(writer.writeStart("conv-1", startEvent)).rejects.toThrow(
189
+ "ClickHouse compaction-log request failed",
190
+ );
191
+
192
+ await writer.writeStart("conv-1", startEvent);
193
+ const ddl = requests.filter((r) => r.body.includes("CREATE TABLE"));
194
+ expect(ddl.length).toBe(2);
195
+ });
196
+
197
+ test("write failures surface the HTTP status and body", async () => {
198
+ let calls = 0;
199
+ const { writer } = createWriter({
200
+ fetchImpl: async () => {
201
+ calls++;
202
+ if (calls === 1) return new Response("", { status: 200 });
203
+ return new Response("table is read-only", { status: 403 });
204
+ },
205
+ });
206
+
207
+ await expect(writer.writeEnd("conv-1", endEvent)).rejects.toThrow(
208
+ /HTTP 403.*table is read-only/,
209
+ );
210
+ });
211
+
212
+ test("long summary text is truncated", async () => {
213
+ const { writer, requests } = createWriter();
214
+
215
+ await writer.writeEnd("conv-1", {
216
+ ...endEvent,
217
+ summaryText: "x".repeat(10_000),
218
+ });
219
+
220
+ const row = JSON.parse(requests[1]!.body) as Record<string, unknown>;
221
+ expect((row.summary_text as string).length).toBe(4000);
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------
226
+ // Read path — getEventsBetween
227
+ // ---------------------------------------------------------------------
228
+
229
+ function startRow(overrides: Record<string, unknown> = {}) {
230
+ return {
231
+ compaction_id: "comp-1",
232
+ request_id: "req-1",
233
+ phase: "start",
234
+ trigger: "budget",
235
+ started_at: 1000,
236
+ finished_at: 0,
237
+ duration_ms: -1,
238
+ pre_message_count: 12,
239
+ result_message_count: -1,
240
+ compacted: 0,
241
+ previous_estimated_input_tokens: -1,
242
+ estimated_input_tokens: -1,
243
+ max_input_tokens: -1,
244
+ threshold_tokens: -1,
245
+ compacted_messages: -1,
246
+ compacted_persisted_messages: -1,
247
+ preserved_tail_messages: -1,
248
+ summary_calls: -1,
249
+ summary_input_tokens: -1,
250
+ summary_output_tokens: -1,
251
+ summary_model: "",
252
+ summary_failed: -1,
253
+ reason: "",
254
+ exhausted: 0,
255
+ injection_mode: "",
256
+ auto_compress_applied: 0,
257
+ summary_text: "",
258
+ ...overrides,
259
+ };
260
+ }
261
+
262
+ function endRow(overrides: Record<string, unknown> = {}) {
263
+ return startRow({
264
+ phase: "end",
265
+ finished_at: 1500,
266
+ duration_ms: 500,
267
+ pre_message_count: -1,
268
+ result_message_count: 4,
269
+ compacted: 1,
270
+ previous_estimated_input_tokens: 900,
271
+ estimated_input_tokens: 300,
272
+ max_input_tokens: 1000,
273
+ threshold_tokens: 850,
274
+ compacted_messages: 10,
275
+ compacted_persisted_messages: 8,
276
+ preserved_tail_messages: 2,
277
+ summary_calls: 1,
278
+ summary_input_tokens: 880,
279
+ summary_output_tokens: 120,
280
+ summary_model: "test-model",
281
+ summary_failed: 0,
282
+ reason: "auto",
283
+ summary_text: "summary text",
284
+ ...overrides,
285
+ });
286
+ }
287
+
288
+ function createReader(rows: Record<string, unknown>[]): {
289
+ writer: ClickHouseCompactionLogStore;
290
+ requests: CapturedRequest[];
291
+ } {
292
+ const requests: CapturedRequest[] = [];
293
+ const { writer } = createWriter({
294
+ fetchImpl: async (url: string, init?: RequestInit) => {
295
+ requests.push({ url: new URL(url), body: String(init?.body ?? "") });
296
+ const body =
297
+ rows.length === 0
298
+ ? ""
299
+ : rows.map((r) => JSON.stringify(r)).join("\n") + "\n";
300
+ return new Response(body, { status: 200 });
301
+ },
302
+ });
303
+ return { writer, requests };
304
+ }
305
+
306
+ describe("ClickHouseCompactionLogStore — getEventsBetween", () => {
307
+ test("binds the window via param_ URL slots, never string interpolation", async () => {
308
+ const { writer, requests } = createReader([]);
309
+
310
+ await writer.getEventsBetween("conv-1", 1999, 9001);
311
+
312
+ expect(requests.length).toBe(1);
313
+ const req = requests[0]!;
314
+ expect(req.url.searchParams.get("database")).toBe("analytics");
315
+ expect(req.url.searchParams.get("param_assistant_id")).toBe(
316
+ "assistant-123",
317
+ );
318
+ expect(req.url.searchParams.get("param_conversation_id")).toBe("conv-1");
319
+ expect(req.url.searchParams.get("param_after_started_at")).toBe("1999");
320
+ expect(req.url.searchParams.get("param_before_started_at")).toBe("9001");
321
+ expect(req.body).toContain("{assistant_id:String}");
322
+ expect(req.body).toContain(
323
+ "started_at > fromUnixTimestamp64Milli({after_started_at:Int64})",
324
+ );
325
+ expect(req.body).toContain("LIMIT 1 BY compaction_id, phase");
326
+ expect(req.body).toContain("FORMAT JSONEachRow");
327
+ });
328
+
329
+ test("omits the floor predicate when afterStartedAt is null", async () => {
330
+ const { writer, requests } = createReader([]);
331
+
332
+ await writer.getEventsBetween("conv-1", null, 9001);
333
+
334
+ const req = requests[0]!;
335
+ expect(req.url.searchParams.get("param_after_started_at")).toBeNull();
336
+ expect(req.body).not.toContain("after_started_at");
337
+ });
338
+
339
+ test("pairs start and end rows into one completed event", async () => {
340
+ const { writer } = createReader([startRow(), endRow()]);
341
+
342
+ const events = await writer.getEventsBetween("conv-1", null, 9001);
343
+
344
+ expect(events).toHaveLength(1);
345
+ const event = events[0]!;
346
+ expect(event.compactionId).toBe("comp-1");
347
+ expect(event.completed).toBe(true);
348
+ expect(event.trigger).toBe("budget");
349
+ expect(event.startedAt).toBe(1000);
350
+ expect(event.finishedAt).toBe(1500);
351
+ expect(event.durationMs).toBe(500);
352
+ // Start-phase field comes from the start row even though the end
353
+ // row carries the -1 sentinel for it.
354
+ expect(event.preMessageCount).toBe(12);
355
+ expect(event.resultMessageCount).toBe(4);
356
+ expect(event.compacted).toBe(true);
357
+ expect(event.summaryInputTokens).toBe(880);
358
+ expect(event.summaryOutputTokens).toBe(120);
359
+ expect(event.summaryModel).toBe("test-model");
360
+ expect(event.summaryFailed).toBe(false);
361
+ expect(event.reason).toBe("auto");
362
+ expect(event.exhausted).toBe(false);
363
+ expect(event.summaryText).toBe("summary text");
364
+ });
365
+
366
+ test("start-only rows surface as incomplete events with null end fields", async () => {
367
+ const { writer } = createReader([startRow()]);
368
+
369
+ const events = await writer.getEventsBetween("conv-1", null, 9001);
370
+
371
+ expect(events).toHaveLength(1);
372
+ const event = events[0]!;
373
+ expect(event.completed).toBe(false);
374
+ expect(event.preMessageCount).toBe(12);
375
+ expect(event.finishedAt).toBeNull();
376
+ expect(event.durationMs).toBeNull();
377
+ expect(event.compacted).toBeNull();
378
+ expect(event.summaryModel).toBeNull();
379
+ expect(event.summaryFailed).toBeNull();
380
+ expect(event.summaryText).toBeNull();
381
+ });
382
+
383
+ test("maps sentinels back to null and coerces quoted Int64 values", async () => {
384
+ // ClickHouse may serialize Int64 columns as JSON strings.
385
+ const { writer } = createReader([
386
+ endRow({
387
+ compaction_id: "comp-2",
388
+ started_at: "2000",
389
+ duration_ms: "700",
390
+ preserved_tail_messages: -1,
391
+ summary_failed: -1,
392
+ reason: "",
393
+ summary_text: "",
394
+ }),
395
+ ]);
396
+
397
+ const events = await writer.getEventsBetween("conv-1", null, 9001);
398
+
399
+ const event = events[0]!;
400
+ expect(event.startedAt).toBe(2000);
401
+ expect(event.durationMs).toBe(700);
402
+ expect(event.preservedTailMessages).toBeNull();
403
+ expect(event.summaryFailed).toBeNull();
404
+ expect(event.reason).toBeNull();
405
+ expect(event.summaryText).toBeNull();
406
+ // End-only pair: start-phase field is unknown.
407
+ expect(event.preMessageCount).toBeNull();
408
+ });
409
+
410
+ test("sorts paired events by startedAt ascending", async () => {
411
+ const { writer } = createReader([
412
+ startRow({ compaction_id: "comp-late", started_at: 5000 }),
413
+ startRow({ compaction_id: "comp-early", started_at: 1000 }),
414
+ endRow({ compaction_id: "comp-late", started_at: 5000 }),
415
+ ]);
416
+
417
+ const events = await writer.getEventsBetween("conv-1", null, 9001);
418
+
419
+ expect(events.map((e) => e.compactionId)).toEqual([
420
+ "comp-early",
421
+ "comp-late",
422
+ ]);
423
+ });
424
+
425
+ test("empty response body yields an empty event list", async () => {
426
+ const { writer } = createReader([]);
427
+
428
+ const events = await writer.getEventsBetween("conv-1", null, 9001);
429
+
430
+ expect(events).toEqual([]);
431
+ });
432
+ });