@vellumai/assistant 0.10.3 → 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
@@ -11,7 +11,12 @@ const mockProfiles = {
11
11
  "cost-optimized": {},
12
12
  disabled: { status: "disabled" },
13
13
  "quality-optimized": {},
14
+ frontier: {},
14
15
  };
16
+ // The advisor consult defaults to `llm.advisorProfile`. Set here so the
17
+ // advisor tests can assert the default and an explicit `inference_profile`
18
+ // override against the same shared config.
19
+ const mockAdvisorProfile = "frontier";
15
20
  mock.module("../config/loader.js", () => ({
16
21
  getConfigReadOnly: () => ({
17
22
  llm: { profiles: mockProfiles },
@@ -24,13 +29,30 @@ mock.module("../config/loader.js", () => ({
24
29
  model: "claude-opus-4-7",
25
30
  },
26
31
  profiles: mockProfiles,
32
+ advisorProfile: mockAdvisorProfile,
27
33
  },
28
34
  rateLimit: { maxRequestsPerMinute: 0 },
35
+ tools: { exclude: [] },
36
+ memory: { enabled: true },
29
37
  }),
30
38
  }));
39
+
40
+ // Mock the conversation registry so the advisor consult can resolve a fake
41
+ // parent conversation (snapshot messages + system prompt) without a live
42
+ // Conversation. Other executors in this suite never call `findConversation`.
43
+ let mockFindConversation: (conversationId: string) =>
44
+ | {
45
+ messages: Array<{ role: string; content: unknown[] }>;
46
+ getCurrentSystemPrompt: () => string;
47
+ }
48
+ | undefined = () => undefined;
49
+ mock.module("../daemon/conversation-registry.js", () => ({
50
+ findConversation: (conversationId: string) =>
51
+ mockFindConversation(conversationId),
52
+ }));
31
53
  mock.module("../memory/conversation-crud.js", () => ({
32
- setConversationProcessingStartedAt: () => {},
33
- isConversationProcessing: () => false,
54
+ setConversationProcessingStartedAt: () => {},
55
+ isConversationProcessing: () => false,
34
56
  setConversationOriginChannelIfUnset: () => {},
35
57
  updateConversationContextWindow: () => {},
36
58
  deleteMessageById: () => {},
@@ -58,7 +80,10 @@ mock.module("../memory/conversation-crud.js", () => ({
58
80
  }));
59
81
 
60
82
  import { getSubagentManager } from "../subagent/index.js";
61
- import { SubagentManager } from "../subagent/manager.js";
83
+ import {
84
+ SubagentAbortedError,
85
+ SubagentManager,
86
+ } from "../subagent/manager.js";
62
87
  import type { SubagentState } from "../subagent/types.js";
63
88
  import { executeSubagentAbort } from "../tools/subagent/abort.js";
64
89
  import { executeSubagentMessage } from "../tools/subagent/message.js";
@@ -1564,8 +1589,378 @@ describe("Subagent role-based spawn", () => {
1564
1589
  "coder",
1565
1590
  "planner",
1566
1591
  "investigator",
1592
+ "advisor",
1567
1593
  ]);
1568
1594
  // role is not required
1569
1595
  expect(def.input_schema.required).not.toContain("role");
1570
1596
  });
1571
1597
  });
1598
+
1599
+ // ── Advisor-role consult ────────────────────────────────────────────
1600
+
1601
+ describe("Subagent advisor-role consult", () => {
1602
+ type Block = { type: string; [k: string]: unknown };
1603
+ type CapturedAwait = {
1604
+ config: Record<string, unknown>;
1605
+ opts?: { signal?: AbortSignal; onText?: (chunk: string) => void };
1606
+ };
1607
+
1608
+ /**
1609
+ * Stub `manager.spawnAndAwait` to capture the config + opts and resolve to
1610
+ * `advice`. Restores the original on cleanup. Returns the captured-call ref.
1611
+ */
1612
+ function stubAwait(advice: string | (() => Promise<string>)): {
1613
+ captured: { current?: CapturedAwait };
1614
+ restore: () => void;
1615
+ } {
1616
+ const manager = getSubagentManager();
1617
+ const original = manager.spawnAndAwait.bind(manager);
1618
+ const captured: { current?: CapturedAwait } = {};
1619
+ manager.spawnAndAwait = (async (
1620
+ config: Record<string, unknown>,
1621
+ _send: unknown,
1622
+ opts?: CapturedAwait["opts"],
1623
+ ) => {
1624
+ captured.current = { config, opts };
1625
+ return typeof advice === "function" ? await advice() : advice;
1626
+ }) as typeof manager.spawnAndAwait;
1627
+ return {
1628
+ captured,
1629
+ restore: () => {
1630
+ manager.spawnAndAwait = original;
1631
+ },
1632
+ };
1633
+ }
1634
+
1635
+ test("advisor role returns guidance synchronously as the tool result", async () => {
1636
+ mockFindConversation = () => ({
1637
+ messages: [{ role: "user", content: [{ type: "text", text: "Help" }] }],
1638
+ getCurrentSystemPrompt: () => "PARENT SYSTEM PROMPT",
1639
+ });
1640
+ const { captured, restore } = stubAwait("Here is my advice.");
1641
+ try {
1642
+ const result = await executeSubagentSpawn(
1643
+ {
1644
+ label: "Consult",
1645
+ objective: "advise me",
1646
+ role: "advisor",
1647
+ },
1648
+ makeContext("advisor-sess-1", { sendToClient: () => {} }),
1649
+ );
1650
+ expect(result.isError).toBe(false);
1651
+ expect(result.content).toBe("Here is my advice.");
1652
+ // Ran synchronously through spawnAndAwait, not fire-and-forget spawn.
1653
+ expect(captured.current).toBeDefined();
1654
+ expect(captured.current!.config.fork).toBe(true);
1655
+ expect(captured.current!.config.role).toBe("advisor");
1656
+ // Framing embeds the executor prompt as advisor system prompt context.
1657
+ expect(captured.current!.config.systemPromptOverride).toContain(
1658
+ "PARENT SYSTEM PROMPT",
1659
+ );
1660
+ } finally {
1661
+ restore();
1662
+ mockFindConversation = () => undefined;
1663
+ }
1664
+ });
1665
+
1666
+ test("advisor inherits and sanitizes the parent transcript", async () => {
1667
+ // Parent in-memory history carries a thinking block (must be stripped) and
1668
+ // a completed tool_use/tool_result pair (must be preserved).
1669
+ mockFindConversation = () => ({
1670
+ messages: [
1671
+ { role: "user", content: [{ type: "text", text: "Do the task" }] },
1672
+ {
1673
+ role: "assistant",
1674
+ content: [
1675
+ { type: "thinking", thinking: "secret reasoning" },
1676
+ { type: "text", text: "Working on it" },
1677
+ { type: "tool_use", id: "t1", name: "bash", input: {} },
1678
+ ],
1679
+ },
1680
+ {
1681
+ role: "user",
1682
+ content: [{ type: "tool_result", tool_use_id: "t1", content: "ok" }],
1683
+ },
1684
+ ],
1685
+ getCurrentSystemPrompt: () => "SYS",
1686
+ });
1687
+ const { captured, restore } = stubAwait("advice");
1688
+ try {
1689
+ await executeSubagentSpawn(
1690
+ { label: "Consult", objective: "x", role: "advisor" },
1691
+ makeContext("advisor-sess-2", { sendToClient: () => {} }),
1692
+ );
1693
+ const msgs = captured.current!.config.parentMessages as Array<{
1694
+ role: string;
1695
+ content: Block[];
1696
+ }>;
1697
+ const allBlocks = msgs.flatMap((m) => m.content);
1698
+ // Thinking is stripped; the completed tool_use/tool_result pair survives.
1699
+ expect(allBlocks.some((b) => b.type === "thinking")).toBe(false);
1700
+ expect(allBlocks.some((b) => b.type === "tool_use")).toBe(true);
1701
+ expect(allBlocks.some((b) => b.type === "tool_result")).toBe(true);
1702
+ } finally {
1703
+ restore();
1704
+ mockFindConversation = () => undefined;
1705
+ }
1706
+ });
1707
+
1708
+ test("in-flight plan is visible to the advisor with no dangling tool_use", async () => {
1709
+ // The in-memory snapshot ends on the user turn — the in-flight assistant
1710
+ // turn (this turn's plan + the pending advisor tool_use) lives only in the
1711
+ // DB at consult time. It must be appended, and its dangling tool_use stripped.
1712
+ mockFindConversation = () => ({
1713
+ messages: [
1714
+ { role: "user", content: [{ type: "text", text: "Plan the work" }] },
1715
+ ],
1716
+ getCurrentSystemPrompt: () => "SYS",
1717
+ });
1718
+ mockGetMessages = (convId: string) => {
1719
+ if (convId !== "advisor-sess-3") return null;
1720
+ return [
1721
+ {
1722
+ role: "user",
1723
+ content: JSON.stringify([{ type: "text", text: "Plan the work" }]),
1724
+ },
1725
+ {
1726
+ role: "assistant",
1727
+ content: JSON.stringify([
1728
+ { type: "text", text: "My plan: step 1, step 2." },
1729
+ {
1730
+ type: "tool_use",
1731
+ id: "adv-1",
1732
+ name: "subagent_spawn",
1733
+ input: {},
1734
+ },
1735
+ ]),
1736
+ },
1737
+ ];
1738
+ };
1739
+ const { captured, restore } = stubAwait("advice");
1740
+ try {
1741
+ await executeSubagentSpawn(
1742
+ { label: "Consult", objective: "x", role: "advisor" },
1743
+ makeContext("advisor-sess-3", { sendToClient: () => {} }),
1744
+ );
1745
+ const msgs = captured.current!.config.parentMessages as Array<{
1746
+ role: string;
1747
+ content: Block[];
1748
+ }>;
1749
+ const allBlocks = msgs.flatMap((m) => m.content);
1750
+ // The plan text the model wrote this turn is present in the consult.
1751
+ expect(
1752
+ allBlocks.some(
1753
+ (b) => b.type === "text" && b.text === "My plan: step 1, step 2.",
1754
+ ),
1755
+ ).toBe(true);
1756
+ // No dangling tool_use is sent.
1757
+ expect(allBlocks.some((b) => b.type === "tool_use")).toBe(false);
1758
+ } finally {
1759
+ restore();
1760
+ mockFindConversation = () => undefined;
1761
+ mockGetMessages = () => null;
1762
+ }
1763
+ });
1764
+
1765
+ test("advisor defaults to llm.advisorProfile (forced)", async () => {
1766
+ mockFindConversation = () => ({
1767
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1768
+ getCurrentSystemPrompt: () => "SYS",
1769
+ });
1770
+ const { captured, restore } = stubAwait("advice");
1771
+ try {
1772
+ await executeSubagentSpawn(
1773
+ { label: "Consult", objective: "x", role: "advisor" },
1774
+ makeContext("advisor-sess-4", { sendToClient: () => {} }),
1775
+ );
1776
+ expect(captured.current!.config.overrideProfile).toBe("frontier");
1777
+ expect(captured.current!.config.forceOverrideProfile).toBe(true);
1778
+ } finally {
1779
+ restore();
1780
+ mockFindConversation = () => undefined;
1781
+ }
1782
+ });
1783
+
1784
+ test("advisor respects an explicit inference_profile over advisorProfile", async () => {
1785
+ mockFindConversation = () => ({
1786
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1787
+ getCurrentSystemPrompt: () => "SYS",
1788
+ });
1789
+ const { captured, restore } = stubAwait("advice");
1790
+ try {
1791
+ await executeSubagentSpawn(
1792
+ {
1793
+ label: "Consult",
1794
+ objective: "x",
1795
+ role: "advisor",
1796
+ inference_profile: "quality-optimized",
1797
+ },
1798
+ makeContext("advisor-sess-5", { sendToClient: () => {} }),
1799
+ );
1800
+ expect(captured.current!.config.overrideProfile).toBe(
1801
+ "quality-optimized",
1802
+ );
1803
+ expect(captured.current!.config.forceOverrideProfile).toBe(true);
1804
+ } finally {
1805
+ restore();
1806
+ mockFindConversation = () => undefined;
1807
+ }
1808
+ });
1809
+
1810
+ test("advisor forwards streamed chunks to the tool's onOutput sink", async () => {
1811
+ mockFindConversation = () => ({
1812
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1813
+ getCurrentSystemPrompt: () => "SYS",
1814
+ });
1815
+ const { captured, restore } = stubAwait("advice");
1816
+ const chunks: string[] = [];
1817
+ const onOutput = (c: string) => chunks.push(c);
1818
+ try {
1819
+ await executeSubagentSpawn(
1820
+ { label: "Consult", objective: "x", role: "advisor" },
1821
+ makeContext("advisor-sess-6", { sendToClient: () => {}, onOutput }),
1822
+ );
1823
+ // onText is a progress-recording wrapper (resets the idle deadline), not
1824
+ // onOutput itself — but invoking it must still forward to onOutput.
1825
+ expect(captured.current!.opts?.onText).toBeInstanceOf(Function);
1826
+ captured.current!.opts?.onText?.("hello");
1827
+ expect(chunks).toEqual(["hello"]);
1828
+ expect(captured.current!.opts?.signal).toBeInstanceOf(AbortSignal);
1829
+ } finally {
1830
+ restore();
1831
+ mockFindConversation = () => undefined;
1832
+ }
1833
+ });
1834
+
1835
+ test("advisor degrades benignly when the consult throws (incl. depth limit)", async () => {
1836
+ mockFindConversation = () => ({
1837
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1838
+ getCurrentSystemPrompt: () => "SYS",
1839
+ });
1840
+ const { restore } = stubAwait(async () => {
1841
+ throw new Error(
1842
+ "Cannot spawn subagent: parent is itself a subagent (max depth 1).",
1843
+ );
1844
+ });
1845
+ try {
1846
+ const result = await executeSubagentSpawn(
1847
+ { label: "Consult", objective: "x", role: "advisor" },
1848
+ makeContext("advisor-sess-7", { sendToClient: () => {} }),
1849
+ );
1850
+ // Never fail the turn — benign non-error notice.
1851
+ expect(result.isError).toBe(false);
1852
+ expect(result.content).toContain("advisor unavailable");
1853
+ expect(result.content).toContain("parent is itself a subagent");
1854
+ } finally {
1855
+ restore();
1856
+ mockFindConversation = () => undefined;
1857
+ }
1858
+ });
1859
+
1860
+ test("advisor returns partial guidance (with a cut-off note) when the consult times out", async () => {
1861
+ mockFindConversation = () => ({
1862
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1863
+ getCurrentSystemPrompt: () => "SYS",
1864
+ });
1865
+ // A timeout surfaces as SubagentAbortedError carrying the partial text the
1866
+ // advisor streamed before being cut off; that text must be salvaged.
1867
+ const { restore } = stubAwait(async () => {
1868
+ throw new SubagentAbortedError(
1869
+ "Lead with the data model, then wire reminders last.",
1870
+ );
1871
+ });
1872
+ try {
1873
+ const result = await executeSubagentSpawn(
1874
+ { label: "Consult", objective: "x", role: "advisor" },
1875
+ makeContext("advisor-sess-timeout", { sendToClient: () => {} }),
1876
+ );
1877
+ expect(result.isError).toBe(false);
1878
+ expect(result.content).toContain(
1879
+ "Lead with the data model, then wire reminders last.",
1880
+ );
1881
+ expect(result.content).toContain("may be cut off");
1882
+ // Not the generic unavailable degrade — real guidance was preserved.
1883
+ expect(result.content).not.toContain("advisor unavailable");
1884
+ } finally {
1885
+ restore();
1886
+ mockFindConversation = () => undefined;
1887
+ }
1888
+ });
1889
+
1890
+ test("advisor still degrades when a timeout yields no partial text", async () => {
1891
+ mockFindConversation = () => ({
1892
+ messages: [{ role: "user", content: [{ type: "text", text: "Hi" }] }],
1893
+ getCurrentSystemPrompt: () => "SYS",
1894
+ });
1895
+ // Aborted before producing any text → empty partial → fall through to the
1896
+ // benign "advisor unavailable" notice.
1897
+ const { restore } = stubAwait(async () => {
1898
+ throw new SubagentAbortedError(" ");
1899
+ });
1900
+ try {
1901
+ const result = await executeSubagentSpawn(
1902
+ { label: "Consult", objective: "x", role: "advisor" },
1903
+ makeContext("advisor-sess-timeout-empty", { sendToClient: () => {} }),
1904
+ );
1905
+ expect(result.isError).toBe(false);
1906
+ expect(result.content).toContain("advisor unavailable");
1907
+ } finally {
1908
+ restore();
1909
+ mockFindConversation = () => undefined;
1910
+ }
1911
+ });
1912
+
1913
+ test("advisor degrades benignly when no client is connected", async () => {
1914
+ // No sendToClient → the shared client guard fires before the advisor branch.
1915
+ const result = await executeSubagentSpawn(
1916
+ { label: "Consult", objective: "x", role: "advisor" },
1917
+ makeContext("advisor-sess-8"),
1918
+ );
1919
+ expect(result.isError).toBe(true);
1920
+ expect(result.content).toContain("No client connected");
1921
+ });
1922
+ });
1923
+
1924
+ // ── Advisor role tool-less enforcement ──────────────────────────────
1925
+
1926
+ describe("Advisor role is tool-less", () => {
1927
+ test("the advisor role's empty allowlist yields zero tools (not 'no filter')", async () => {
1928
+ // An empty allowlist must mean ZERO tools, not "no restriction". Build a
1929
+ // resolveTools callback with the advisor's empty allowlist and confirm no
1930
+ // tool survives — including a fake skill tool the projection would add.
1931
+ const { createResolveToolsCallback } =
1932
+ await import("../daemon/conversation-tool-setup.js");
1933
+ const { SUBAGENT_ROLE_REGISTRY } = await import("../subagent/types.js");
1934
+ const advisorAllowed = SUBAGENT_ROLE_REGISTRY.advisor.allowedTools;
1935
+ expect(advisorAllowed).toEqual([]);
1936
+
1937
+ const toolDefs = [
1938
+ { name: "bash", description: "", input_schema: { type: "object" } },
1939
+ { name: "file_read", description: "", input_schema: { type: "object" } },
1940
+ ];
1941
+ const ctx = {
1942
+ skillProjectionState: new Map<string, string>(),
1943
+ skillProjectionCache: new Map(),
1944
+ coreToolNames: new Set(toolDefs.map((d) => d.name)),
1945
+ toolsDisabledDepth: 0,
1946
+ // The advisor role applies `new Set(allowedTools)` — empty Set here.
1947
+ subagentAllowedTools: new Set<string>(advisorAllowed),
1948
+ // Default (absent) gate mode is "wire": the allowlist filters the wire
1949
+ // tool list, so an empty Set leaves nothing.
1950
+ isSubagent: true,
1951
+ } as unknown as Parameters<typeof createResolveToolsCallback>[1];
1952
+
1953
+ const resolve = createResolveToolsCallback(
1954
+ toolDefs as unknown as Parameters<typeof createResolveToolsCallback>[0],
1955
+ ctx,
1956
+ );
1957
+ expect(resolve).toBeDefined();
1958
+ const resolved = resolve!([]);
1959
+ expect(resolved).toEqual([]);
1960
+ // The per-turn execution gate is likewise empty.
1961
+ expect(
1962
+ (ctx as unknown as { allowedToolNames?: Set<string> }).allowedToolNames
1963
+ ?.size ?? 0,
1964
+ ).toBe(0);
1965
+ });
1966
+ });
@@ -98,7 +98,6 @@ import {
98
98
  saveRawConfig,
99
99
  setNestedValue,
100
100
  } from "../config/loader.js";
101
- import { upsertContactChannel } from "../contacts/contacts-write.js";
102
101
  import {
103
102
  type ChannelCapabilities,
104
103
  loadSlackChronologicalContext,
@@ -121,6 +120,7 @@ import {
121
120
  } from "../runtime/routes/inbound-message-handler.js";
122
121
  import {
123
122
  handleChannelInbound,
123
+ seedContactChannel,
124
124
  setAdapterProcessMessage,
125
125
  } from "./helpers/channel-test-adapter.js";
126
126
 
@@ -1902,7 +1902,7 @@ function resetHttpState(): void {
1902
1902
  }
1903
1903
 
1904
1904
  function seedHttpActiveMember(chatId = HTTP_SLACK_CHANNEL_ID): void {
1905
- upsertContactChannel({
1905
+ seedContactChannel({
1906
1906
  sourceChannel: "slack",
1907
1907
  externalUserId: HTTP_SLACK_USER_ID,
1908
1908
  externalChatId: chatId,
@@ -1913,7 +1913,7 @@ function seedHttpActiveMember(chatId = HTTP_SLACK_CHANNEL_ID): void {
1913
1913
  }
1914
1914
 
1915
1915
  function seedHttpGuardianMember(chatId = HTTP_SLACK_CHANNEL_ID): void {
1916
- upsertContactChannel({
1916
+ seedContactChannel({
1917
1917
  sourceChannel: "slack",
1918
1918
  externalUserId: HTTP_SLACK_USER_ID,
1919
1919
  externalChatId: chatId,
@@ -59,14 +59,27 @@ const reserveMessageMock = mock(
59
59
  );
60
60
  const updateMessageContentMock = mock((_id: string, _content: string) => {});
61
61
 
62
+ // Stand-in for the `conversations.seq` column. The DB-backed
63
+ // `recordConversationPersistedSeq` / `getConversationPersistedSeq` are mocked
64
+ // over this map with the same monotonic, ignore-non-positive semantics so the
65
+ // handler's persisted-seq writes are observable without a real database.
66
+ const persistedSeqByConversation = new Map<string, number>();
67
+
62
68
  mock.module("../memory/conversation-crud.js", () => ({
63
- setConversationProcessingStartedAt: () => {},
64
- isConversationProcessing: () => false,
69
+ setConversationProcessingStartedAt: () => {},
70
+ isConversationProcessing: () => false,
65
71
  getConversation: () => null,
66
72
  getMessageById: () => null,
67
73
  updateMessageContent: updateMessageContentMock,
68
74
  provenanceFromTrustContext: () => ({}),
69
75
  reserveMessage: reserveMessageMock,
76
+ recordConversationPersistedSeq: (id: string, seq: number) => {
77
+ if (!Number.isFinite(seq) || seq <= 0) return;
78
+ const prev = persistedSeqByConversation.get(id);
79
+ if (prev == null || prev < seq) persistedSeqByConversation.set(id, seq);
80
+ },
81
+ getConversationPersistedSeq: (id: string) =>
82
+ persistedSeqByConversation.get(id) ?? null,
70
83
  }));
71
84
 
72
85
  mock.module("../memory/conversation-disk-view.js", () => ({
@@ -102,11 +115,11 @@ import {
102
115
  handleToolUsePreviewStart,
103
116
  } from "../daemon/conversation-agent-loop-handlers.js";
104
117
  import type { ServerMessage } from "../daemon/message-protocol.js";
118
+ import { getConversationPersistedSeq } from "../memory/conversation-crud.js";
105
119
  import type { AssistantEvent } from "../runtime/assistant-event.js";
106
120
  import {
107
121
  _resetStreamStateForTesting,
108
122
  getCurrentSeq,
109
- getPersistedSeq,
110
123
  stampAndBuffer,
111
124
  } from "../runtime/assistant-stream-state.js";
112
125
 
@@ -324,6 +337,7 @@ describe("tool preview lifecycle", () => {
324
337
  describe("persisted seq advances on tool_use_start", () => {
325
338
  beforeEach(() => {
326
339
  _resetStreamStateForTesting();
340
+ persistedSeqByConversation.clear();
327
341
  });
328
342
 
329
343
  test("advances the conversation's persisted seq to the tool_use_start seq", () => {
@@ -374,8 +388,8 @@ describe("tool preview lifecycle", () => {
374
388
  (e) => e.type === "tool_use_start",
375
389
  );
376
390
  expect(toolUseStart).toBeDefined();
377
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
378
- expect(getPersistedSeq(conversationId)).toBe(
391
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
392
+ expect(getConversationPersistedSeq(conversationId)).toBe(
379
393
  (toolUseStart as unknown as AssistantEvent).seq ?? null,
380
394
  );
381
395
  });
@@ -386,6 +400,7 @@ describe("tool preview lifecycle", () => {
386
400
 
387
401
  beforeEach(() => {
388
402
  _resetStreamStateForTesting();
403
+ persistedSeqByConversation.clear();
389
404
  });
390
405
 
391
406
  /** onEvent that stamps conversation-scoped events like the runtime hub. */
@@ -474,8 +489,8 @@ describe("tool preview lifecycle", () => {
474
489
  (e) => e.type === "assistant_thinking_delta",
475
490
  );
476
491
  expect(thinkingDelta).toBeDefined();
477
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
478
- expect(getPersistedSeq(conversationId)).toBe(
492
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
493
+ expect(getConversationPersistedSeq(conversationId)).toBe(
479
494
  (thinkingDelta as unknown as AssistantEvent).seq ?? null,
480
495
  );
481
496
  });
@@ -505,8 +520,8 @@ describe("tool preview lifecycle", () => {
505
520
  // THEN the persisted seq equals the just-stamped tool_result seq
506
521
  const toolResult = events.find((e) => e.type === "tool_result");
507
522
  expect(toolResult).toBeDefined();
508
- expect(getPersistedSeq(conversationId)).toBe(getCurrentSeq());
509
- expect(getPersistedSeq(conversationId)).toBe(
523
+ expect(getConversationPersistedSeq(conversationId)).toBe(getCurrentSeq());
524
+ expect(getConversationPersistedSeq(conversationId)).toBe(
510
525
  (toolResult as unknown as AssistantEvent).seq ?? null,
511
526
  );
512
527
  });
@@ -539,7 +554,7 @@ describe("tool preview lifecycle", () => {
539
554
  events.find((e) => e.type === "assistant_thinking_delta"),
540
555
  ).toBeUndefined();
541
556
  expect(state.lastPersistedContentSeq).toBeUndefined();
542
- expect(getPersistedSeq(conversationId)).toBeNull();
557
+ expect(getConversationPersistedSeq(conversationId)).toBeNull();
543
558
  });
544
559
  });
545
560
 
@@ -548,6 +563,7 @@ describe("tool preview lifecycle", () => {
548
563
 
549
564
  beforeEach(() => {
550
565
  _resetStreamStateForTesting();
566
+ persistedSeqByConversation.clear();
551
567
  reserveMessageMock.mockClear();
552
568
  updateMessageContentMock.mockClear();
553
569
  });
@@ -43,8 +43,8 @@ let mockedRowContent = "";
43
43
  const updates: Array<{ id: string; content: string }> = [];
44
44
 
45
45
  mock.module("../memory/conversation-crud.js", () => ({
46
- setConversationProcessingStartedAt: () => {},
47
- isConversationProcessing: () => false,
46
+ setConversationProcessingStartedAt: () => {},
47
+ isConversationProcessing: () => false,
48
48
  addMessage: () => ({ id: "mock-msg-id" }),
49
49
  getMessageById: (id: string) =>
50
50
  mockedRowContent ? { id, content: mockedRowContent } : null,
@@ -53,6 +53,8 @@ mock.module("../memory/conversation-crud.js", () => ({
53
53
  },
54
54
  provenanceFromTrustContext: () => ({}),
55
55
  reserveMessage: mock(async () => ({ id: "msg-reserve" })),
56
+ recordConversationPersistedSeq: () => {},
57
+ getConversationPersistedSeq: () => null,
56
58
  }));
57
59
 
58
60
  mock.module("../memory/llm-request-log-store.js", () => ({
@@ -62,7 +64,6 @@ mock.module("../memory/llm-request-log-store.js", () => ({
62
64
 
63
65
  mock.module("../runtime/assistant-stream-state.js", () => ({
64
66
  getCurrentSeq: () => 0,
65
- recordPersistedSeq: () => {},
66
67
  }));
67
68
 
68
69
  // ── Imports (after mocks) ─────────────────────────────────────────────────────