@vellumai/assistant 0.10.3-staging.2 → 0.10.4-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 (239) hide show
  1. package/openapi.yaml +73 -56
  2. package/package.json +1 -1
  3. package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
  4. package/src/__tests__/assistant-stream-state.test.ts +3 -76
  5. package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
  6. package/src/__tests__/channel-approval-routes.test.ts +21 -26
  7. package/src/__tests__/channel-delivery-store.test.ts +28 -0
  8. package/src/__tests__/channel-guardian.test.ts +82 -32
  9. package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
  10. package/src/__tests__/channel-reply-delivery.test.ts +6 -2
  11. package/src/__tests__/compaction-ledger-store.test.ts +128 -0
  12. package/src/__tests__/config-loader-backfill.test.ts +148 -0
  13. package/src/__tests__/consult-deadline.test.ts +60 -0
  14. package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
  15. package/src/__tests__/contact-store-user-file.test.ts +7 -10
  16. package/src/__tests__/contacts-relay-reads.test.ts +6 -9
  17. package/src/__tests__/contacts-write.test.ts +0 -2
  18. package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
  19. package/src/__tests__/conversation-agent-loop.test.ts +98 -7
  20. package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
  21. package/src/__tests__/conversation-error.test.ts +18 -0
  22. package/src/__tests__/conversation-fork-crud.test.ts +354 -24
  23. package/src/__tests__/conversation-title-service.test.ts +222 -201
  24. package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
  25. package/src/__tests__/delete-propagation.test.ts +5 -3
  26. package/src/__tests__/dm-backfill.test.ts +6 -4
  27. package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
  28. package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
  29. package/src/__tests__/guardian-dispatch.test.ts +50 -5
  30. package/src/__tests__/guardian-routing-state.test.ts +6 -10
  31. package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
  32. package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
  33. package/src/__tests__/helpers/mock-logger.ts +1 -0
  34. package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
  35. package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
  36. package/src/__tests__/invite-redemption-service.test.ts +273 -53
  37. package/src/__tests__/invite-routes-http.test.ts +34 -0
  38. package/src/__tests__/invite-service-ipc.test.ts +65 -2
  39. package/src/__tests__/list-messages-page-latest.test.ts +173 -4
  40. package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
  41. package/src/__tests__/non-member-access-request.test.ts +15 -13
  42. package/src/__tests__/onboarding-persona-write.test.ts +52 -22
  43. package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
  44. package/src/__tests__/persona-resolver.test.ts +75 -45
  45. package/src/__tests__/plugin-bootstrap.test.ts +13 -5
  46. package/src/__tests__/plugin-disabled-state.test.ts +190 -0
  47. package/src/__tests__/provider-usage-tracking.test.ts +1 -1
  48. package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
  49. package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
  50. package/src/__tests__/reaction-persistence.test.ts +51 -4
  51. package/src/__tests__/relay-server.test.ts +88 -31
  52. package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
  53. package/src/__tests__/settings-routes.test.ts +32 -0
  54. package/src/__tests__/slack-block-formatting.test.ts +1 -38
  55. package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
  56. package/src/__tests__/stt-hints.test.ts +6 -3
  57. package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
  58. package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
  59. package/src/__tests__/subagent-role-registry.test.ts +17 -4
  60. package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
  61. package/src/__tests__/subagent-tools.test.ts +398 -3
  62. package/src/__tests__/thread-backfill.test.ts +3 -3
  63. package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
  64. package/src/__tests__/tool-start-timestamp.test.ts +4 -3
  65. package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
  66. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
  67. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
  68. package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
  69. package/src/__tests__/trusted-contact-verification.test.ts +79 -54
  70. package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
  71. package/src/__tests__/voice-invite-redemption.test.ts +183 -20
  72. package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
  73. package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
  74. package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
  75. package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
  76. package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
  77. package/src/agent/loop-exclusive-tool.test.ts +19 -15
  78. package/src/agent/loop-native-web-search.test.ts +200 -0
  79. package/src/agent/loop.ts +108 -1
  80. package/src/api/responses/conversation-message.ts +9 -0
  81. package/src/approvals/guardian-request-resolvers.ts +16 -4
  82. package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
  83. package/src/calls/guardian-dispatch.ts +14 -11
  84. package/src/calls/inbound-trust-reader.ts +7 -1
  85. package/src/calls/relay-access-wait.ts +6 -6
  86. package/src/calls/relay-server.ts +22 -2
  87. package/src/calls/relay-setup-router.ts +10 -10
  88. package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
  89. package/src/cli/commands/contacts.ts +10 -7
  90. package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
  91. package/src/cli/commands/memory/worker.ts +97 -30
  92. package/src/cli/commands/plugins.ts +3 -146
  93. package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
  94. package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
  95. package/src/cli/lib/publish-plugin.ts +231 -1
  96. package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
  97. package/src/config/bundled-skills/subagent/SKILL.md +16 -1
  98. package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
  99. package/src/config/call-site-defaults.ts +0 -6
  100. package/src/config/llm-resolver.ts +0 -3
  101. package/src/config/schemas/call-site-catalog.ts +0 -7
  102. package/src/config/schemas/heartbeat.ts +2 -5
  103. package/src/config/schemas/llm.ts +3 -12
  104. package/src/config/schemas/memory-lifecycle.ts +1 -1
  105. package/src/config/seed-inference-profiles.ts +76 -35
  106. package/src/config/sync-gated-profiles.ts +0 -3
  107. package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
  108. package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
  109. package/src/contacts/contact-store.ts +27 -237
  110. package/src/contacts/contacts-write.ts +18 -58
  111. package/src/contacts/gateway-channel-read.ts +51 -0
  112. package/src/contacts/member-write-relay.ts +25 -31
  113. package/src/contacts/types.ts +3 -15
  114. package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
  115. package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
  116. package/src/daemon/conversation-agent-loop.ts +68 -61
  117. package/src/daemon/conversation-error.ts +7 -10
  118. package/src/daemon/conversation-tool-setup.ts +0 -10
  119. package/src/daemon/conversation.ts +10 -0
  120. package/src/daemon/external-plugins-bootstrap.ts +8 -2
  121. package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
  122. package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
  123. package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
  124. package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
  125. package/src/daemon/handlers/config-channels.ts +14 -29
  126. package/src/daemon/lifecycle.ts +16 -4
  127. package/src/daemon/message-types/surfaces.ts +2 -0
  128. package/src/heartbeat/heartbeat-service.ts +5 -0
  129. package/src/home/relationship-state-writer.ts +5 -0
  130. package/src/memory/__tests__/embedding-cache.test.ts +136 -0
  131. package/src/memory/compaction-ledger-store.ts +107 -0
  132. package/src/memory/conversation-crud.ts +136 -61
  133. package/src/memory/conversation-title-service.ts +173 -24
  134. package/src/memory/embedding-backend.ts +8 -1
  135. package/src/memory/embedding-cache.ts +139 -0
  136. package/src/memory/jobs-worker.ts +75 -29
  137. package/src/memory/memory-retrospective-job.ts +5 -0
  138. package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
  139. package/src/memory/migrations/302-create-compaction-events.ts +107 -0
  140. package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
  141. package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
  142. package/src/memory/schema/contacts.ts +6 -2
  143. package/src/memory/schema/conversations.ts +39 -0
  144. package/src/memory/steps.ts +1090 -367
  145. package/src/memory/worker-control.ts +104 -18
  146. package/src/memory/worker-process.ts +17 -0
  147. package/src/messaging/channel-binding-metadata.ts +31 -0
  148. package/src/messaging/channel-binding-schema.ts +51 -0
  149. package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
  150. package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
  151. package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
  152. package/src/messaging/providers/a2a/deliver.ts +5 -1
  153. package/src/messaging/providers/a2a/transport.ts +10 -0
  154. package/src/messaging/providers/callback-routing.ts +48 -0
  155. package/src/messaging/providers/channel-transport.ts +55 -0
  156. package/src/messaging/providers/index.ts +65 -241
  157. package/src/messaging/providers/slack/binding-metadata.ts +62 -0
  158. package/src/messaging/providers/slack/transport.ts +92 -0
  159. package/src/messaging/providers/telegram-bot/transport.ts +51 -0
  160. package/src/messaging/providers/whatsapp/transport.ts +38 -0
  161. package/src/notifications/__tests__/broadcaster.test.ts +0 -8
  162. package/src/notifications/__tests__/connected-channels.test.ts +8 -36
  163. package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
  164. package/src/notifications/destination-resolver.ts +7 -23
  165. package/src/notifications/emit-signal.ts +5 -11
  166. package/src/plugins/defaults/index.ts +0 -35
  167. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
  168. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
  169. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
  170. package/src/plugins/disabled-state.ts +31 -0
  171. package/src/plugins/registry.ts +55 -12
  172. package/src/prompts/persona-resolver.ts +43 -11
  173. package/src/providers/call-site-routing.ts +41 -0
  174. package/src/providers/provider-send-message.ts +6 -0
  175. package/src/providers/ratelimit.ts +6 -0
  176. package/src/providers/registry.ts +1 -1
  177. package/src/providers/retry.ts +6 -0
  178. package/src/providers/types.ts +13 -0
  179. package/src/providers/usage-tracking.ts +6 -0
  180. package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
  181. package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
  182. package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
  183. package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
  184. package/src/runtime/access-request-helper.ts +1 -2
  185. package/src/runtime/actor-trust-resolver.ts +44 -17
  186. package/src/runtime/anchored-guardian.test.ts +7 -54
  187. package/src/runtime/anchored-guardian.ts +4 -53
  188. package/src/runtime/assistant-stream-state.ts +12 -74
  189. package/src/runtime/channel-reply-delivery.ts +3 -8
  190. package/src/runtime/guardian-vellum-migration.ts +18 -16
  191. package/src/runtime/invite-redemption-service.ts +25 -10
  192. package/src/runtime/local-actor-identity.test.ts +108 -0
  193. package/src/runtime/local-actor-identity.ts +27 -20
  194. package/src/runtime/member-verdict-cache.ts +0 -0
  195. package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
  196. package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
  197. package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
  198. package/src/runtime/routes/contact-routes.ts +40 -25
  199. package/src/runtime/routes/conversation-list-routes.ts +1 -29
  200. package/src/runtime/routes/conversation-routes.ts +27 -7
  201. package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
  202. package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
  203. package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
  204. package/src/runtime/routes/settings-routes.ts +8 -3
  205. package/src/runtime/services/conversation-serializer.ts +6 -49
  206. package/src/runtime/slack-block-formatting.ts +0 -15
  207. package/src/runtime/trust-verdict-consumer.ts +36 -41
  208. package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
  209. package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
  210. package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
  211. package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
  212. package/src/subagent/index.ts +1 -1
  213. package/src/subagent/manager.ts +245 -33
  214. package/src/subagent/types.ts +8 -1
  215. package/src/tools/registry.ts +10 -3
  216. package/src/tools/subagent/consult-deadline.ts +49 -0
  217. package/src/tools/subagent/spawn.ts +234 -5
  218. package/src/util/logger.ts +9 -0
  219. package/src/util/platform.ts +14 -0
  220. package/src/workspace/migrations/031-drop-user-md.ts +232 -148
  221. package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
  222. package/src/workspace/migrations/registry.ts +2 -0
  223. package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
  224. package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
  225. package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
  226. package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
  227. package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
  228. package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
  229. package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
  230. package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
  231. package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
  232. package/src/plugins/defaults/advisor/config.ts +0 -21
  233. package/src/plugins/defaults/advisor/consult.ts +0 -197
  234. package/src/plugins/defaults/advisor/context-pack.ts +0 -288
  235. package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
  236. package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
  237. package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
  238. package/src/plugins/defaults/advisor/package.json +0 -14
  239. package/src/plugins/defaults/advisor/tools/advisor.ts +0 -92
@@ -31,7 +31,6 @@ mock.module("../daemon/handlers/shared.js", () => ({
31
31
 
32
32
  import { eq } from "drizzle-orm";
33
33
 
34
- import { upsertContact } from "../contacts/contact-store.js";
35
34
  import { getDb } from "../memory/db-connection.js";
36
35
  import { initializeDb } from "../memory/db-init.js";
37
36
  import * as deliveryChannels from "../memory/delivery-channels.js";
@@ -39,7 +38,10 @@ import { resetTestTables } from "../memory/raw-query.js";
39
38
  import { attachments, conversationAttentionEvents } from "../memory/schema.js";
40
39
  import * as pendingInteractions from "../runtime/pending-interactions.js";
41
40
  import { resetDbForTesting } from "./db-test-helpers.js";
42
- import { handleChannelInbound } from "./helpers/channel-test-adapter.js";
41
+ import {
42
+ handleChannelInbound,
43
+ seedContactChannel,
44
+ } from "./helpers/channel-test-adapter.js";
43
45
 
44
46
  await initializeDb();
45
47
 
@@ -69,16 +71,12 @@ function resetTables(): void {
69
71
  }
70
72
 
71
73
  function ensureTestContact(): void {
72
- upsertContact({
74
+ seedContactChannel({
75
+ sourceChannel: "telegram",
76
+ externalUserId: "telegram-user-default",
73
77
  displayName: "Test User",
74
- channels: [
75
- {
76
- type: "telegram",
77
- address: "telegram-user-default",
78
- status: "active",
79
- policy: "allow",
80
- },
81
- ],
78
+ status: "active",
79
+ policy: "allow",
82
80
  });
83
81
  }
84
82
 
@@ -623,6 +623,24 @@ describe("classifyConversationError", () => {
623
623
  expect(result.errorCategory).toBe("provider_invalid_key");
624
624
  });
625
625
 
626
+ it("classifies managed-proxy auth failures as managed credential refresh failures", () => {
627
+ providerRoutingSources.anthropic = "managed-proxy";
628
+ const err = new ProviderError(
629
+ 'Anthropic API error (403): {"detail":"API key has expired."}',
630
+ "anthropic",
631
+ 403,
632
+ );
633
+
634
+ const result = classifyConversationError(err, baseCtx);
635
+
636
+ expect(result.code).toBe("MANAGED_KEY_INVALID");
637
+ expect(result.userMessage).toBe(
638
+ "Couldn't refresh assistant credentials.",
639
+ );
640
+ expect(result.retryable).toBe(false);
641
+ expect(result.errorCategory).toBe("managed_key_invalid");
642
+ });
643
+
626
644
  it("classifies ProviderError 401 with 'invalid x-api-key' message as PROVIDER_INVALID_KEY", () => {
627
645
  // Regex-match branch — Anthropic's standard 401 wording.
628
646
  const err = new ProviderError(
@@ -26,6 +26,7 @@ import {
26
26
  linkAttachmentToMessage,
27
27
  uploadAttachment,
28
28
  } from "../memory/attachments-store.js";
29
+ import { appendCompactionEvent } from "../memory/compaction-ledger-store.js";
29
30
  import {
30
31
  getAttentionStateByConversationIds,
31
32
  markConversationUnread,
@@ -54,6 +55,7 @@ import {
54
55
  activationState,
55
56
  channelInboundEvents,
56
57
  conversationAssistantAttentionState,
58
+ conversationCompactionEvents,
57
59
  conversationGraphMemoryState,
58
60
  conversations,
59
61
  externalConversationBindings,
@@ -78,6 +80,7 @@ function resetTables(): void {
78
80
  db.delete(externalConversationBindings).run();
79
81
  db.delete(conversationAssistantAttentionState).run();
80
82
  db.delete(activationState).run();
83
+ db.delete(conversationCompactionEvents).run();
81
84
  db.delete(conversationGraphMemoryState).run();
82
85
  db.delete(memoryRetrospectiveState).run();
83
86
  getLogsDb()!.delete(llmRequestLogs).run();
@@ -346,24 +349,37 @@ describe("forkConversation", () => {
346
349
  expect(toolResultUserRow.id).not.toBe(anchor.id);
347
350
  });
348
351
 
349
- test("preserves compacted context when forking from the visible window", async () => {
352
+ test("inherits the most recent compaction at-or-before the forked-from message", async () => {
350
353
  const source = createConversation("Compacted thread");
351
- await addMessage(source.id, "user", "Message 1", {
354
+ const m1 = await addMessage(source.id, "user", "Message 1", {
352
355
  skipIndexing: true,
353
356
  });
354
- await addMessage(source.id, "assistant", "Message 2", {
357
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
355
358
  skipIndexing: true,
356
359
  });
357
360
  const branchPoint = await addMessage(source.id, "user", "Message 3", {
358
361
  skipIndexing: true,
359
362
  });
360
- await addMessage(source.id, "assistant", "Message 4", {
363
+ const m4 = await addMessage(source.id, "assistant", "Message 4", {
361
364
  skipIndexing: true,
362
365
  });
363
366
 
364
- const compactedAt = Date.now();
365
- getDb()
366
- .update(conversations)
367
+ // Pin timestamps so the compaction event sits strictly between M2 and M3:
368
+ // it covers M1+M2 and ran before M3 was sent.
369
+ const db = getDb();
370
+ const base = Date.now();
371
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
372
+ db.run(
373
+ `UPDATE messages SET created_at = ${base + 1} WHERE id = '${m2.id}'`,
374
+ );
375
+ db.run(
376
+ `UPDATE messages SET created_at = ${base + 3} WHERE id = '${branchPoint.id}'`,
377
+ );
378
+ db.run(
379
+ `UPDATE messages SET created_at = ${base + 4} WHERE id = '${m4.id}'`,
380
+ );
381
+ const compactedAt = base + 2;
382
+ db.update(conversations)
367
383
  .set({
368
384
  contextSummary: "Compacted summary",
369
385
  contextCompactedMessageCount: 2,
@@ -371,7 +387,14 @@ describe("forkConversation", () => {
371
387
  })
372
388
  .where(eq(conversations.id, source.id))
373
389
  .run();
390
+ appendCompactionEvent(source.id, {
391
+ compactedAt,
392
+ summary: "Compacted summary",
393
+ compactedMessageCount: 2,
394
+ });
374
395
 
396
+ // M3 was sent after the compaction, so forking through it reproduces the
397
+ // compacted working context.
375
398
  const fork = forkConversation({
376
399
  conversationId: source.id,
377
400
  throughMessageId: branchPoint.id,
@@ -384,6 +407,67 @@ describe("forkConversation", () => {
384
407
  expect(fork.forkParentMessageId).toBe(branchPoint.id);
385
408
  });
386
409
 
410
+ test("does not inherit a compaction that ran after the forked-from message", async () => {
411
+ const source = createConversation("Compacted thread");
412
+ const m1 = await addMessage(source.id, "user", "Message 1", {
413
+ skipIndexing: true,
414
+ });
415
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
416
+ skipIndexing: true,
417
+ });
418
+ const m3 = await addMessage(source.id, "user", "Message 3", {
419
+ skipIndexing: true,
420
+ });
421
+ const m4 = await addMessage(source.id, "assistant", "Message 4", {
422
+ skipIndexing: true,
423
+ });
424
+
425
+ // `/compact` ran after M4 — the compaction postdates every message.
426
+ const db = getDb();
427
+ const base = Date.now();
428
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
429
+ db.run(
430
+ `UPDATE messages SET created_at = ${base + 1} WHERE id = '${m2.id}'`,
431
+ );
432
+ db.run(
433
+ `UPDATE messages SET created_at = ${base + 2} WHERE id = '${m3.id}'`,
434
+ );
435
+ db.run(
436
+ `UPDATE messages SET created_at = ${base + 3} WHERE id = '${m4.id}'`,
437
+ );
438
+ const compactedAt = base + 10;
439
+ db.update(conversations)
440
+ .set({
441
+ contextSummary: "Compacted summary",
442
+ contextCompactedMessageCount: 2,
443
+ contextCompactedAt: compactedAt,
444
+ })
445
+ .where(eq(conversations.id, source.id))
446
+ .run();
447
+ appendCompactionEvent(source.id, {
448
+ compactedAt,
449
+ summary: "Compacted summary",
450
+ compactedMessageCount: 2,
451
+ });
452
+
453
+ // Forking through the final message yields the full uncompacted history:
454
+ // the compaction did not exist when M4 was the latest turn.
455
+ const fork = forkConversation({
456
+ conversationId: source.id,
457
+ throughMessageId: m4.id,
458
+ });
459
+
460
+ expect(fork.contextSummary).toBeNull();
461
+ expect(fork.contextCompactedMessageCount).toBe(0);
462
+ expect(fork.contextCompactedAt).toBeNull();
463
+ expect(getMessages(fork.id).map((message) => message.content)).toEqual([
464
+ "Message 1",
465
+ "Message 2",
466
+ "Message 3",
467
+ "Message 4",
468
+ ]);
469
+ });
470
+
387
471
  test("forks from the compacted-away prefix without inheriting source compaction state", async () => {
388
472
  const source = createConversation("Compacted thread");
389
473
  const compactedBranchPoint = await addMessage(
@@ -399,15 +483,21 @@ describe("forkConversation", () => {
399
483
  skipIndexing: true,
400
484
  });
401
485
 
486
+ const compactedAt = Date.now();
402
487
  getDb()
403
488
  .update(conversations)
404
489
  .set({
405
490
  contextSummary: "Compacted summary",
406
491
  contextCompactedMessageCount: 2,
407
- contextCompactedAt: Date.now(),
492
+ contextCompactedAt: compactedAt,
408
493
  })
409
494
  .where(eq(conversations.id, source.id))
410
495
  .run();
496
+ appendCompactionEvent(source.id, {
497
+ compactedAt,
498
+ summary: "Compacted summary",
499
+ compactedMessageCount: 2,
500
+ });
411
501
 
412
502
  const fork = forkConversation({
413
503
  conversationId: source.id,
@@ -1000,35 +1090,70 @@ describe("forkConversation", () => {
1000
1090
 
1001
1091
  test("truncated fork ignores attachments behind an inherited compaction boundary", async () => {
1002
1092
  const source = createConversation("Compacted truncated thread");
1003
- await addMessage(source.id, "user", "compacted turn", {
1004
- metadata: {
1005
- memoryInjectedBlock:
1006
- "# memory/concepts/topics/page-compacted.md\nOld summary",
1093
+ const compactedTurn = await addMessage(
1094
+ source.id,
1095
+ "user",
1096
+ "compacted turn",
1097
+ {
1098
+ metadata: {
1099
+ memoryInjectedBlock:
1100
+ "# memory/concepts/topics/page-compacted.md\nOld summary",
1101
+ },
1102
+ skipIndexing: true,
1007
1103
  },
1008
- skipIndexing: true,
1009
- });
1010
- await addMessage(source.id, "assistant", "compacted reply", {
1011
- skipIndexing: true,
1012
- });
1104
+ );
1105
+ const compactedReply = await addMessage(
1106
+ source.id,
1107
+ "assistant",
1108
+ "compacted reply",
1109
+ { skipIndexing: true },
1110
+ );
1013
1111
  const boundaryMessage = await addMessage(source.id, "user", "live turn", {
1014
1112
  metadata: {
1015
1113
  memoryInjectedBlock: "# memory/concepts/topics/page-live.md\nSummary",
1016
1114
  },
1017
1115
  skipIndexing: true,
1018
1116
  });
1019
- await addMessage(source.id, "assistant", "live reply", {
1117
+ const liveReply = await addMessage(source.id, "assistant", "live reply", {
1020
1118
  skipIndexing: true,
1021
1119
  });
1022
- await addMessage(source.id, "user", "past boundary", {
1120
+ const pastBoundary = await addMessage(source.id, "user", "past boundary", {
1023
1121
  skipIndexing: true,
1024
1122
  });
1025
- // First two messages sit behind the compaction boundary: their
1026
- // attachments are not rendered, so the fork must not claim them.
1027
- getDb()
1028
- .update(conversations)
1029
- .set({ contextCompactedMessageCount: 2 })
1123
+ // First two messages sit behind a compaction that ran before the live
1124
+ // turn: their injected blocks are not rendered, so the fork must not
1125
+ // claim them.
1126
+ const db = getDb();
1127
+ const base = Date.now();
1128
+ db.run(
1129
+ `UPDATE messages SET created_at = ${base} WHERE id = '${compactedTurn.id}'`,
1130
+ );
1131
+ db.run(
1132
+ `UPDATE messages SET created_at = ${base + 1} WHERE id = '${compactedReply.id}'`,
1133
+ );
1134
+ db.run(
1135
+ `UPDATE messages SET created_at = ${base + 3} WHERE id = '${boundaryMessage.id}'`,
1136
+ );
1137
+ db.run(
1138
+ `UPDATE messages SET created_at = ${base + 4} WHERE id = '${liveReply.id}'`,
1139
+ );
1140
+ db.run(
1141
+ `UPDATE messages SET created_at = ${base + 5} WHERE id = '${pastBoundary.id}'`,
1142
+ );
1143
+ const compactedAt = base + 2;
1144
+ db.update(conversations)
1145
+ .set({
1146
+ contextSummary: "Compacted summary",
1147
+ contextCompactedMessageCount: 2,
1148
+ contextCompactedAt: compactedAt,
1149
+ })
1030
1150
  .where(eq(conversations.id, source.id))
1031
1151
  .run();
1152
+ appendCompactionEvent(source.id, {
1153
+ compactedAt,
1154
+ summary: "Compacted summary",
1155
+ compactedMessageCount: 2,
1156
+ });
1032
1157
 
1033
1158
  const fork = forkConversation({
1034
1159
  conversationId: source.id,
@@ -1382,4 +1507,209 @@ describe("forkConversation + memory_retrospective_state", () => {
1382
1507
 
1383
1508
  expect(getRetrospectiveState(fork.id)?.lastRunAt).toBe(1_700_000_000_000);
1384
1509
  });
1510
+
1511
+ test("inherits the earlier of two compactions when forking between them", async () => {
1512
+ const source = createConversation("Twice-compacted thread");
1513
+ const m1 = await addMessage(source.id, "user", "Message 1", {
1514
+ skipIndexing: true,
1515
+ });
1516
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
1517
+ skipIndexing: true,
1518
+ });
1519
+ const m3 = await addMessage(source.id, "user", "Message 3", {
1520
+ skipIndexing: true,
1521
+ });
1522
+ const m4 = await addMessage(source.id, "assistant", "Message 4", {
1523
+ skipIndexing: true,
1524
+ });
1525
+
1526
+ const db = getDb();
1527
+ const base = Date.now();
1528
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
1529
+ db.run(
1530
+ `UPDATE messages SET created_at = ${base + 2} WHERE id = '${m2.id}'`,
1531
+ );
1532
+ db.run(
1533
+ `UPDATE messages SET created_at = ${base + 4} WHERE id = '${m3.id}'`,
1534
+ );
1535
+ db.run(
1536
+ `UPDATE messages SET created_at = ${base + 6} WHERE id = '${m4.id}'`,
1537
+ );
1538
+ // C1 ran after M1 (covers 1 message); C2 ran after M3 (covers 3).
1539
+ appendCompactionEvent(source.id, {
1540
+ compactedAt: base + 1,
1541
+ summary: "Summary 1",
1542
+ compactedMessageCount: 1,
1543
+ });
1544
+ appendCompactionEvent(source.id, {
1545
+ compactedAt: base + 5,
1546
+ summary: "Summary 2",
1547
+ compactedMessageCount: 3,
1548
+ });
1549
+
1550
+ // Forking through M3 lands between the two compactions, so it inherits the
1551
+ // earlier one — the capability a single stored pointer cannot provide.
1552
+ const fork = forkConversation({
1553
+ conversationId: source.id,
1554
+ throughMessageId: m3.id,
1555
+ });
1556
+ expect(fork.contextSummary).toBe("Summary 1");
1557
+ expect(fork.contextCompactedMessageCount).toBe(1);
1558
+ expect(fork.contextCompactedAt).toBe(base + 1);
1559
+ });
1560
+
1561
+ test("carries a ledger into the fork so re-forks resolve compaction", async () => {
1562
+ const source = createConversation("Re-fork thread");
1563
+ const m1 = await addMessage(source.id, "user", "Message 1", {
1564
+ skipIndexing: true,
1565
+ });
1566
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
1567
+ skipIndexing: true,
1568
+ });
1569
+ const m3 = await addMessage(source.id, "user", "Message 3", {
1570
+ skipIndexing: true,
1571
+ });
1572
+
1573
+ const db = getDb();
1574
+ const base = Date.now();
1575
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
1576
+ db.run(
1577
+ `UPDATE messages SET created_at = ${base + 1} WHERE id = '${m2.id}'`,
1578
+ );
1579
+ db.run(
1580
+ `UPDATE messages SET created_at = ${base + 3} WHERE id = '${m3.id}'`,
1581
+ );
1582
+ const compactedAt = base + 2; // covers M1+M2, before M3
1583
+ db.update(conversations)
1584
+ .set({
1585
+ contextSummary: "Compacted summary",
1586
+ contextCompactedMessageCount: 2,
1587
+ contextCompactedAt: compactedAt,
1588
+ })
1589
+ .where(eq(conversations.id, source.id))
1590
+ .run();
1591
+ appendCompactionEvent(source.id, {
1592
+ compactedAt,
1593
+ summary: "Compacted summary",
1594
+ compactedMessageCount: 2,
1595
+ });
1596
+
1597
+ const fork = forkConversation({ conversationId: source.id });
1598
+ expect(fork.contextCompactedMessageCount).toBe(2);
1599
+
1600
+ // The fork owns a copy of the ledger, so a re-fork resolves the inherited
1601
+ // compaction without walking back to the original source.
1602
+ const reFork = forkConversation({ conversationId: fork.id });
1603
+ expect(reFork.contextSummary).toBe("Compacted summary");
1604
+ expect(reFork.contextCompactedMessageCount).toBe(2);
1605
+ });
1606
+
1607
+ test("drops the stale Slack watermark when forking inherits an older compaction", async () => {
1608
+ const source = createConversation("Slack twice-compacted thread");
1609
+ const m1 = await addMessage(source.id, "user", "Message 1", {
1610
+ skipIndexing: true,
1611
+ });
1612
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
1613
+ skipIndexing: true,
1614
+ });
1615
+ const m3 = await addMessage(source.id, "user", "Message 3", {
1616
+ skipIndexing: true,
1617
+ });
1618
+ const m4 = await addMessage(source.id, "assistant", "Message 4", {
1619
+ skipIndexing: true,
1620
+ });
1621
+
1622
+ const db = getDb();
1623
+ const base = Date.now();
1624
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
1625
+ db.run(
1626
+ `UPDATE messages SET created_at = ${base + 2} WHERE id = '${m2.id}'`,
1627
+ );
1628
+ db.run(
1629
+ `UPDATE messages SET created_at = ${base + 4} WHERE id = '${m3.id}'`,
1630
+ );
1631
+ db.run(
1632
+ `UPDATE messages SET created_at = ${base + 6} WHERE id = '${m4.id}'`,
1633
+ );
1634
+ appendCompactionEvent(source.id, {
1635
+ compactedAt: base + 1,
1636
+ summary: "Summary 1",
1637
+ compactedMessageCount: 1,
1638
+ });
1639
+ appendCompactionEvent(source.id, {
1640
+ compactedAt: base + 5,
1641
+ summary: "Summary 2",
1642
+ compactedMessageCount: 3,
1643
+ });
1644
+ // The source's single-valued watermark reflects only the latest compaction.
1645
+ db.update(conversations)
1646
+ .set({
1647
+ contextSummary: "Summary 2",
1648
+ contextCompactedMessageCount: 3,
1649
+ contextCompactedAt: base + 5,
1650
+ slackContextCompactionWatermarkTs: "ts-latest",
1651
+ slackContextCompactionWatermarkAt: base + 5,
1652
+ })
1653
+ .where(eq(conversations.id, source.id))
1654
+ .run();
1655
+
1656
+ // Forking through M3 inherits the OLDER compaction (Summary 1); the latest
1657
+ // watermark must not ride along, or it would hide Slack messages the older
1658
+ // summary does not cover.
1659
+ const fork = forkConversation({
1660
+ conversationId: source.id,
1661
+ throughMessageId: m3.id,
1662
+ });
1663
+ expect(fork.contextSummary).toBe("Summary 1");
1664
+ expect(fork.contextCompactedMessageCount).toBe(1);
1665
+ expect(fork.slackContextCompactionWatermarkTs).toBeNull();
1666
+ expect(fork.slackContextCompactionWatermarkAt).toBeNull();
1667
+ });
1668
+
1669
+ test("carries the Slack watermark when forking inherits the latest compaction", async () => {
1670
+ const source = createConversation("Slack compacted thread");
1671
+ const m1 = await addMessage(source.id, "user", "Message 1", {
1672
+ skipIndexing: true,
1673
+ });
1674
+ const m2 = await addMessage(source.id, "assistant", "Message 2", {
1675
+ skipIndexing: true,
1676
+ });
1677
+ const m3 = await addMessage(source.id, "user", "Message 3", {
1678
+ skipIndexing: true,
1679
+ });
1680
+
1681
+ const db = getDb();
1682
+ const base = Date.now();
1683
+ db.run(`UPDATE messages SET created_at = ${base} WHERE id = '${m1.id}'`);
1684
+ db.run(
1685
+ `UPDATE messages SET created_at = ${base + 1} WHERE id = '${m2.id}'`,
1686
+ );
1687
+ db.run(
1688
+ `UPDATE messages SET created_at = ${base + 3} WHERE id = '${m3.id}'`,
1689
+ );
1690
+ const compactedAt = base + 2; // latest (and only) compaction, covers M1+M2
1691
+ appendCompactionEvent(source.id, {
1692
+ compactedAt,
1693
+ summary: "Compacted summary",
1694
+ compactedMessageCount: 2,
1695
+ });
1696
+ db.update(conversations)
1697
+ .set({
1698
+ contextSummary: "Compacted summary",
1699
+ contextCompactedMessageCount: 2,
1700
+ contextCompactedAt: compactedAt,
1701
+ slackContextCompactionWatermarkTs: "ts-latest",
1702
+ slackContextCompactionWatermarkAt: compactedAt,
1703
+ })
1704
+ .where(eq(conversations.id, source.id))
1705
+ .run();
1706
+
1707
+ const fork = forkConversation({
1708
+ conversationId: source.id,
1709
+ throughMessageId: m3.id,
1710
+ });
1711
+ expect(fork.contextCompactedMessageCount).toBe(2);
1712
+ expect(fork.slackContextCompactionWatermarkTs).toBe("ts-latest");
1713
+ expect(fork.slackContextCompactionWatermarkAt).toBe(compactedAt);
1714
+ });
1385
1715
  });