@vellumai/assistant 0.5.5 → 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 (102) hide show
  1. package/Dockerfile +3 -4
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-token-service.test.ts +113 -0
  4. package/src/__tests__/config-schema.test.ts +2 -2
  5. package/src/__tests__/context-window-manager.test.ts +78 -0
  6. package/src/__tests__/conversation-title-service.test.ts +30 -1
  7. package/src/__tests__/docker-signing-key-bootstrap.test.ts +207 -0
  8. package/src/__tests__/memory-regressions.test.ts +8 -30
  9. package/src/__tests__/require-fresh-approval.test.ts +4 -0
  10. package/src/__tests__/tool-executor-lifecycle-events.test.ts +4 -0
  11. package/src/__tests__/tool-executor.test.ts +4 -0
  12. package/src/cli/commands/conversations.ts +0 -18
  13. package/src/config/env.ts +8 -2
  14. package/src/config/feature-flag-registry.json +0 -8
  15. package/src/config/schema.ts +0 -12
  16. package/src/config/schemas/memory.ts +0 -4
  17. package/src/config/schemas/platform.ts +1 -1
  18. package/src/config/schemas/security.ts +4 -0
  19. package/src/context/window-manager.ts +53 -2
  20. package/src/daemon/config-watcher.ts +1 -4
  21. package/src/daemon/conversation-agent-loop.ts +0 -60
  22. package/src/daemon/conversation-memory.ts +0 -117
  23. package/src/daemon/conversation-runtime-assembly.ts +0 -2
  24. package/src/daemon/handlers/conversations.ts +0 -11
  25. package/src/daemon/lifecycle.ts +3 -46
  26. package/src/followups/followup-store.ts +5 -2
  27. package/src/memory/conversation-crud.ts +0 -236
  28. package/src/memory/conversation-title-service.ts +26 -10
  29. package/src/memory/db-init.ts +5 -13
  30. package/src/memory/indexer.ts +15 -106
  31. package/src/memory/job-handlers/embedding.ts +0 -79
  32. package/src/memory/job-utils.ts +1 -1
  33. package/src/memory/jobs-store.ts +0 -8
  34. package/src/memory/jobs-worker.ts +0 -20
  35. package/src/memory/migrations/189-drop-simplified-memory.ts +42 -0
  36. package/src/memory/migrations/index.ts +1 -3
  37. package/src/memory/qdrant-client.ts +4 -6
  38. package/src/memory/schema/conversations.ts +0 -3
  39. package/src/memory/schema/index.ts +0 -2
  40. package/src/messaging/draft-store.ts +2 -2
  41. package/src/permissions/defaults.ts +3 -3
  42. package/src/permissions/trust-client.ts +2 -13
  43. package/src/permissions/trust-store.ts +8 -3
  44. package/src/runtime/auth/route-policy.ts +14 -0
  45. package/src/runtime/auth/token-service.ts +133 -0
  46. package/src/runtime/http-server.ts +2 -0
  47. package/src/runtime/routes/conversation-management-routes.ts +0 -36
  48. package/src/runtime/routes/conversation-query-routes.ts +44 -2
  49. package/src/runtime/routes/conversation-routes.ts +2 -1
  50. package/src/runtime/routes/memory-item-routes.test.ts +221 -3
  51. package/src/runtime/routes/memory-item-routes.ts +124 -2
  52. package/src/runtime/routes/upgrade-broadcast-routes.ts +151 -0
  53. package/src/schedule/schedule-store.ts +0 -21
  54. package/src/skills/inline-command-render.ts +5 -1
  55. package/src/skills/inline-command-runner.ts +30 -2
  56. package/src/tools/memory/handlers.ts +1 -129
  57. package/src/tools/permission-checker.ts +18 -0
  58. package/src/tools/skills/load.ts +9 -2
  59. package/src/util/platform.ts +5 -5
  60. package/src/util/xml.ts +8 -0
  61. package/src/workspace/heartbeat-service.ts +5 -24
  62. package/src/__tests__/archive-recall.test.ts +0 -560
  63. package/src/__tests__/conversation-memory-dirty-tail.test.ts +0 -150
  64. package/src/__tests__/conversation-switch-memory-reduction.test.ts +0 -474
  65. package/src/__tests__/db-memory-archive-migration.test.ts +0 -372
  66. package/src/__tests__/db-memory-brief-state-migration.test.ts +0 -213
  67. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +0 -273
  68. package/src/__tests__/memory-brief-open-loops.test.ts +0 -530
  69. package/src/__tests__/memory-brief-time.test.ts +0 -285
  70. package/src/__tests__/memory-brief-wrapper.test.ts +0 -311
  71. package/src/__tests__/memory-chunk-archive.test.ts +0 -400
  72. package/src/__tests__/memory-chunk-dual-write.test.ts +0 -453
  73. package/src/__tests__/memory-episode-archive.test.ts +0 -370
  74. package/src/__tests__/memory-episode-dual-write.test.ts +0 -626
  75. package/src/__tests__/memory-observation-archive.test.ts +0 -375
  76. package/src/__tests__/memory-observation-dual-write.test.ts +0 -318
  77. package/src/__tests__/memory-reducer-job.test.ts +0 -538
  78. package/src/__tests__/memory-reducer-scheduling.test.ts +0 -473
  79. package/src/__tests__/memory-reducer-store.test.ts +0 -728
  80. package/src/__tests__/memory-reducer-types.test.ts +0 -707
  81. package/src/__tests__/memory-reducer.test.ts +0 -704
  82. package/src/__tests__/memory-simplified-config.test.ts +0 -281
  83. package/src/__tests__/simplified-memory-e2e.test.ts +0 -666
  84. package/src/__tests__/simplified-memory-runtime.test.ts +0 -616
  85. package/src/config/schemas/memory-simplified.ts +0 -101
  86. package/src/memory/archive-recall.ts +0 -516
  87. package/src/memory/archive-store.ts +0 -400
  88. package/src/memory/brief-formatting.ts +0 -33
  89. package/src/memory/brief-open-loops.ts +0 -266
  90. package/src/memory/brief-time.ts +0 -162
  91. package/src/memory/brief.ts +0 -75
  92. package/src/memory/job-handlers/backfill-simplified-memory.ts +0 -462
  93. package/src/memory/job-handlers/reduce-conversation-memory.ts +0 -229
  94. package/src/memory/migrations/185-memory-brief-state.ts +0 -52
  95. package/src/memory/migrations/186-memory-archive.ts +0 -109
  96. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +0 -19
  97. package/src/memory/reducer-scheduler.ts +0 -242
  98. package/src/memory/reducer-store.ts +0 -271
  99. package/src/memory/reducer-types.ts +0 -106
  100. package/src/memory/reducer.ts +0 -467
  101. package/src/memory/schema/memory-archive.ts +0 -121
  102. 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
- });