@vellumai/assistant 0.5.4 → 0.5.6

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 (151) hide show
  1. package/Dockerfile +17 -27
  2. package/node_modules/@vellumai/ces-contracts/src/index.ts +1 -0
  3. package/node_modules/@vellumai/ces-contracts/src/trust-rules.ts +42 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/actor-token-service.test.ts +113 -0
  6. package/src/__tests__/config-schema.test.ts +2 -2
  7. package/src/__tests__/context-window-manager.test.ts +78 -0
  8. package/src/__tests__/conversation-title-service.test.ts +30 -1
  9. package/src/__tests__/credential-security-invariants.test.ts +2 -0
  10. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  11. package/src/__tests__/memory-regressions.test.ts +8 -30
  12. package/src/__tests__/openai-whisper.test.ts +93 -0
  13. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  14. package/src/__tests__/slack-messaging-token-resolution.test.ts +319 -0
  15. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  16. package/src/__tests__/tool-executor.test.ts +4 -0
  17. package/src/__tests__/volume-security-guard.test.ts +155 -0
  18. package/src/cli/commands/conversations.ts +0 -18
  19. package/src/config/bundled-skills/messaging/tools/shared.ts +1 -0
  20. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +16 -37
  21. package/src/config/env-registry.ts +9 -0
  22. package/src/config/env.ts +8 -2
  23. package/src/config/feature-flag-registry.json +8 -8
  24. package/src/config/schema.ts +0 -12
  25. package/src/config/schemas/memory.ts +0 -4
  26. package/src/config/schemas/platform.ts +1 -1
  27. package/src/config/schemas/security.ts +4 -0
  28. package/src/context/window-manager.ts +53 -2
  29. package/src/credential-execution/managed-catalog.ts +5 -15
  30. package/src/daemon/conversation-agent-loop.ts +0 -60
  31. package/src/daemon/conversation-memory.ts +0 -117
  32. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  33. package/src/daemon/daemon-control.ts +7 -0
  34. package/src/daemon/handlers/conversations.ts +0 -11
  35. package/src/daemon/lifecycle.ts +10 -47
  36. package/src/daemon/providers-setup.ts +2 -1
  37. package/src/followups/followup-store.ts +5 -2
  38. package/src/hooks/manager.ts +7 -0
  39. package/src/instrument.ts +33 -1
  40. package/src/memory/conversation-crud.ts +0 -236
  41. package/src/memory/conversation-title-service.ts +26 -10
  42. package/src/memory/db-init.ts +5 -13
  43. package/src/memory/embedding-local.ts +11 -5
  44. package/src/memory/indexer.ts +15 -106
  45. package/src/memory/job-handlers/conversation-starters.ts +24 -36
  46. package/src/memory/job-handlers/embedding.ts +0 -79
  47. package/src/memory/job-utils.ts +1 -1
  48. package/src/memory/jobs-store.ts +0 -8
  49. package/src/memory/jobs-worker.ts +0 -20
  50. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  51. package/src/memory/migrations/index.ts +1 -3
  52. package/src/memory/qdrant-client.ts +4 -6
  53. package/src/memory/schema/conversations.ts +0 -3
  54. package/src/memory/schema/index.ts +0 -2
  55. package/src/messaging/draft-store.ts +2 -2
  56. package/src/messaging/provider.ts +9 -0
  57. package/src/messaging/providers/slack/adapter.ts +29 -2
  58. package/src/oauth/connection-resolver.test.ts +22 -18
  59. package/src/oauth/connection-resolver.ts +92 -7
  60. package/src/oauth/platform-connection.test.ts +78 -69
  61. package/src/oauth/platform-connection.ts +12 -19
  62. package/src/permissions/defaults.ts +3 -3
  63. package/src/permissions/trust-client.ts +332 -0
  64. package/src/permissions/trust-store-interface.ts +105 -0
  65. package/src/permissions/trust-store.ts +531 -39
  66. package/src/platform/client.test.ts +148 -0
  67. package/src/platform/client.ts +71 -0
  68. package/src/providers/speech-to-text/openai-whisper.test.ts +190 -0
  69. package/src/providers/speech-to-text/openai-whisper.ts +68 -0
  70. package/src/providers/speech-to-text/resolve.ts +9 -0
  71. package/src/providers/speech-to-text/types.ts +17 -0
  72. package/src/runtime/auth/route-policy.ts +14 -0
  73. package/src/runtime/auth/token-service.ts +133 -0
  74. package/src/runtime/http-server.ts +4 -2
  75. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  76. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  77. package/src/runtime/routes/conversation-routes.ts +2 -1
  78. package/src/runtime/routes/inbound-message-handler.ts +27 -3
  79. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +16 -1
  80. package/src/runtime/routes/inbound-stages/transcribe-audio.test.ts +287 -0
  81. package/src/runtime/routes/inbound-stages/transcribe-audio.ts +122 -0
  82. package/src/runtime/routes/log-export-routes.ts +1 -0
  83. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  84. package/src/runtime/routes/memory-item-routes.ts +124 -2
  85. package/src/runtime/routes/secret-routes.ts +4 -1
  86. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  87. package/src/schedule/schedule-store.ts +0 -21
  88. package/src/security/ces-credential-client.ts +173 -0
  89. package/src/security/secure-keys.ts +65 -22
  90. package/src/signals/bash.ts +3 -0
  91. package/src/signals/cancel.ts +3 -0
  92. package/src/signals/confirm.ts +3 -0
  93. package/src/signals/conversation-undo.ts +3 -0
  94. package/src/signals/event-stream.ts +7 -0
  95. package/src/signals/shotgun.ts +3 -0
  96. package/src/signals/trust-rule.ts +3 -0
  97. package/src/skills/inline-command-render.ts +5 -1
  98. package/src/skills/inline-command-runner.ts +30 -2
  99. package/src/telemetry/usage-telemetry-reporter.test.ts +23 -36
  100. package/src/telemetry/usage-telemetry-reporter.ts +21 -19
  101. package/src/tools/memory/handlers.ts +1 -129
  102. package/src/tools/permission-checker.ts +18 -0
  103. package/src/tools/skills/load.ts +9 -2
  104. package/src/util/device-id.ts +70 -7
  105. package/src/util/logger.ts +35 -9
  106. package/src/util/platform.ts +29 -5
  107. package/src/util/xml.ts +8 -0
  108. package/src/workspace/heartbeat-service.ts +5 -24
  109. package/src/workspace/migrations/migrate-to-workspace-volume.ts +113 -0
  110. package/src/workspace/migrations/registry.ts +2 -0
  111. package/src/__tests__/archive-recall.test.ts +0 -560
  112. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  113. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  114. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  115. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  116. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  117. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  118. package/src/__tests__/memory-brief-time.test.ts +0 -285
  119. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  120. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  121. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  122. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  123. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  124. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  125. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  126. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  127. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  128. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  129. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  130. package/src/__tests__/memory-reducer.test.ts +0 -704
  131. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  132. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  133. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  134. package/src/config/schemas/memory-simplified.ts +0 -101
  135. package/src/memory/archive-recall.ts +0 -516
  136. package/src/memory/archive-store.ts +0 -400
  137. package/src/memory/brief-formatting.ts +0 -33
  138. package/src/memory/brief-open-loops.ts +0 -266
  139. package/src/memory/brief-time.ts +0 -162
  140. package/src/memory/brief.ts +0 -75
  141. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  142. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  143. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  144. package/src/memory/migrations/186-memory-archive.ts +0 -109
  145. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  146. package/src/memory/reducer-scheduler.ts +0 -242
  147. package/src/memory/reducer-store.ts +0 -271
  148. package/src/memory/reducer-types.ts +0 -106
  149. package/src/memory/reducer.ts +0 -467
  150. package/src/memory/schema/memory-archive.ts +0 -121
  151. package/src/memory/schema/memory-brief.ts +0 -55
@@ -1,626 +0,0 @@
1
- /**
2
- * Tests for dual-writing archive episodes from compaction summaries.
3
- *
4
- * Verifies:
5
- * - Normal compaction triggers an episode insertion
6
- * - Overflow (preflight) compaction triggers an episode insertion
7
- * - No episode is created when compaction does not produce a new summary
8
- */
9
- import { beforeEach, describe, expect, mock, test } from "bun:test";
10
-
11
- import type {
12
- AgentEvent,
13
- CheckpointDecision,
14
- CheckpointInfo,
15
- } from "../agent/loop.js";
16
- import type { ContextWindowResult } from "../context/window-manager.js";
17
- import type { ServerMessage } from "../daemon/message-protocol.js";
18
- import type { Message } from "../providers/types.js";
19
-
20
- // ── Module mocks (must precede imports of the module under test) ─────
21
-
22
- mock.module("../util/logger.js", () => ({
23
- getLogger: () =>
24
- new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
25
- }));
26
-
27
- mock.module("../util/platform.js", () => ({
28
- getDataDir: () => "/tmp",
29
- }));
30
-
31
- mock.module("../config/loader.js", () => ({
32
- getConfig: () => ({
33
- provider: "mock-provider",
34
- maxTokens: 4096,
35
- thinking: false,
36
- contextWindow: {
37
- maxInputTokens: 100000,
38
- thresholdTokens: 80000,
39
- preserveRecentMessages: 6,
40
- summaryModel: "mock-model",
41
- maxSummaryTokens: 512,
42
- overflowRecovery: {
43
- enabled: true,
44
- safetyMarginRatio: 0.05,
45
- maxAttempts: 3,
46
- interactiveLatestTurnCompression: "summarize",
47
- nonInteractiveLatestTurnCompression: "truncate",
48
- },
49
- },
50
- rateLimit: { maxRequestsPerMinute: 0 },
51
- workspaceGit: { turnCommitMaxWaitMs: 10 },
52
- ui: {},
53
- }),
54
- loadRawConfig: () => ({}),
55
- saveRawConfig: () => {},
56
- invalidateConfigCache: () => {},
57
- }));
58
-
59
- // Token estimator — small by default to avoid preflight trigger
60
- let mockEstimateTokens = 1000;
61
- mock.module("../context/token-estimator.js", () => ({
62
- estimatePromptTokens: () => mockEstimateTokens,
63
- }));
64
-
65
- // Reducer
66
- let mockReducerStepFn:
67
- | ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
68
- | null = null;
69
- mock.module("../daemon/context-overflow-reducer.js", () => ({
70
- createInitialReducerState: () => ({
71
- appliedTiers: [],
72
- injectionMode: "full" as const,
73
- exhausted: false,
74
- }),
75
- reduceContextOverflow: async (
76
- msgs: Message[],
77
- cfg: unknown,
78
- state: unknown,
79
- ) => {
80
- if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
81
- return {
82
- messages: msgs,
83
- tier: "forced_compaction",
84
- state: {
85
- appliedTiers: [
86
- "forced_compaction",
87
- "tool_result_truncation",
88
- "media_stubbing",
89
- "injection_downgrade",
90
- ],
91
- injectionMode: "full",
92
- exhausted: true,
93
- },
94
- estimatedTokens: 1000,
95
- };
96
- },
97
- }));
98
-
99
- mock.module("../daemon/context-overflow-policy.js", () => ({
100
- resolveOverflowAction: () => "fail_gracefully",
101
- }));
102
-
103
- mock.module("../daemon/context-overflow-approval.js", () => ({
104
- requestCompressionApproval: async () => ({ approved: false }),
105
- CONTEXT_OVERFLOW_TOOL_NAME: "context_overflow_compression",
106
- }));
107
-
108
- mock.module("../hooks/manager.js", () => ({
109
- getHookManager: () => ({
110
- trigger: async () => ({ blocked: false }),
111
- }),
112
- }));
113
-
114
- mock.module("../memory/conversation-crud.js", () => ({
115
- getConversationType: () => "default",
116
- setConversationOriginChannelIfUnset: () => {},
117
- updateConversationUsage: () => {},
118
- getMessages: () => [],
119
- getConversation: () => ({
120
- id: "conv-1",
121
- contextSummary: null,
122
- contextCompactedMessageCount: 0,
123
- totalInputTokens: 0,
124
- totalOutputTokens: 0,
125
- totalEstimatedCost: 0,
126
- title: null,
127
- }),
128
- provenanceFromTrustContext: () => ({
129
- source: "user",
130
- trustContext: undefined,
131
- }),
132
- getConversationOriginInterface: () => null,
133
- addMessage: () => ({ id: "mock-msg-id" }),
134
- deleteMessageById: () => {},
135
- updateConversationContextWindow: () => {},
136
- updateConversationTitle: () => {},
137
- getConversationOriginChannel: () => null,
138
- }));
139
-
140
- mock.module("../memory/conversation-disk-view.js", () => ({
141
- syncMessageToDisk: () => {},
142
- rebuildConversationDiskViewFromDbState: () => {},
143
- }));
144
-
145
- mock.module("../memory/retriever.js", () => ({
146
- buildMemoryRecall: async () => ({
147
- enabled: false,
148
- degraded: false,
149
- injectedText: "",
150
- semanticHits: 0,
151
- recencyHits: 0,
152
- injectedTokens: 0,
153
- latencyMs: 0,
154
- }),
155
- injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
156
- }));
157
-
158
- mock.module("../memory/app-store.js", () => ({
159
- getApp: () => null,
160
- listAppFiles: () => [],
161
- getAppsDir: () => "/tmp/apps",
162
- }));
163
-
164
- mock.module("../memory/app-git-service.js", () => ({
165
- commitAppTurnChanges: () => Promise.resolve(),
166
- }));
167
-
168
- mock.module("../daemon/conversation-memory.js", () => ({
169
- prepareMemoryContext: async (
170
- _ctx: unknown,
171
- _content: string,
172
- _id: string,
173
- _signal: AbortSignal,
174
- ) => ({
175
- runMessages: [],
176
- recall: {
177
- enabled: false,
178
- degraded: false,
179
- injectedText: "",
180
- semanticHits: 0,
181
- recencyHits: 0,
182
- injectedTokens: 0,
183
- latencyMs: 0,
184
- tier1Count: 0,
185
- tier2Count: 0,
186
- hybridSearchMs: 0,
187
- },
188
- }),
189
- }));
190
-
191
- mock.module("../daemon/conversation-runtime-assembly.js", () => ({
192
- applyRuntimeInjections: (msgs: Message[]) => msgs,
193
- stripInjectedContext: (msgs: Message[]) => msgs,
194
- }));
195
-
196
- mock.module("../daemon/date-context.js", () => ({
197
- buildTemporalContext: () => null,
198
- }));
199
-
200
- mock.module("../daemon/history-repair.js", () => ({
201
- repairHistory: (msgs: Message[]) => ({
202
- messages: msgs,
203
- stats: {
204
- assistantToolResultsMigrated: 0,
205
- missingToolResultsInserted: 0,
206
- orphanToolResultsDowngraded: 0,
207
- consecutiveSameRoleMerged: 0,
208
- },
209
- }),
210
- deepRepairHistory: (msgs: Message[]) => ({ messages: msgs, stats: {} }),
211
- }));
212
-
213
- mock.module("../daemon/conversation-history.js", () => ({
214
- consolidateAssistantMessages: () => false,
215
- }));
216
-
217
- mock.module("../daemon/conversation-usage.js", () => ({
218
- recordUsage: () => {},
219
- }));
220
-
221
- mock.module("../daemon/conversation-attachments.js", () => ({
222
- resolveAssistantAttachments: async () => ({
223
- assistantAttachments: [],
224
- emittedAttachments: [],
225
- directiveWarnings: [],
226
- }),
227
- approveHostAttachmentRead: async () => true,
228
- formatAttachmentWarnings: () => "",
229
- }));
230
-
231
- mock.module("../daemon/assistant-attachments.js", () => ({
232
- cleanAssistantContent: (content: unknown[]) => ({
233
- cleanedContent: content,
234
- directives: [],
235
- warnings: [],
236
- }),
237
- drainDirectiveDisplayBuffer: (buffer: string) => ({
238
- emitText: buffer,
239
- bufferedRemainder: "",
240
- }),
241
- }));
242
-
243
- mock.module("../daemon/conversation-media-retry.js", () => ({
244
- stripMediaPayloadsForRetry: (msgs: Message[]) => ({
245
- messages: msgs,
246
- modified: false,
247
- replacedBlocks: 0,
248
- latestUserIndex: null,
249
- }),
250
- raceWithTimeout: async () => "completed" as const,
251
- }));
252
-
253
- mock.module("../workspace/turn-commit.js", () => ({
254
- commitTurnChanges: async () => {},
255
- }));
256
-
257
- mock.module("../workspace/git-service.js", () => ({
258
- getWorkspaceGitService: () => ({
259
- ensureInitialized: async () => {},
260
- }),
261
- }));
262
-
263
- mock.module("../daemon/conversation-error.js", () => ({
264
- classifyConversationError: (_err: unknown, _ctx: unknown) => ({
265
- code: "CONVERSATION_PROCESSING_FAILED",
266
- userMessage: "Something went wrong processing your message.",
267
- retryable: false,
268
- errorCategory: "processing_failed",
269
- }),
270
- isUserCancellation: (err: unknown, ctx: { aborted?: boolean }) => {
271
- if (!ctx.aborted) return false;
272
- if (err instanceof DOMException && err.name === "AbortError") return true;
273
- if (err instanceof Error && err.name === "AbortError") return true;
274
- return false;
275
- },
276
- buildConversationErrorMessage: (
277
- conversationId: string,
278
- classified: Record<string, unknown>,
279
- ) => ({
280
- type: "conversation_error",
281
- conversationId,
282
- ...classified,
283
- }),
284
- isContextTooLarge: (msg: string) => /context.?length.?exceeded/i.test(msg),
285
- }));
286
-
287
- mock.module("../daemon/conversation-slash.js", () => ({
288
- isProviderOrderingError: (msg: string) =>
289
- /ordering|before.*after|messages.*order/i.test(msg),
290
- }));
291
-
292
- mock.module("../util/truncate.js", () => ({
293
- truncate: (s: string, maxLen: number) =>
294
- s.length <= maxLen ? s : s.slice(0, maxLen),
295
- }));
296
-
297
- mock.module("../agent/message-types.js", () => ({
298
- createAssistantMessage: (text: string) => ({
299
- role: "assistant" as const,
300
- content: [{ type: "text", text }],
301
- }),
302
- }));
303
-
304
- mock.module("../memory/llm-request-log-store.js", () => ({
305
- recordRequestLog: () => {},
306
- backfillMessageIdOnLogs: () => {},
307
- }));
308
-
309
- // ── Archive store mock — tracks insertCompactionEpisode calls ────────
310
-
311
- const insertCompactionEpisodeCalls: Array<{
312
- conversationId: string;
313
- scopeId?: string;
314
- title: string;
315
- summary: string;
316
- tokenEstimate: number;
317
- }> = [];
318
-
319
- mock.module("../memory/archive-store.js", () => ({
320
- insertCompactionEpisode: (params: {
321
- conversationId: string;
322
- scopeId?: string;
323
- title: string;
324
- summary: string;
325
- tokenEstimate: number;
326
- startAt: number;
327
- endAt: number;
328
- }) => {
329
- insertCompactionEpisodeCalls.push({
330
- conversationId: params.conversationId,
331
- scopeId: params.scopeId,
332
- title: params.title,
333
- summary: params.summary,
334
- tokenEstimate: params.tokenEstimate,
335
- });
336
- return { episodeId: "mock-episode-id", jobId: "mock-job-id" };
337
- },
338
- }));
339
-
340
- // ── Imports (after mocks) ────────────────────────────────────────────
341
-
342
- import {
343
- type AgentLoopConversationContext,
344
- runAgentLoopImpl,
345
- } from "../daemon/conversation-agent-loop.js";
346
-
347
- // ── Test helpers ─────────────────────────────────────────────────────
348
-
349
- type AgentLoopRun = (
350
- messages: Message[],
351
- onEvent: (event: AgentEvent) => void,
352
- signal?: AbortSignal,
353
- requestId?: string,
354
- onCheckpoint?: (checkpoint: CheckpointInfo) => CheckpointDecision,
355
- ) => Promise<Message[]>;
356
-
357
- function makeCompactResult(
358
- summaryText: string,
359
- overrides?: Partial<ContextWindowResult>,
360
- ): ContextWindowResult {
361
- return {
362
- messages: [
363
- {
364
- role: "user",
365
- content: [{ type: "text", text: `[Summary] ${summaryText}` }],
366
- },
367
- ] as Message[],
368
- compacted: true,
369
- previousEstimatedInputTokens: 80000,
370
- estimatedInputTokens: 30000,
371
- maxInputTokens: 100000,
372
- thresholdTokens: 80000,
373
- compactedMessages: 10,
374
- compactedPersistedMessages: 8,
375
- summaryCalls: 1,
376
- summaryInputTokens: 500,
377
- summaryOutputTokens: 150,
378
- summaryModel: "mock-model",
379
- summaryText,
380
- ...overrides,
381
- };
382
- }
383
-
384
- function makeCtx(
385
- overrides?: Partial<AgentLoopConversationContext> & {
386
- agentLoopRun?: AgentLoopRun;
387
- },
388
- ): AgentLoopConversationContext {
389
- const agentLoopRun =
390
- overrides?.agentLoopRun ??
391
- (async (messages: Message[]) => [
392
- ...messages,
393
- {
394
- role: "assistant" as const,
395
- content: [{ type: "text" as const, text: "response" }],
396
- },
397
- ]);
398
-
399
- return {
400
- conversationId: "test-conv",
401
- messages: [
402
- { role: "user", content: [{ type: "text", text: "Hello" }] },
403
- ] as Message[],
404
- processing: true,
405
- abortController: new AbortController(),
406
- currentRequestId: "test-req",
407
-
408
- agentLoop: {
409
- run: agentLoopRun,
410
- getToolTokenBudget: () => 0,
411
- } as unknown as AgentLoopConversationContext["agentLoop"],
412
- provider: {
413
- name: "mock-provider",
414
- sendMessage: async () => ({
415
- content: [{ type: "text", text: "title" }],
416
- model: "mock",
417
- usage: { inputTokens: 0, outputTokens: 0 },
418
- stopReason: "end_turn",
419
- }),
420
- } as unknown as AgentLoopConversationContext["provider"],
421
- systemPrompt: "system prompt",
422
-
423
- contextWindowManager: {
424
- shouldCompact: () => ({ needed: false, estimatedTokens: 0 }),
425
- maybeCompact: async () => ({ compacted: false }),
426
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
427
- contextCompactedMessageCount: 0,
428
- contextCompactedAt: null,
429
-
430
- memoryPolicy: { scopeId: "default", includeDefaultFallback: true },
431
-
432
- currentActiveSurfaceId: undefined,
433
- currentPage: undefined,
434
- surfaceState: new Map(),
435
- pendingSurfaceActions: new Map(),
436
- surfaceActionRequestIds: new Set<string>(),
437
- currentTurnSurfaces: [],
438
-
439
- workingDir: "/tmp",
440
- workspaceTopLevelContext: null,
441
- workspaceTopLevelDirty: false,
442
- channelCapabilities: undefined,
443
- commandIntent: undefined,
444
- trustContext: undefined,
445
-
446
- coreToolNames: new Set(),
447
- allowedToolNames: undefined,
448
- preactivatedSkillIds: undefined,
449
- skillProjectionState: new Map(),
450
- skillProjectionCache:
451
- new Map() as unknown as AgentLoopConversationContext["skillProjectionCache"],
452
-
453
- traceEmitter: {
454
- emit: () => {},
455
- } as unknown as AgentLoopConversationContext["traceEmitter"],
456
- profiler: {
457
- startRequest: () => {},
458
- emitSummary: () => {},
459
- } as unknown as AgentLoopConversationContext["profiler"],
460
- usageStats: {
461
- totalInputTokens: 0,
462
- totalOutputTokens: 0,
463
- totalEstimatedCost: 0,
464
- model: "",
465
- },
466
- turnCount: 0,
467
-
468
- lastAssistantAttachments: [],
469
- lastAttachmentWarnings: [],
470
-
471
- hasNoClient: false,
472
- streamThinking: false,
473
- prompter: {} as unknown as AgentLoopConversationContext["prompter"],
474
- queue: {} as unknown as AgentLoopConversationContext["queue"],
475
-
476
- getWorkspaceGitService: () => ({ ensureInitialized: async () => {} }),
477
- commitTurnChanges: async () => {},
478
-
479
- refreshWorkspaceTopLevelContextIfNeeded: () => {},
480
- markWorkspaceTopLevelDirty: () => {},
481
- emitActivityState: () => {},
482
- emitConfirmationStateChanged: () => {},
483
- getQueueDepth: () => 0,
484
- hasQueuedMessages: () => false,
485
- canHandoffAtCheckpoint: () => false,
486
- drainQueue: () => {},
487
- getTurnInterfaceContext: () => null,
488
- getTurnChannelContext: () => ({
489
- userMessageChannel: "vellum" as const,
490
- assistantMessageChannel: "vellum" as const,
491
- }),
492
-
493
- toolsDisabledDepth: 0,
494
-
495
- ...overrides,
496
- } as AgentLoopConversationContext;
497
- }
498
-
499
- // ── Tests ────────────────────────────────────────────────────────────
500
-
501
- beforeEach(() => {
502
- insertCompactionEpisodeCalls.length = 0;
503
- mockEstimateTokens = 1000;
504
- mockReducerStepFn = null;
505
- });
506
-
507
- describe("memory episode dual-write from compaction", () => {
508
- test("normal compaction creates a compaction episode", async () => {
509
- const summaryText =
510
- "User discussed project blockers and asked about deployment timeline.";
511
- const compactResult = makeCompactResult(summaryText);
512
-
513
- const ctx = makeCtx({
514
- contextWindowManager: {
515
- shouldCompact: () => ({ needed: true, estimatedTokens: 85000 }),
516
- maybeCompact: async () => compactResult,
517
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
518
- });
519
-
520
- await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
521
-
522
- expect(insertCompactionEpisodeCalls.length).toBe(1);
523
- expect(insertCompactionEpisodeCalls[0]!.conversationId).toBe("test-conv");
524
- expect(insertCompactionEpisodeCalls[0]!.scopeId).toBe("default");
525
- expect(insertCompactionEpisodeCalls[0]!.summary).toBe(summaryText);
526
- expect(insertCompactionEpisodeCalls[0]!.tokenEstimate).toBe(150);
527
- });
528
-
529
- test("overflow (preflight) compaction creates a compaction episode", async () => {
530
- // Make the preflight budget check trigger by returning a high token count
531
- mockEstimateTokens = 200000;
532
-
533
- const summaryText = "Overflow compaction summary of earlier conversation.";
534
- const compactResult = makeCompactResult(summaryText, {
535
- summaryOutputTokens: 200,
536
- });
537
-
538
- // The reducer step must trigger compaction via its compactionResult
539
- mockReducerStepFn = (_msgs: Message[]) => ({
540
- messages: compactResult.messages,
541
- tier: "forced_compaction",
542
- state: {
543
- appliedTiers: [
544
- "forced_compaction",
545
- "tool_result_truncation",
546
- "media_stubbing",
547
- "injection_downgrade",
548
- ],
549
- injectionMode: "full",
550
- exhausted: true,
551
- },
552
- estimatedTokens: 30000,
553
- compactionResult: compactResult,
554
- });
555
-
556
- const ctx = makeCtx({
557
- contextWindowManager: {
558
- shouldCompact: () => ({ needed: false, estimatedTokens: 200000 }),
559
- maybeCompact: async () => ({ compacted: false }),
560
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
561
- });
562
-
563
- await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
564
-
565
- expect(insertCompactionEpisodeCalls.length).toBe(1);
566
- expect(insertCompactionEpisodeCalls[0]!.conversationId).toBe("test-conv");
567
- expect(insertCompactionEpisodeCalls[0]!.summary).toBe(summaryText);
568
- expect(insertCompactionEpisodeCalls[0]!.tokenEstimate).toBe(200);
569
- });
570
-
571
- test("no episode created when compaction does not produce a new summary", async () => {
572
- // Compaction returns compacted: false — no new summary was produced
573
- const ctx = makeCtx({
574
- contextWindowManager: {
575
- shouldCompact: () => ({ needed: false, estimatedTokens: 5000 }),
576
- maybeCompact: async () => ({ compacted: false }),
577
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
578
- });
579
-
580
- await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
581
-
582
- expect(insertCompactionEpisodeCalls.length).toBe(0);
583
- });
584
-
585
- test("episode uses the conversation's memory scope", async () => {
586
- const summaryText = "Scoped compaction summary.";
587
- const compactResult = makeCompactResult(summaryText);
588
-
589
- const ctx = makeCtx({
590
- memoryPolicy: { scopeId: "project-alpha", includeDefaultFallback: false },
591
- contextWindowManager: {
592
- shouldCompact: () => ({ needed: true, estimatedTokens: 85000 }),
593
- maybeCompact: async () => compactResult,
594
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
595
- });
596
-
597
- await runAgentLoopImpl(ctx, "hello", "msg-1", () => {});
598
-
599
- expect(insertCompactionEpisodeCalls.length).toBe(1);
600
- expect(insertCompactionEpisodeCalls[0]!.scopeId).toBe("project-alpha");
601
- });
602
-
603
- test("existing contextSummary persistence is unchanged alongside episode write", async () => {
604
- const events: ServerMessage[] = [];
605
- const summaryText =
606
- "Compaction summary that should be persisted in both places.";
607
- const compactResult = makeCompactResult(summaryText);
608
-
609
- const ctx = makeCtx({
610
- contextWindowManager: {
611
- shouldCompact: () => ({ needed: true, estimatedTokens: 85000 }),
612
- maybeCompact: async () => compactResult,
613
- } as unknown as AgentLoopConversationContext["contextWindowManager"],
614
- });
615
-
616
- await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
617
-
618
- // The context_compacted event should still be emitted (existing behavior)
619
- const compactEvent = events.find((e) => e.type === "context_compacted");
620
- expect(compactEvent).toBeDefined();
621
-
622
- // And the episode should also be created (new dual-write behavior)
623
- expect(insertCompactionEpisodeCalls.length).toBe(1);
624
- expect(insertCompactionEpisodeCalls[0]!.summary).toBe(summaryText);
625
- });
626
- });