@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
@@ -1426,9 +1426,156 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
1426
1426
 
1427
1427
  expect(config.llm.profiles.balanced?.status).toBe("disabled");
1428
1428
  expect(config.llm.profiles["quality-optimized"]?.status).toBe("disabled");
1429
+ expect(config.llm.profiles.frontier?.status).toBe("disabled");
1429
1430
  expect(config.llm.profiles["cost-optimized"]?.status).toBe("disabled");
1430
1431
  });
1431
1432
 
1433
+ test("off-platform BYOK hatch defaults advisor to the personal quality profile", () => {
1434
+ const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1435
+ writeFileSync(
1436
+ overlayPath,
1437
+ JSON.stringify({ llm: { default: { provider: "anthropic" } } }, null, 2) +
1438
+ "\n",
1439
+ );
1440
+ process.env.VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH = overlayPath;
1441
+
1442
+ mergeDefaultConfigAndSeedInferenceProfiles();
1443
+ const config = loadConfig();
1444
+
1445
+ expect(config.llm.activeProfile).toBe("custom-balanced");
1446
+ expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
1447
+ expect(config.llm.profiles["custom-quality-optimized"]?.provider).toBe(
1448
+ "anthropic",
1449
+ );
1450
+ expect(
1451
+ config.llm.profiles["custom-quality-optimized"]?.provider_connection,
1452
+ ).toBe("anthropic-personal");
1453
+ expect(config.llm.profiles.frontier?.status).toBe("disabled");
1454
+ });
1455
+
1456
+ test("off-platform boot repairs a disabled managed advisor to a personal profile when no active managed replacement exists", () => {
1457
+ writeConfig({
1458
+ llm: {
1459
+ advisorProfile: "frontier",
1460
+ profiles: {
1461
+ frontier: {
1462
+ source: "managed",
1463
+ provider: "anthropic",
1464
+ provider_connection: "anthropic-managed",
1465
+ model: "claude-opus-4-8",
1466
+ status: "disabled",
1467
+ },
1468
+ balanced: {
1469
+ source: "managed",
1470
+ provider: "together",
1471
+ provider_connection: "together-managed",
1472
+ model: "open-model",
1473
+ status: "disabled",
1474
+ },
1475
+ "quality-optimized": {
1476
+ source: "managed",
1477
+ provider: "fireworks",
1478
+ provider_connection: "fireworks-managed",
1479
+ model: "accounts/fireworks/models/glm-5p2",
1480
+ status: "disabled",
1481
+ },
1482
+ "cost-optimized": {
1483
+ source: "managed",
1484
+ provider: "fireworks",
1485
+ provider_connection: "fireworks-managed",
1486
+ model: "accounts/fireworks/models/deepseek-v4-flash",
1487
+ status: "disabled",
1488
+ },
1489
+ "custom-quality-optimized": {
1490
+ source: "user",
1491
+ provider: "anthropic",
1492
+ provider_connection: "anthropic-personal",
1493
+ model: "claude-opus-4-8",
1494
+ label: "Quality",
1495
+ },
1496
+ },
1497
+ },
1498
+ });
1499
+
1500
+ mergeDefaultConfigAndSeedInferenceProfiles();
1501
+ const config = loadConfig();
1502
+
1503
+ expect(config.llm.advisorProfile).toBe("custom-quality-optimized");
1504
+ });
1505
+
1506
+ test("platform boot repairs a disabled managed advisor to an active managed profile", () => {
1507
+ process.env.IS_PLATFORM = "true";
1508
+ writeConfig({
1509
+ llm: {
1510
+ advisorProfile: "frontier",
1511
+ profiles: {
1512
+ frontier: {
1513
+ source: "managed",
1514
+ provider: "anthropic",
1515
+ provider_connection: "anthropic-managed",
1516
+ model: "claude-opus-4-8",
1517
+ status: "disabled",
1518
+ },
1519
+ "custom-quality-optimized": {
1520
+ source: "user",
1521
+ provider: "anthropic",
1522
+ provider_connection: "anthropic-personal",
1523
+ model: "claude-opus-4-8",
1524
+ label: "Quality",
1525
+ },
1526
+ },
1527
+ },
1528
+ });
1529
+
1530
+ mergeDefaultConfigAndSeedInferenceProfiles();
1531
+ const config = loadConfig();
1532
+
1533
+ expect(config.llm.advisorProfile).toBe("quality-optimized");
1534
+ });
1535
+
1536
+ test("off-platform boot clears a disabled managed advisor when no active replacement exists", () => {
1537
+ writeConfig({
1538
+ llm: {
1539
+ advisorProfile: "frontier",
1540
+ profiles: {
1541
+ frontier: {
1542
+ source: "managed",
1543
+ provider: "anthropic",
1544
+ provider_connection: "anthropic-managed",
1545
+ model: "claude-opus-4-8",
1546
+ status: "disabled",
1547
+ },
1548
+ balanced: {
1549
+ source: "managed",
1550
+ provider: "together",
1551
+ provider_connection: "together-managed",
1552
+ model: "open-model",
1553
+ status: "disabled",
1554
+ },
1555
+ "quality-optimized": {
1556
+ source: "managed",
1557
+ provider: "fireworks",
1558
+ provider_connection: "fireworks-managed",
1559
+ model: "accounts/fireworks/models/glm-5p2",
1560
+ status: "disabled",
1561
+ },
1562
+ "cost-optimized": {
1563
+ source: "managed",
1564
+ provider: "fireworks",
1565
+ provider_connection: "fireworks-managed",
1566
+ model: "accounts/fireworks/models/deepseek-v4-flash",
1567
+ status: "disabled",
1568
+ },
1569
+ },
1570
+ },
1571
+ });
1572
+
1573
+ mergeDefaultConfigAndSeedInferenceProfiles();
1574
+ const config = loadConfig();
1575
+
1576
+ expect(config.llm.advisorProfile).toBeUndefined();
1577
+ });
1578
+
1432
1579
  test("off-platform managed-inference hatch keeps selected managed connection active", () => {
1433
1580
  const overlayPath = join(WORKSPACE_DIR, "hatch-overlay.json");
1434
1581
  writeFileSync(
@@ -1451,6 +1598,7 @@ describe("seedInferenceProfiles BYOK-mode managed profile labels", () => {
1451
1598
  const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
1452
1599
 
1453
1600
  expect(raw.llm.activeProfile).toBe("balanced");
1601
+ expect(raw.llm.advisorProfile).toBe("balanced");
1454
1602
  expect(raw.llm.profiles.balanced.provider_connection).toBe(
1455
1603
  "together-managed",
1456
1604
  );
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Tests for `createConsultDeadline` — the advisor consult's progress-aware
3
+ * timeout. Uses short real timers with generous (>=3x) margins so a streamed
4
+ * chunk reliably lands inside the idle window without timing flakiness.
5
+ */
6
+ import { describe, expect, test } from "bun:test";
7
+
8
+ import { createConsultDeadline } from "../tools/subagent/consult-deadline.js";
9
+
10
+ const delay = (ms: number): Promise<void> =>
11
+ new Promise((resolve) => setTimeout(resolve, ms));
12
+
13
+ describe("createConsultDeadline", () => {
14
+ test("streamed progress keeps the consult alive past the idle window", async () => {
15
+ // Record progress every 25ms against a 150ms idle window (6x margin). Each
16
+ // chunk resets the window, so it must never abort over ~200ms of streaming.
17
+ const d = createConsultDeadline({ idleMs: 150, maxMs: 10_000 });
18
+ try {
19
+ for (let i = 0; i < 8; i++) {
20
+ await delay(25);
21
+ expect(d.signal.aborted).toBe(false);
22
+ d.recordProgress();
23
+ }
24
+ } finally {
25
+ d.dispose();
26
+ }
27
+ });
28
+
29
+ test("aborts after the idle window once progress stops", async () => {
30
+ const d = createConsultDeadline({ idleMs: 50, maxMs: 10_000 });
31
+ try {
32
+ expect(d.signal.aborted).toBe(false);
33
+ await delay(250); // 5x the idle window with no progress
34
+ expect(d.signal.aborted).toBe(true);
35
+ } finally {
36
+ d.dispose();
37
+ }
38
+ });
39
+
40
+ test("aborts at the absolute max even under steady progress", async () => {
41
+ // Idle window can't fire (huge); the short max must, despite steady chunks.
42
+ const d = createConsultDeadline({ idleMs: 10_000, maxMs: 70 });
43
+ try {
44
+ for (let i = 0; i < 8; i++) {
45
+ await delay(25);
46
+ d.recordProgress();
47
+ }
48
+ expect(d.signal.aborted).toBe(true); // ~200ms elapsed > 70ms max
49
+ } finally {
50
+ d.dispose();
51
+ }
52
+ });
53
+
54
+ test("dispose() cancels both timers — no late abort", async () => {
55
+ const d = createConsultDeadline({ idleMs: 40, maxMs: 40 });
56
+ d.dispose();
57
+ await delay(150); // well past both windows
58
+ expect(d.signal.aborted).toBe(false);
59
+ });
60
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * INFO telemetry (interactionCount/lastInteraction/lastSeenAt) is local, not
3
+ * ACL: the gateway's handle-inbound mirror writes it to the assistant DB, and
4
+ * model-facing turn context reads it back. These tests assert the daemon-native
5
+ * read paths (getContact / searchContacts) re-hydrate and aggregate it.
6
+ */
7
+
8
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
9
+
10
+ mock.module("../util/logger.js", () => ({
11
+ getLogger: () =>
12
+ new Proxy({} as Record<string, unknown>, {
13
+ get: () => () => {},
14
+ }),
15
+ }));
16
+
17
+ import {
18
+ findContactInfoById,
19
+ getContact,
20
+ searchContacts,
21
+ } from "../contacts/contact-store.js";
22
+ import { getSqlite } from "../memory/db-connection.js";
23
+ import { initializeDb } from "../memory/db-init.js";
24
+
25
+ await initializeDb();
26
+
27
+ function resetContactTables(): void {
28
+ const sqlite = getSqlite();
29
+ sqlite.run("DELETE FROM contact_channels");
30
+ sqlite.run("DELETE FROM contacts");
31
+ }
32
+
33
+ function insertContact(id: string, displayName: string): void {
34
+ const now = Date.now();
35
+ getSqlite().run(
36
+ "INSERT INTO contacts (id, display_name, role, contact_type, user_file, created_at, updated_at) VALUES (?, ?, 'contact', 'human', ?, ?, ?)",
37
+ [id, displayName, `${id}.md`, now, now],
38
+ );
39
+ }
40
+
41
+ function insertChannel(params: {
42
+ id: string;
43
+ contactId: string;
44
+ type: string;
45
+ address: string;
46
+ interactionCount: number;
47
+ lastInteraction: number | null;
48
+ lastSeenAt?: number | null;
49
+ }): void {
50
+ const now = Date.now();
51
+ getSqlite().run(
52
+ "INSERT INTO contact_channels (id, contact_id, type, address, is_primary, interaction_count, last_interaction, last_seen_at, created_at, updated_at) VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?)",
53
+ [
54
+ params.id,
55
+ params.contactId,
56
+ params.type,
57
+ params.address,
58
+ params.interactionCount,
59
+ params.lastInteraction,
60
+ params.lastSeenAt ?? null,
61
+ now,
62
+ now,
63
+ ],
64
+ );
65
+ }
66
+
67
+ describe("contact interaction INFO aggregation", () => {
68
+ beforeEach(() => {
69
+ resetContactTables();
70
+ });
71
+
72
+ test("getContact sums interaction_count and takes the latest last_interaction across channels", () => {
73
+ insertContact("ct_1", "Alice");
74
+ insertChannel({
75
+ id: "ch_a",
76
+ contactId: "ct_1",
77
+ type: "phone",
78
+ address: "+15550100",
79
+ interactionCount: 3,
80
+ lastInteraction: 1900,
81
+ lastSeenAt: 1850,
82
+ });
83
+ insertChannel({
84
+ id: "ch_b",
85
+ contactId: "ct_1",
86
+ type: "email",
87
+ address: "alice@example.com",
88
+ interactionCount: 4,
89
+ lastInteraction: 2100,
90
+ lastSeenAt: 2050,
91
+ });
92
+
93
+ const contact = getContact("ct_1");
94
+ expect(contact).not.toBeNull();
95
+ expect(contact!.interactionCount).toBe(7);
96
+ expect(contact!.lastInteraction).toBe(2100);
97
+
98
+ // Per-channel INFO is hydrated too.
99
+ const phone = contact!.channels.find((c) => c.type === "phone");
100
+ expect(phone?.interactionCount).toBe(3);
101
+ expect(phone?.lastInteraction).toBe(1900);
102
+ expect(phone?.lastSeenAt).toBe(1850);
103
+ });
104
+
105
+ test("findContactInfoById surfaces the real interaction count", () => {
106
+ insertContact("ct_2", "Bob");
107
+ insertChannel({
108
+ id: "ch_c",
109
+ contactId: "ct_2",
110
+ type: "phone",
111
+ address: "+15550200",
112
+ interactionCount: 5,
113
+ lastInteraction: 1000,
114
+ });
115
+
116
+ const info = findContactInfoById("ct_2");
117
+ expect(info?.interactionCount).toBe(5);
118
+ });
119
+
120
+ test("lastInteraction is null when no channel has interacted", () => {
121
+ insertContact("ct_3", "Carol");
122
+ insertChannel({
123
+ id: "ch_d",
124
+ contactId: "ct_3",
125
+ type: "phone",
126
+ address: "+15550300",
127
+ interactionCount: 0,
128
+ lastInteraction: null,
129
+ });
130
+
131
+ const contact = getContact("ct_3");
132
+ expect(contact!.interactionCount).toBe(0);
133
+ expect(contact!.lastInteraction).toBeNull();
134
+ });
135
+
136
+ test("filtered searchContacts carries channel INFO fields", () => {
137
+ insertContact("ct_4", "Dana");
138
+ insertChannel({
139
+ id: "ch_e",
140
+ contactId: "ct_4",
141
+ type: "phone",
142
+ address: "+15550400",
143
+ interactionCount: 9,
144
+ lastInteraction: 3000,
145
+ lastSeenAt: 2900,
146
+ });
147
+
148
+ const results = searchContacts({ query: "Dana", limit: 10 });
149
+ expect(results).toHaveLength(1);
150
+ expect(results[0].interactionCount).toBe(9);
151
+ expect(results[0].lastInteraction).toBe(3000);
152
+ const ch = results[0].channels[0];
153
+ expect(ch.interactionCount).toBe(9);
154
+ expect(ch.lastSeenAt).toBe(2900);
155
+ });
156
+ });
@@ -66,11 +66,10 @@ describe("upsertContact user_file selection", () => {
66
66
  resetContactTables();
67
67
  });
68
68
 
69
- test("reuses an existing sibling's userFile when principalId matches", () => {
69
+ test("assigns a fresh slug per contact; principalId is gateway-owned and no longer groups siblings locally", () => {
70
70
  const primary = upsertContact({
71
71
  displayName: "Chris",
72
72
  role: "guardian",
73
- principalId: "principal-abc",
74
73
  channels: [
75
74
  {
76
75
  type: "vellum",
@@ -80,12 +79,12 @@ describe("upsertContact user_file selection", () => {
80
79
  });
81
80
  expect(primary.userFile).toBe("chris.md");
82
81
 
83
- // Second contact for the same principal on Slack must inherit the
84
- // first contact's userFile, NOT auto-increment to chris-2.md.
82
+ // A second contact with the same display name no longer inherits the
83
+ // first's userFile via a local principal lookup — it gets a fresh
84
+ // (collision-incremented) slug. Sibling grouping is owned by the gateway.
85
85
  const slack = upsertContact({
86
86
  displayName: "chris",
87
87
  role: "guardian",
88
- principalId: "principal-abc",
89
88
  channels: [
90
89
  {
91
90
  type: "slack",
@@ -94,15 +93,14 @@ describe("upsertContact user_file selection", () => {
94
93
  },
95
94
  ],
96
95
  });
97
- expect(slack.userFile).toBe("chris.md");
96
+ expect(slack.userFile).toBe("chris-2.md");
98
97
  expect(slack.id).not.toBe(primary.id);
99
98
  });
100
99
 
101
- test("falls back to generateUserFileSlug when principalId has no existing sibling", () => {
100
+ test("generates a slug from the display name for a brand-new contact", () => {
102
101
  const contact = upsertContact({
103
102
  displayName: "Alice",
104
103
  role: "contact",
105
- principalId: "principal-alone",
106
104
  channels: [
107
105
  {
108
106
  type: "slack",
@@ -141,7 +139,7 @@ describe("upsertContact user_file selection", () => {
141
139
  expect(second.userFile).toBe("bob-2.md");
142
140
  });
143
141
 
144
- test("ignores a sibling whose userFile is null and generates a new slug", () => {
142
+ test("a null-userFile contact does not block a fresh slug for a new contact", () => {
145
143
  insertContact({
146
144
  id: "seed-null",
147
145
  displayName: "legacy",
@@ -154,7 +152,6 @@ describe("upsertContact user_file selection", () => {
154
152
  const contact = upsertContact({
155
153
  displayName: "Legacy",
156
154
  role: "guardian",
157
- principalId: "principal-null",
158
155
  channels: [
159
156
  {
160
157
  type: "phone",
@@ -63,19 +63,18 @@ const realContactStore = await import("../contacts/contact-store.js");
63
63
  // contactType is filtered in SQL (before the limit) rather than relayed.
64
64
  const listContactsArgs: Array<{
65
65
  limit?: number;
66
- role?: string;
67
66
  contactType?: string;
68
67
  }> = [];
69
68
  mock.module("../contacts/contact-store.js", () => ({
70
69
  ...realContactStore,
71
- listContacts: (limit?: number, role?: string, contactType?: string) => {
70
+ listContacts: (limit?: number, contactType?: string) => {
72
71
  localCalls.push("listContacts");
73
- listContactsArgs.push({ limit, role, contactType });
72
+ listContactsArgs.push({ limit, contactType });
74
73
  return [
75
74
  {
76
75
  id: "local-1",
77
76
  displayName: "Local Contact",
78
- role: role ?? "contact",
77
+ role: "contact",
79
78
  contactType: contactType ?? "human",
80
79
  interactionCount: 0,
81
80
  channels: [],
@@ -248,20 +247,18 @@ describe("handleListContacts relay", () => {
248
247
  // contactType + limit are pushed into the SQL-filtered daemon read (the
249
248
  // daemon-native listContacts filters contactType BEFORE applying limit).
250
249
  expect(listContactsArgs).toEqual([
251
- { limit: 50, role: undefined, contactType: "assistant" },
250
+ { limit: 50, contactType: "assistant" },
252
251
  ]);
253
252
  expect(result.contacts[0].id).toBe("local-1");
254
253
  expect(result.contacts[0].contactType).toBe("assistant");
255
254
  expect(debugLogs.some((m) => m.includes("daemon-native"))).toBe(true);
256
255
  });
257
256
 
258
- test("contactType + role both flow into the daemon-native read", async () => {
257
+ test("contactType filters daemon-native; role is gateway-owned and no longer a local predicate", async () => {
259
258
  await handleListContacts({ contactType: "human", role: "guardian" });
260
259
 
261
260
  expect(ipcCalls).toEqual([]);
262
- expect(listContactsArgs).toEqual([
263
- { limit: 50, role: "guardian", contactType: "human" },
264
- ]);
261
+ expect(listContactsArgs).toEqual([{ limit: 50, contactType: "human" }]);
265
262
  });
266
263
  });
267
264
 
@@ -63,8 +63,6 @@ describe("guardian persona seeding and trust-cache invariants", () => {
63
63
  externalUserId: "Bob",
64
64
  externalChatId: "chat-bob",
65
65
  displayName: "Bob",
66
- role: "contact",
67
- status: "active",
68
66
  });
69
67
 
70
68
  expect(result).not.toBeNull();
@@ -257,8 +257,8 @@ mock.module("../plugins/defaults/compaction/overflow-policy.js", () => ({
257
257
  }));
258
258
 
259
259
  mock.module("../memory/conversation-crud.js", () => ({
260
- setConversationProcessingStartedAt: () => {},
261
- isConversationProcessing: () => false,
260
+ setConversationProcessingStartedAt: () => {},
261
+ isConversationProcessing: () => false,
262
262
  setConversationOriginChannelIfUnset: () => {},
263
263
  setConversationHistoryStrippedAt: () => {},
264
264
  updateConversationUsage: () => {},
@@ -289,6 +289,8 @@ mock.module("../memory/conversation-crud.js", () => ({
289
289
  getLastUserTimestampBefore: () => 0,
290
290
  resolveOverrideProfile: () => undefined,
291
291
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
292
+ recordConversationPersistedSeq: () => {},
293
+ getConversationPersistedSeq: () => null,
292
294
  }));
293
295
 
294
296
  afterAll(() => {
@@ -278,6 +278,7 @@ const deleteMessageByIdMock = mock(() => ({
278
278
  }));
279
279
  const reserveMessageMock = mock(async () => ({ id: "msg-reserve" }));
280
280
  const updateMessageContentMock = mock(() => {});
281
+ const addMessageMock = mock(() => ({ id: "mock-msg-id" }));
281
282
  mock.module("../memory/conversation-crud.js", () => ({
282
283
  setConversationProcessingStartedAt: () => {},
283
284
  isConversationProcessing: () => false,
@@ -292,7 +293,7 @@ mock.module("../memory/conversation-crud.js", () => ({
292
293
  trustContext: undefined,
293
294
  }),
294
295
  getConversationOriginInterface: () => null,
295
- addMessage: () => ({ id: "mock-msg-id" }),
296
+ addMessage: addMessageMock,
296
297
  deleteMessageById: deleteMessageByIdMock,
297
298
  updateConversationContextWindow: () => {},
298
299
  updateConversationSlackContextWatermark:
@@ -303,6 +304,8 @@ mock.module("../memory/conversation-crud.js", () => ({
303
304
  getLastUserTimestampBefore: () => 0,
304
305
  reserveMessage: reserveMessageMock,
305
306
  updateMessageContent: updateMessageContentMock,
307
+ recordConversationPersistedSeq: () => {},
308
+ getConversationPersistedSeq: () => null,
306
309
  // The real schema is a Zod object; tests don't exercise validation,
307
310
  // so a passthrough is sufficient — the production code at
308
311
  // `handleMessageComplete` only branches on `success` and reads two
@@ -558,13 +561,16 @@ mock.module("../workspace/git-service.js", () => ({
558
561
  }),
559
562
  }));
560
563
 
564
+ let mockConversationErrorClassification = {
565
+ code: "CONVERSATION_PROCESSING_FAILED",
566
+ userMessage: "Something went wrong processing your message.",
567
+ retryable: false,
568
+ errorCategory: "processing_failed",
569
+ };
570
+
561
571
  mock.module("../daemon/conversation-error.js", () => ({
562
- classifyConversationError: (_err: unknown, _ctx: unknown) => ({
563
- code: "CONVERSATION_PROCESSING_FAILED",
564
- userMessage: "Something went wrong processing your message.",
565
- retryable: false,
566
- errorCategory: "processing_failed",
567
- }),
572
+ classifyConversationError: (_err: unknown, _ctx: unknown) =>
573
+ mockConversationErrorClassification,
568
574
  isUserCancellation: (err: unknown, ctx: { aborted?: boolean }) => {
569
575
  if (!ctx.aborted) return false;
570
576
  if (err instanceof DOMException && err.name === "AbortError") return true;
@@ -870,6 +876,13 @@ beforeEach(() => {
870
876
  deleteMessageByIdMock.mockClear();
871
877
  reserveMessageMock.mockClear();
872
878
  updateMessageContentMock.mockClear();
879
+ addMessageMock.mockClear();
880
+ mockConversationErrorClassification = {
881
+ code: "CONVERSATION_PROCESSING_FAILED",
882
+ userMessage: "Something went wrong processing your message.",
883
+ retryable: false,
884
+ errorCategory: "processing_failed",
885
+ };
873
886
  indexMessageNowMock.mockClear();
874
887
  projectAssistantMessageMock.mockClear();
875
888
  publishSyncInvalidationMock.mockClear();
@@ -2239,6 +2252,49 @@ describe("session-agent-loop", () => {
2239
2252
  expect(backfillCall[0]).toBe("test-conv");
2240
2253
  expect(backfillCall[1]).toBe("mock-msg-id");
2241
2254
  });
2255
+
2256
+ test("does not persist managed credential refresh failures as assistant text", async () => {
2257
+ mockConversationErrorClassification = {
2258
+ code: "MANAGED_KEY_INVALID",
2259
+ userMessage: "Couldn't refresh assistant credentials.",
2260
+ retryable: false,
2261
+ errorCategory: "managed_key_invalid",
2262
+ };
2263
+ const events: ServerMessage[] = [];
2264
+
2265
+ const ctx = makeCtx({
2266
+ loopProvider: {
2267
+ name: "mock-provider",
2268
+ async sendMessage() {
2269
+ throw new Error("API key has expired.");
2270
+ },
2271
+ } as unknown as Provider,
2272
+ });
2273
+ await runAgentLoopImpl(ctx, "hello", "msg-1", (msg) => events.push(msg));
2274
+
2275
+ expect(
2276
+ events.filter((event) => event.type === "assistant_text_delta"),
2277
+ ).toHaveLength(0);
2278
+
2279
+ const conversationError = events.find(
2280
+ (event) => event.type === "conversation_error",
2281
+ );
2282
+ expect(conversationError).toBeDefined();
2283
+ expect(conversationError).toMatchObject({
2284
+ code: "MANAGED_KEY_INVALID",
2285
+ userMessage: "Couldn't refresh assistant credentials.",
2286
+ errorCategory: "managed_key_invalid",
2287
+ });
2288
+
2289
+ expect(addMessageMock).not.toHaveBeenCalled();
2290
+ expect(recordRequestLogMock).not.toHaveBeenCalled();
2291
+ expect(backfillMessageIdOnLogsMock).not.toHaveBeenCalled();
2292
+ expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
2293
+ const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
2294
+ string,
2295
+ ];
2296
+ expect(deleteCall[0]).toBe("msg-reserve");
2297
+ });
2242
2298
  });
2243
2299
 
2244
2300
  describe("B3 pre-allocation: indexing + cleanup", () => {
@@ -2450,6 +2506,41 @@ describe("session-agent-loop", () => {
2450
2506
  expect(lastSync?.[1]).toBe("mock-msg-id");
2451
2507
  expect(lastSync?.[1]).not.toBe("msg-orphaned-reservation");
2452
2508
  });
2509
+
2510
+ test("managed-key provider-error cleanup publishes message invalidation after deleting the reservation", async () => {
2511
+ reserveMessageMock.mockImplementationOnce(async () => ({
2512
+ id: "msg-managed-key-reservation",
2513
+ }));
2514
+ mockConversationErrorClassification = {
2515
+ code: "MANAGED_KEY_INVALID",
2516
+ userMessage: "Couldn't refresh assistant credentials.",
2517
+ retryable: false,
2518
+ errorCategory: "managed_key_invalid",
2519
+ };
2520
+
2521
+ const ctx = makeCtx({
2522
+ loopProvider: {
2523
+ name: "mock-provider",
2524
+ async sendMessage() {
2525
+ throw new Error("API key has expired.");
2526
+ },
2527
+ } as unknown as Provider,
2528
+ });
2529
+ await runAgentLoopImpl(ctx, "hi", "msg-1", () => {});
2530
+
2531
+ expect(deleteMessageByIdMock).toHaveBeenCalledTimes(1);
2532
+ const deleteCall = deleteMessageByIdMock.mock.calls[0] as unknown as [
2533
+ string,
2534
+ ];
2535
+ expect(deleteCall[0]).toBe("msg-managed-key-reservation");
2536
+ expect(addMessageMock).not.toHaveBeenCalled();
2537
+ expect(syncMessageToDiskMock).not.toHaveBeenCalled();
2538
+
2539
+ const messagePublishes = (
2540
+ publishSyncInvalidationMock.mock.calls as unknown as Array<[string[]]>
2541
+ ).filter((args) => args[0]?.includes("conversation:test-conv:messages"));
2542
+ expect(messagePublishes).toHaveLength(1);
2543
+ });
2453
2544
  });
2454
2545
 
2455
2546
  describe("partial persistence", () => {