@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
@@ -2,18 +2,16 @@
2
2
  * Regression: the SSE subscribe path must resolve the actor principal from the
3
3
  * SAME guardian source as the send/result routes.
4
4
  *
5
- * The send/result routes resolve the actor principal via the async,
6
- * gateway-first `findLocalGuardianPrincipalId`. The SSE eager-subscribe path
7
- * cannot await and uses the sync `findLocalGuardianPrincipalIdFromStore`. When
8
- * the gateway binding is canonical but the local contact row is stale/missing
9
- * (after a guardian reset or gateway-owned binding update), the sync path must
10
- * still land on the gateway principal otherwise the event hub registers the
11
- * SSE client under a DIFFERENT principal than the turn/result paths use, and
12
- * targeted result submissions 403.
5
+ * The send/result routes resolve the actor principal via the async
6
+ * `findLocalGuardianPrincipalId`. The SSE eager-subscribe path cannot await and
7
+ * uses the sync `findLocalGuardianPrincipalIdFromStore`. Both read the
8
+ * gateway-owned guardian binding async via the cached IPC read, sync via the
9
+ * IO-free cache snapshot so the event hub registers the SSE client under the
10
+ * SAME principal the turn/result paths use; otherwise targeted result
11
+ * submissions 403.
13
12
  *
14
- * These tests pin the invariant by priming the gateway-delivery cache with a
15
- * principal that differs from the stale local store and asserting both
16
- * resolvers agree; and that a cold cache falls back to the local store.
13
+ * These tests pin the invariant by priming the gateway-delivery cache and
14
+ * asserting both resolvers agree; and that a cold cache yields no principal.
17
15
  */
18
16
 
19
17
  import { beforeEach, describe, expect, mock, test } from "bun:test";
@@ -30,17 +28,6 @@ mock.module("../ipc/gateway-client.js", () => ({
30
28
  resetPersistentClient: () => {},
31
29
  }));
32
30
 
33
- // ── Local store mock (the stale fallback source) ─────────────────────────────
34
-
35
- let storePrincipalId: string | undefined;
36
-
37
- mock.module("../contacts/contact-store.js", () => ({
38
- findGuardianForChannel: (channelType: string) =>
39
- storePrincipalId && channelType === "vellum"
40
- ? { contact: { principalId: storePrincipalId }, channel: {} }
41
- : null,
42
- }));
43
-
44
31
  import {
45
32
  __resetGuardianDeliveryCacheForTest,
46
33
  getGuardianDelivery,
@@ -62,31 +49,21 @@ describe("SSE actor principal resolves from the same guardian source as send/res
62
49
  beforeEach(() => {
63
50
  __resetGuardianDeliveryCacheForTest();
64
51
  ipcGuardians = [];
65
- storePrincipalId = undefined;
66
52
  });
67
53
 
68
- test("warm gateway cache: sync (SSE) and async (send/result) resolve the SAME principal despite a stale local store", async () => {
69
- // Gateway binding is canonical; local store row is stale (different id).
54
+ test("warm gateway cache: sync (SSE) and async (send/result) resolve the SAME principal", async () => {
70
55
  ipcGuardians = [gatewayVellumGuardian];
71
- storePrincipalId = "guardian-stale-local";
72
56
 
73
57
  // Prime the cache the way the async hot paths do.
74
58
  const asyncPrincipalId = await findLocalGuardianPrincipalId();
75
59
  expect(asyncPrincipalId).toBe("guardian-from-gateway");
76
60
 
77
- // SSE's sync resolver reads the same cached gateway snapshot, NOT the
78
- // stale store — so the principals match and host-proxy targeting works.
61
+ // SSE's sync resolver reads the same cached gateway snapshot so the
62
+ // principals match and host-proxy targeting works.
79
63
  expect(findLocalGuardianPrincipalIdFromStore()).toBe(asyncPrincipalId);
80
64
  });
81
65
 
82
- test("cold cache: sync resolver falls back to the local store as before", () => {
83
- // Nothing primed the cache; only the local store has a binding.
84
- storePrincipalId = "guardian-stale-local";
85
-
86
- expect(findLocalGuardianPrincipalIdFromStore()).toBe("guardian-stale-local");
87
- });
88
-
89
- test("cold cache with no store binding: sync resolver returns undefined", () => {
66
+ test("cold cache: sync resolver returns undefined", () => {
90
67
  expect(findLocalGuardianPrincipalIdFromStore()).toBeUndefined();
91
68
  });
92
69
 
@@ -318,13 +318,12 @@ function makeContact(displayName: string): ContactWithChannels {
318
318
  id: `contact-${displayName.toLowerCase()}`,
319
319
  displayName,
320
320
  notes: null,
321
+ role: "contact",
321
322
  lastInteraction: null,
322
323
  interactionCount: 0,
323
324
  createdAt: now,
324
325
  updatedAt: now,
325
- role: "contact",
326
326
  contactType: "human",
327
- principalId: null,
328
327
  userFile: null,
329
328
  channels: [],
330
329
  };
@@ -345,7 +344,11 @@ describe("resolveCallHints", () => {
345
344
 
346
345
  test("guardian displayName for hints comes from the gateway binding", async () => {
347
346
  mockGuardianDelivery = [
348
- { channelType: "phone", status: "active", displayName: "GatewayGuardian" },
347
+ {
348
+ channelType: "phone",
349
+ status: "active",
350
+ displayName: "GatewayGuardian",
351
+ },
349
352
  ];
350
353
 
351
354
  await resolveCallHints(null, []);
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Tests for decoupling message-inheritance from prompt source and role on
3
+ * context-inheriting subagents (forks).
4
+ *
5
+ * A fork normally pins the parent's system prompt verbatim and keeps the
6
+ * `general` role so its KV cache stays aligned with the parent. These tests
7
+ * verify the opt-out paths: a fork may supply its own `systemPromptOverride`
8
+ * (which is used as-is and does NOT set `hasSystemPromptOverride`), and a fork
9
+ * may carry an explicit read-only role (whose tool allowlist is applied). A
10
+ * plain fork (no override, no role) must still behave exactly as before.
11
+ *
12
+ * The harness stubs `Conversation` and the spawn() dependencies so we can call
13
+ * `SubagentManager.spawn()` directly and capture the constructed conversation's
14
+ * system prompt, `hasSystemPromptOverride` flag, and allowed-tools set.
15
+ */
16
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
17
+
18
+ import type { ServerMessage } from "../daemon/message-protocol.js";
19
+
20
+ // ── Captured constructor state ──────────────────────────────────────────────
21
+
22
+ interface CapturedConversationState {
23
+ systemPrompt: string;
24
+ hasSystemPromptOverride: boolean;
25
+ allowedTools: Set<string> | undefined;
26
+ }
27
+
28
+ const capturedConversations: CapturedConversationState[] = [];
29
+
30
+ // Stub Conversation so spawn() never runs an agent loop — we only inspect how
31
+ // it was constructed and configured.
32
+ class FakeConversation {
33
+ private readonly captured: CapturedConversationState;
34
+
35
+ constructor(
36
+ _id: string,
37
+ _provider: unknown,
38
+ systemPrompt: string,
39
+ _sendToClient: (msg: ServerMessage) => void,
40
+ _workingDir: string,
41
+ _options?: unknown,
42
+ ) {
43
+ this.captured = {
44
+ systemPrompt,
45
+ hasSystemPromptOverride: false,
46
+ allowedTools: undefined,
47
+ };
48
+ capturedConversations.push(this.captured);
49
+ }
50
+ set hasSystemPromptOverride(value: boolean) {
51
+ this.captured.hasSystemPromptOverride = value;
52
+ }
53
+ get hasSystemPromptOverride(): boolean {
54
+ return this.captured.hasSystemPromptOverride;
55
+ }
56
+ conversationType = "background";
57
+ updateClient() {}
58
+ setIsSubagent() {}
59
+ setTrustContext() {}
60
+ setAuthContext() {}
61
+ getAuthContext() {
62
+ return undefined;
63
+ }
64
+ setAssistantId() {}
65
+ setSubagentAllowedTools(tools: Set<string>) {
66
+ this.captured.allowedTools = tools;
67
+ }
68
+ setPreactivatedSkillIds() {}
69
+ injectInheritedContext() {}
70
+ abort() {}
71
+ dispose() {}
72
+ messages = [];
73
+ usageStats = { inputTokens: 0, outputTokens: 0, estimatedCost: 0 };
74
+ sendToClient() {}
75
+ persistUserMessage() {
76
+ return { id: "msg-id", deduplicated: false };
77
+ }
78
+ runAgentLoop() {
79
+ return Promise.resolve();
80
+ }
81
+ getCurrentSystemPrompt() {
82
+ return "resolved-parent-prompt";
83
+ }
84
+ }
85
+
86
+ mock.module("../daemon/conversation.js", () => ({
87
+ Conversation: FakeConversation,
88
+ }));
89
+
90
+ mock.module("../memory/conversation-bootstrap.js", () => ({
91
+ bootstrapConversation: () => ({ id: "conv-id" }),
92
+ }));
93
+
94
+ // Resolve a stub provider without touching connections/DB.
95
+ const providerStub = { name: "anthropic", sendMessage: async () => ({}) };
96
+
97
+ mock.module("../providers/connection-resolution.js", () => ({
98
+ resolveDefaultProvider: async () => providerStub,
99
+ }));
100
+
101
+ mock.module("../providers/call-site-routing.js", () => ({
102
+ wrapWithCallSiteRouting: (p: unknown) => p,
103
+ }));
104
+
105
+ mock.module("../config/loader.js", () => ({
106
+ getConfig: () => ({
107
+ llm: { default: { provider: "anthropic", model: "claude-opus-4-7" } },
108
+ rateLimit: { maxRequestsPerMinute: 0 },
109
+ }),
110
+ }));
111
+
112
+ mock.module("../config/llm-resolver.js", () => ({
113
+ resolveCallSiteConfig: () => ({ provider: "anthropic", maxTokens: 8192 }),
114
+ }));
115
+
116
+ mock.module("../util/logger.js", () => ({
117
+ getLogger: () =>
118
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
119
+ }));
120
+
121
+ // ── Imports (after mocks) ───────────────────────────────────────────────────
122
+
123
+ import { clearConversations } from "../daemon/conversation-registry.js";
124
+ import { SubagentManager } from "../subagent/manager.js";
125
+ import type { SubagentConfig } from "../subagent/types.js";
126
+
127
+ const PARENT_PROMPT = "You are the parent's system prompt.";
128
+
129
+ type SpawnConfig = Omit<SubagentConfig, "id">;
130
+
131
+ function makeForkSpawnConfig(
132
+ overrides: Partial<SpawnConfig> = {},
133
+ ): SpawnConfig {
134
+ return {
135
+ parentConversationId: `parent-${Math.random().toString(36).slice(2)}`,
136
+ label: "test fork",
137
+ objective: "do something",
138
+ fork: true,
139
+ parentSystemPrompt: PARENT_PROMPT,
140
+ ...overrides,
141
+ };
142
+ }
143
+
144
+ describe("SubagentManager fork — prompt source and role decoupling", () => {
145
+ let manager: SubagentManager;
146
+
147
+ beforeEach(() => {
148
+ clearConversations();
149
+ capturedConversations.length = 0;
150
+ manager = new SubagentManager();
151
+ });
152
+
153
+ afterEach(() => {
154
+ (manager as unknown as { stopSweep: () => void }).stopSweep();
155
+ });
156
+
157
+ /** Spawn a fork and return the single conversation that spawn() constructed. */
158
+ async function spawnFork(
159
+ overrides: Partial<SpawnConfig> = {},
160
+ ): Promise<CapturedConversationState> {
161
+ await manager.spawn(makeForkSpawnConfig(overrides), () => {});
162
+ const created = capturedConversations[0];
163
+ if (!created) throw new Error("Expected a subagent conversation");
164
+ return created;
165
+ }
166
+
167
+ test("fork with systemPromptOverride uses that prompt and does not set hasSystemPromptOverride", async () => {
168
+ const overridePrompt = "You are an advisor. Frame the context as advice.";
169
+ const created = await spawnFork({ systemPromptOverride: overridePrompt });
170
+
171
+ expect(created.systemPrompt).toBe(overridePrompt);
172
+ expect(created.hasSystemPromptOverride).toBe(false);
173
+ });
174
+
175
+ test("fork with explicit read-only role applies the role allowlist via setSubagentAllowedTools", async () => {
176
+ const created = await spawnFork({ role: "researcher" });
177
+
178
+ // The researcher role is read-only; its allowlist must be applied even for
179
+ // a fork.
180
+ expect(created.allowedTools).toBeInstanceOf(Set);
181
+ expect(created.allowedTools?.has("web_search")).toBe(true);
182
+ expect(created.allowedTools?.has("file_read")).toBe(true);
183
+ expect(created.allowedTools?.has("bash")).toBe(false);
184
+ });
185
+
186
+ test("plain fork (no override, no role) keeps parent prompt verbatim, general tools, and sets hasSystemPromptOverride", async () => {
187
+ const created = await spawnFork();
188
+
189
+ // Parent prompt verbatim, no tool filter (general role has no allowlist),
190
+ // and the prompt is pinned for KV-cache alignment.
191
+ expect(created.systemPrompt).toBe(PARENT_PROMPT);
192
+ expect(created.allowedTools).toBeUndefined();
193
+ expect(created.hasSystemPromptOverride).toBe(true);
194
+ });
195
+ });
@@ -273,7 +273,7 @@ describe("SubagentManager fork spawn", () => {
273
273
  expect(memoryPolicy.includeDefaultFallback).toBe(true);
274
274
  });
275
275
 
276
- test("fork forces general role and skips tool filtering", async () => {
276
+ test("fork defaults to general role (which has no tool allowlist)", async () => {
277
277
  const manager = new SubagentManager();
278
278
  const subagentId = "sub-fork-role";
279
279
 
@@ -286,14 +286,16 @@ describe("SubagentManager fork spawn", () => {
286
286
  fakeConversation.injectInheritedContext = () => {};
287
287
  fakeConversation.setSubagentAllowedTools = () => {};
288
288
 
289
- // Create a fork state in real spawn(), the role would be forced to
290
- // "general" regardless of what was requested, and tool filtering skipped.
289
+ // A fork that does not request a role defaults to "general", which has
290
+ // `allowedTools: undefined` so no tool filter is applied. (An explicit
291
+ // non-general role on a fork IS honored; that path is covered in
292
+ // subagent-fork-prompt-role.test.ts.)
291
293
  const state = makeState(
292
294
  subagentId,
293
295
  { isFork: true },
294
296
  {
295
297
  fork: true,
296
- role: "general", // forced by spawn() logic
298
+ role: "general",
297
299
  parentMessages: FAKE_PARENT_MESSAGES,
298
300
  parentSystemPrompt: "Parent system prompt.",
299
301
  },
@@ -303,9 +305,6 @@ describe("SubagentManager fork spawn", () => {
303
305
 
304
306
  await asInternals(manager).runSubagent(subagentId, "Do something");
305
307
 
306
- // Tool filtering is only applied in spawn(), not runSubagent(), so we
307
- // verify the logic directly: forks skip setSubagentAllowedTools.
308
- // For this test, we verify the fork's role is general (which has no allowedTools).
309
308
  expect(state.config.role).toBe("general");
310
309
 
311
310
  asInternals(manager).stopSweep();
@@ -13,6 +13,7 @@ const ALL_ROLES: SubagentRole[] = [
13
13
  "coder",
14
14
  "planner",
15
15
  "investigator",
16
+ "advisor",
16
17
  ];
17
18
 
18
19
  describe("SUBAGENT_ROLE_REGISTRY", () => {
@@ -32,18 +33,30 @@ describe("SUBAGENT_ROLE_REGISTRY", () => {
32
33
  expect(SUBAGENT_ROLE_REGISTRY.general.allowedTools).toBeUndefined();
33
34
  });
34
35
 
35
- test("all non-general roles have allowedTools as a non-empty array", () => {
36
+ test("all scoped tool-using roles have allowedTools as a non-empty array", () => {
36
37
  for (const role of ALL_ROLES) {
37
- if (role === "general") continue;
38
+ // 'general' has no filter (undefined); 'advisor' is tool-less (empty).
39
+ if (role === "general" || role === "advisor") continue;
38
40
  const config = SUBAGENT_ROLE_REGISTRY[role];
39
41
  expect(Array.isArray(config.allowedTools)).toBe(true);
40
42
  expect(config.allowedTools!.length).toBeGreaterThan(0);
41
43
  }
42
44
  });
43
45
 
44
- test('every role with allowedTools includes "notify_parent"', () => {
46
+ test("advisor is tool-less with an empty allowedTools array", () => {
47
+ const config = SUBAGENT_ROLE_REGISTRY.advisor;
48
+ expect(config.allowedTools).toEqual([]);
49
+ });
50
+
51
+ test("SubagentRole type includes advisor", () => {
52
+ const advisor: SubagentRole = "advisor";
53
+ expect(SUBAGENT_ROLE_REGISTRY[advisor]).toBeDefined();
54
+ });
55
+
56
+ test('every role with a non-empty allowlist includes "notify_parent"', () => {
45
57
  for (const [_role, config] of Object.entries(SUBAGENT_ROLE_REGISTRY)) {
46
- if (config.allowedTools !== undefined) {
58
+ // 'advisor' is tool-less (empty allowlist) and intentionally has none.
59
+ if (config.allowedTools !== undefined && config.allowedTools.length > 0) {
47
60
  expect(config.allowedTools).toContain("notify_parent");
48
61
  }
49
62
  }