@vellumai/assistant 0.5.2 → 0.5.3

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 (108) hide show
  1. package/ARCHITECTURE.md +109 -0
  2. package/docs/skills.md +100 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/conversation-agent-loop-overflow.test.ts +7 -0
  5. package/src/__tests__/conversation-agent-loop.test.ts +7 -0
  6. package/src/__tests__/conversation-memory-dirty-tail.test.ts +150 -0
  7. package/src/__tests__/conversation-provider-retry-repair.test.ts +7 -0
  8. package/src/__tests__/conversation-wipe.test.ts +226 -0
  9. package/src/__tests__/db-memory-archive-migration.test.ts +372 -0
  10. package/src/__tests__/db-memory-brief-state-migration.test.ts +213 -0
  11. package/src/__tests__/db-memory-reducer-checkpoints.test.ts +273 -0
  12. package/src/__tests__/inline-command-runner.test.ts +311 -0
  13. package/src/__tests__/inline-skill-authoring-guard.test.ts +220 -0
  14. package/src/__tests__/inline-skill-load-permissions.test.ts +435 -0
  15. package/src/__tests__/list-messages-attachments.test.ts +96 -0
  16. package/src/__tests__/memory-brief-open-loops.test.ts +530 -0
  17. package/src/__tests__/memory-brief-time.test.ts +285 -0
  18. package/src/__tests__/memory-brief-wrapper.test.ts +311 -0
  19. package/src/__tests__/memory-chunk-archive.test.ts +400 -0
  20. package/src/__tests__/memory-chunk-dual-write.test.ts +453 -0
  21. package/src/__tests__/memory-episode-archive.test.ts +370 -0
  22. package/src/__tests__/memory-episode-dual-write.test.ts +626 -0
  23. package/src/__tests__/memory-observation-archive.test.ts +375 -0
  24. package/src/__tests__/memory-observation-dual-write.test.ts +318 -0
  25. package/src/__tests__/memory-recall-quality.test.ts +2 -2
  26. package/src/__tests__/memory-reducer-store.test.ts +728 -0
  27. package/src/__tests__/memory-reducer-types.test.ts +699 -0
  28. package/src/__tests__/memory-reducer.test.ts +698 -0
  29. package/src/__tests__/memory-regressions.test.ts +6 -4
  30. package/src/__tests__/memory-simplified-config.test.ts +281 -0
  31. package/src/__tests__/parse-identity-fields.test.ts +129 -0
  32. package/src/__tests__/skill-load-inline-command.test.ts +598 -0
  33. package/src/__tests__/skill-load-inline-includes.test.ts +644 -0
  34. package/src/__tests__/skills-inline-command-expansions.test.ts +301 -0
  35. package/src/__tests__/skills-transitive-hash.test.ts +333 -0
  36. package/src/__tests__/vellum-self-knowledge-inline-command.test.ts +320 -0
  37. package/src/__tests__/workspace-migration-backfill-installation-id.test.ts +4 -4
  38. package/src/config/bundled-skills/app-builder/SKILL.md +8 -8
  39. package/src/config/bundled-skills/skill-management/SKILL.md +1 -1
  40. package/src/config/bundled-skills/skill-management/TOOLS.json +2 -2
  41. package/src/config/feature-flag-registry.json +16 -0
  42. package/src/config/loader.ts +1 -0
  43. package/src/config/raw-config-utils.ts +28 -0
  44. package/src/config/schema.ts +12 -0
  45. package/src/config/schemas/memory-simplified.ts +101 -0
  46. package/src/config/schemas/memory.ts +4 -0
  47. package/src/config/skills.ts +50 -4
  48. package/src/daemon/conversation-agent-loop-handlers.ts +8 -3
  49. package/src/daemon/conversation-agent-loop.ts +71 -1
  50. package/src/daemon/conversation-lifecycle.ts +11 -1
  51. package/src/daemon/conversation-runtime-assembly.ts +2 -1
  52. package/src/daemon/conversation-surfaces.ts +31 -8
  53. package/src/daemon/conversation.ts +40 -23
  54. package/src/daemon/handlers/config-embeddings.ts +10 -2
  55. package/src/daemon/handlers/config-model.ts +0 -9
  56. package/src/daemon/handlers/identity.ts +12 -1
  57. package/src/daemon/lifecycle.ts +9 -1
  58. package/src/daemon/message-types/conversations.ts +0 -1
  59. package/src/daemon/server.ts +1 -1
  60. package/src/followups/followup-store.ts +47 -1
  61. package/src/memory/archive-store.ts +400 -0
  62. package/src/memory/brief-formatting.ts +33 -0
  63. package/src/memory/brief-open-loops.ts +266 -0
  64. package/src/memory/brief-time.ts +161 -0
  65. package/src/memory/brief.ts +75 -0
  66. package/src/memory/conversation-crud.ts +245 -101
  67. package/src/memory/db-init.ts +12 -0
  68. package/src/memory/indexer.ts +106 -15
  69. package/src/memory/job-handlers/embedding.test.ts +1 -0
  70. package/src/memory/job-handlers/embedding.ts +83 -0
  71. package/src/memory/job-utils.ts +1 -1
  72. package/src/memory/jobs-store.ts +6 -0
  73. package/src/memory/jobs-worker.ts +12 -0
  74. package/src/memory/migrations/185-memory-brief-state.ts +52 -0
  75. package/src/memory/migrations/186-memory-archive.ts +109 -0
  76. package/src/memory/migrations/187-memory-reducer-checkpoints.ts +19 -0
  77. package/src/memory/migrations/index.ts +3 -0
  78. package/src/memory/qdrant-client.ts +23 -4
  79. package/src/memory/reducer-store.ts +271 -0
  80. package/src/memory/reducer-types.ts +99 -0
  81. package/src/memory/reducer.ts +453 -0
  82. package/src/memory/schema/conversations.ts +3 -0
  83. package/src/memory/schema/index.ts +2 -0
  84. package/src/memory/schema/memory-archive.ts +121 -0
  85. package/src/memory/schema/memory-brief.ts +55 -0
  86. package/src/memory/search/semantic.ts +17 -4
  87. package/src/oauth/oauth-store.ts +3 -1
  88. package/src/permissions/checker.ts +89 -6
  89. package/src/permissions/defaults.ts +14 -0
  90. package/src/runtime/routes/conversation-management-routes.ts +6 -0
  91. package/src/runtime/routes/conversation-query-routes.ts +7 -0
  92. package/src/runtime/routes/conversation-routes.ts +52 -5
  93. package/src/runtime/routes/identity-routes.ts +2 -35
  94. package/src/runtime/routes/llm-context-normalization.ts +14 -1
  95. package/src/runtime/routes/memory-item-routes.ts +90 -5
  96. package/src/runtime/routes/secret-routes.ts +2 -0
  97. package/src/runtime/routes/surface-action-routes.ts +68 -1
  98. package/src/schedule/schedule-store.ts +21 -0
  99. package/src/skills/inline-command-expansions.ts +204 -0
  100. package/src/skills/inline-command-render.ts +127 -0
  101. package/src/skills/inline-command-runner.ts +242 -0
  102. package/src/skills/transitive-version-hash.ts +88 -0
  103. package/src/tasks/task-store.ts +43 -1
  104. package/src/tools/permission-checker.ts +8 -1
  105. package/src/tools/skills/load.ts +140 -6
  106. package/src/util/platform.ts +18 -0
  107. package/src/workspace/migrations/{002-backfill-installation-id.ts → 011-backfill-installation-id.ts} +1 -1
  108. package/src/workspace/migrations/registry.ts +1 -1
@@ -0,0 +1,626 @@
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
+ });