@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
@@ -0,0 +1,546 @@
1
+ /**
2
+ * Tests for `SubagentManager.spawnAndAwait` — the synchronous run primitive.
3
+ *
4
+ * Unlike fire-and-forget `spawn` (covered elsewhere), `spawnAndAwait` awaits
5
+ * the child's run, resolves to its final assistant text, forwards streaming
6
+ * deltas via `onText`, supports external abort via `signal`, and MUST NOT
7
+ * trigger the terminal parent-injection that the fire-and-forget path uses.
8
+ *
9
+ * The harness mocks `Conversation` + bootstrap + provider registry + config
10
+ * (same pattern as subagent-call-site-routing.test.ts) so the manager runs
11
+ * its real setUpSubagent → runSubagent path against a controllable fake
12
+ * Conversation without touching SQLite or a real provider.
13
+ */
14
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
15
+
16
+ import type { ServerMessage } from "../daemon/message-protocol.js";
17
+ import type { Message } from "../providers/types.js";
18
+
19
+ // ── Fake Conversation ───────────────────────────────────────────────────────
20
+
21
+ interface FakeConversationConfig {
22
+ /** Final in-memory messages exposed after runAgentLoop resolves. */
23
+ messages?: Message[];
24
+ /** When set, runAgentLoop rejects with this error. */
25
+ runError?: Error;
26
+ /**
27
+ * When true, runAgentLoop blocks until `abort()` is called, then rejects.
28
+ * Used to exercise the external-signal abort path.
29
+ */
30
+ waitForAbort?: boolean;
31
+ /**
32
+ * When true, runAgentLoop blocks until `abort()` is called, then RESOLVES
33
+ * normally (does not throw). Simulates the real `runAgentLoop`, which
34
+ * consumes the cancellation internally and resolves — the case where a
35
+ * timed-out run would otherwise reach the success branch.
36
+ */
37
+ resolveOnAbort?: boolean;
38
+ /** Deltas to emit through sendToClient before runAgentLoop resolves. */
39
+ emitDeltas?: ServerMessage[];
40
+ /**
41
+ * Invoked synchronously at the very start of runAgentLoop (after the loop has
42
+ * begun, so the run is past the early-terminal guard and marked "running").
43
+ * Lets a test trigger an external abort while the loop is genuinely in
44
+ * flight, deterministically exercising the resolve-on-abort branch that
45
+ * captures partial trailing text.
46
+ */
47
+ onLoopStart?: () => void;
48
+ }
49
+
50
+ let nextConversationConfig: FakeConversationConfig = {};
51
+ /** Set true when any FakeConversation's runAgentLoop is invoked. */
52
+ let runLoopInvoked = false;
53
+ /** The first user message persisted by the most recent FakeConversation. */
54
+ let lastPersistedUserMessage: string | undefined;
55
+
56
+ class FakeConversation {
57
+ messages: Message[];
58
+ usageStats = { inputTokens: 10, outputTokens: 5, estimatedCost: 0.001 };
59
+ conversationType = "background";
60
+ hasSystemPromptOverride = false;
61
+
62
+ private sendToClient: (msg: ServerMessage) => void;
63
+ private readonly cfg: FakeConversationConfig;
64
+ private aborted = false;
65
+ private resolveAbort?: () => void;
66
+
67
+ constructor(
68
+ _id: string,
69
+ _provider: unknown,
70
+ _systemPrompt: string,
71
+ sendToClient: (msg: ServerMessage) => void,
72
+ _workingDir: string,
73
+ _options?: unknown,
74
+ ) {
75
+ this.sendToClient = sendToClient;
76
+ this.cfg = nextConversationConfig;
77
+ this.messages = this.cfg.messages ?? [];
78
+ }
79
+
80
+ updateClient(sendToClient: (msg: ServerMessage) => void) {
81
+ // The manager re-points sendToClient via updateClient; honor it so the
82
+ // wrappedSendToClient tap is the one the deltas flow through.
83
+ this.sendToClient = sendToClient;
84
+ }
85
+ setIsSubagent() {}
86
+ setTrustContext() {}
87
+ setAuthContext() {}
88
+ getAuthContext() {
89
+ return undefined;
90
+ }
91
+ setAssistantId() {}
92
+ setSubagentAllowedTools() {}
93
+ setPreactivatedSkillIds() {}
94
+ getCurrentSystemPrompt() {
95
+ return "system";
96
+ }
97
+ injectInheritedContext() {}
98
+
99
+ persistUserMessage(args: { content: string }) {
100
+ lastPersistedUserMessage = args.content;
101
+ return { id: "msg-id", deduplicated: false };
102
+ }
103
+
104
+ async runAgentLoop() {
105
+ runLoopInvoked = true;
106
+ this.cfg.onLoopStart?.();
107
+ for (const delta of this.cfg.emitDeltas ?? []) {
108
+ this.sendToClient(delta);
109
+ }
110
+ if (this.cfg.waitForAbort || this.cfg.resolveOnAbort) {
111
+ // Block until abort() resolves the gate (unless abort already fired, e.g.
112
+ // an already-aborted signal). resolveOnAbort RESOLVES normally to mimic
113
+ // the real runAgentLoop consuming the cancellation; waitForAbort throws.
114
+ if (!this.aborted) {
115
+ await new Promise<void>((resolve) => {
116
+ this.resolveAbort = resolve;
117
+ });
118
+ }
119
+ if (this.cfg.resolveOnAbort) return;
120
+ throw new Error("aborted");
121
+ }
122
+ if (this.cfg.runError) {
123
+ throw this.cfg.runError;
124
+ }
125
+ }
126
+
127
+ abort() {
128
+ this.aborted = true;
129
+ this.resolveAbort?.();
130
+ }
131
+ dispose() {}
132
+ }
133
+
134
+ mock.module("../daemon/conversation.js", () => ({
135
+ Conversation: FakeConversation,
136
+ }));
137
+
138
+ mock.module("../memory/conversation-bootstrap.js", () => ({
139
+ bootstrapConversation: () => ({ id: `conv-${Math.random()}` }),
140
+ }));
141
+
142
+ mock.module("../prompts/system-prompt.js", () => ({
143
+ buildSystemPrompt: () => "system prompt",
144
+ buildSubagentSystemPrompt: () => "subagent system",
145
+ }));
146
+
147
+ const anthropicStub = { name: "anthropic" };
148
+
149
+ mock.module("../providers/registry.js", () => ({
150
+ getProvider: () => anthropicStub,
151
+ resolveProviderFromConnection: async () => anthropicStub,
152
+ clearConnectionProviderCache: () => {},
153
+ listProviders: () => ["anthropic"],
154
+ }));
155
+
156
+ mock.module("../providers/connection-resolution.js", () => ({
157
+ resolveDefaultProvider: async () => anthropicStub,
158
+ }));
159
+
160
+ mock.module("../providers/call-site-routing.js", () => ({
161
+ wrapWithCallSiteRouting: (provider: unknown) => provider,
162
+ }));
163
+
164
+ mock.module("../config/loader.js", () => ({
165
+ getConfig: () => ({
166
+ llm: {
167
+ default: {
168
+ provider: "anthropic",
169
+ provider_connection: "anthropic-conn",
170
+ model: "claude-opus-4-7",
171
+ },
172
+ },
173
+ rateLimit: { maxRequestsPerMinute: 0 },
174
+ }),
175
+ }));
176
+
177
+ mock.module("../config/llm-resolver.js", () => ({
178
+ resolveCallSiteConfig: () => ({
179
+ provider: "anthropic",
180
+ provider_connection: "anthropic-conn",
181
+ maxTokens: 4096,
182
+ }),
183
+ }));
184
+
185
+ mock.module("../util/logger.js", () => ({
186
+ getLogger: () =>
187
+ new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
188
+ }));
189
+
190
+ // ── Imports (after mocks) ───────────────────────────────────────────────────
191
+
192
+ import {
193
+ clearConversations,
194
+ setConversation,
195
+ } from "../daemon/conversation-registry.js";
196
+ import { SubagentAbortedError, SubagentManager } from "../subagent/manager.js";
197
+
198
+ function makeConfig(overrides: Record<string, unknown> = {}) {
199
+ return {
200
+ parentConversationId: `parent-${Math.random()}`,
201
+ label: "test",
202
+ objective: "do the thing",
203
+ ...overrides,
204
+ };
205
+ }
206
+
207
+ /** Statuses broadcast to the parent via `subagent_status_changed` events. */
208
+ function broadcastStatuses(events: ServerMessage[]): string[] {
209
+ return events
210
+ .filter((m) => m.type === "subagent_status_changed")
211
+ .map((m) => (m as { status: string }).status);
212
+ }
213
+
214
+ /** A fake parent conversation that records injected (enqueued) messages. */
215
+ function registerFakeParent(parentConversationId: string): {
216
+ enqueuedCount: () => number;
217
+ } {
218
+ let enqueued = 0;
219
+ setConversation(parentConversationId, {
220
+ // Accessors read by setUpSubagent when copying trust/auth context.
221
+ trustContext: undefined,
222
+ getAuthContext: () => undefined,
223
+ assistantId: undefined,
224
+ enqueueMessage: () => {
225
+ enqueued += 1;
226
+ return { rejected: false, queued: true };
227
+ },
228
+ } as never);
229
+ return { enqueuedCount: () => enqueued };
230
+ }
231
+
232
+ describe("SubagentManager.spawnAndAwait", () => {
233
+ test("resolves to the child's final assistant text", async () => {
234
+ nextConversationConfig = {
235
+ messages: [
236
+ { role: "user", content: [{ type: "text", text: "do the thing" }] },
237
+ {
238
+ role: "assistant",
239
+ content: [
240
+ { type: "text", text: "Final " },
241
+ { type: "text", text: "answer." },
242
+ ],
243
+ },
244
+ ],
245
+ };
246
+
247
+ const manager = new SubagentManager();
248
+ const text = await manager.spawnAndAwait(makeConfig(), () => {});
249
+
250
+ expect(text).toBe("Final answer.");
251
+ });
252
+
253
+ test("returns empty string when the final assistant message has no text", async () => {
254
+ nextConversationConfig = {
255
+ messages: [
256
+ {
257
+ role: "assistant",
258
+ content: [{ type: "tool_use", id: "t1", name: "noop", input: {} }],
259
+ },
260
+ ],
261
+ };
262
+
263
+ const manager = new SubagentManager();
264
+ const text = await manager.spawnAndAwait(makeConfig(), () => {});
265
+
266
+ expect(text).toBe("");
267
+ });
268
+
269
+ test("does NOT inject a terminal notification into the parent (synchronous path)", async () => {
270
+ clearConversations();
271
+ const cfg = makeConfig();
272
+ const parent = registerFakeParent(cfg.parentConversationId);
273
+
274
+ nextConversationConfig = {
275
+ messages: [
276
+ {
277
+ role: "assistant",
278
+ content: [{ type: "text", text: "result" }],
279
+ },
280
+ ],
281
+ };
282
+
283
+ const manager = new SubagentManager();
284
+ await manager.spawnAndAwait(cfg, () => {});
285
+
286
+ expect(parent.enqueuedCount()).toBe(0);
287
+ clearConversations();
288
+ });
289
+
290
+ test("forwards streaming text/thinking deltas via onText", async () => {
291
+ nextConversationConfig = {
292
+ messages: [
293
+ { role: "assistant", content: [{ type: "text", text: "done" }] },
294
+ ],
295
+ emitDeltas: [
296
+ { type: "assistant_text_delta", text: "Hello " } as ServerMessage,
297
+ {
298
+ type: "assistant_thinking_delta",
299
+ thinking: "(pondering) ",
300
+ } as ServerMessage,
301
+ { type: "assistant_text_delta", text: "world" } as ServerMessage,
302
+ // Non-delta events must not be forwarded to onText.
303
+ { type: "subagent_status_changed" } as ServerMessage,
304
+ ],
305
+ };
306
+
307
+ const chunks: string[] = [];
308
+ const manager = new SubagentManager();
309
+ await manager.spawnAndAwait(makeConfig(), () => {}, {
310
+ onText: (chunk) => chunks.push(chunk),
311
+ });
312
+
313
+ expect(chunks).toEqual(["Hello ", "(pondering) ", "world"]);
314
+ });
315
+
316
+ test("aborting the provided signal rejects the run", async () => {
317
+ nextConversationConfig = { waitForAbort: true };
318
+
319
+ const controller = new AbortController();
320
+ const manager = new SubagentManager();
321
+ const promise = manager.spawnAndAwait(makeConfig(), () => {}, {
322
+ signal: controller.signal,
323
+ });
324
+
325
+ // Abort on the next tick so the run is in flight.
326
+ queueMicrotask(() => controller.abort());
327
+
328
+ await expect(promise).rejects.toThrow();
329
+ });
330
+
331
+ test("an already-aborted signal aborts the run immediately", async () => {
332
+ nextConversationConfig = { waitForAbort: true };
333
+
334
+ const controller = new AbortController();
335
+ controller.abort();
336
+
337
+ const manager = new SubagentManager();
338
+ await expect(
339
+ manager.spawnAndAwait(makeConfig(), () => {}, {
340
+ signal: controller.signal,
341
+ }),
342
+ ).rejects.toThrow();
343
+ });
344
+
345
+ test("a live-signal abort records status 'aborted', never broadcasts 'completed'", async () => {
346
+ // runAgentLoop RESOLVES normally on abort (the real loop consumes the
347
+ // cancellation). Before the fix, runSubagent's success branch then
348
+ // recorded the run as "completed"; the manager-routed abort must mark it
349
+ // terminal first so this is recorded and broadcast as "aborted".
350
+ nextConversationConfig = { resolveOnAbort: true };
351
+
352
+ const events: ServerMessage[] = [];
353
+ const controller = new AbortController();
354
+ const manager = new SubagentManager();
355
+ const promise = manager.spawnAndAwait(
356
+ makeConfig(),
357
+ (msg) => events.push(msg),
358
+ { signal: controller.signal },
359
+ );
360
+
361
+ // Abort once the run is in flight (runAgentLoop is awaiting the gate).
362
+ queueMicrotask(() => controller.abort());
363
+
364
+ await expect(promise).rejects.toThrow();
365
+
366
+ const statuses = broadcastStatuses(events);
367
+ expect(statuses).toContain("aborted");
368
+ expect(statuses).not.toContain("completed");
369
+ });
370
+
371
+ test("an abort carries the partial assistant text on the rejection", async () => {
372
+ // The real runAgentLoop consumes the cancellation and resolves, so the
373
+ // success branch captures whatever trailing assistant text was streamed
374
+ // before the abort. A timed-out caller (e.g. the advisor consult) must be
375
+ // able to recover that partial text rather than have it discarded.
376
+ const controller = new AbortController();
377
+ nextConversationConfig = {
378
+ resolveOnAbort: true,
379
+ messages: [
380
+ {
381
+ role: "assistant",
382
+ content: [{ type: "text", text: "partial advice so far" }],
383
+ },
384
+ ],
385
+ // Abort once the loop is in flight (past the early-terminal guard, status
386
+ // "running") so we exercise the partial-capture branch, not the
387
+ // aborted-before-start early return.
388
+ onLoopStart: () => controller.abort(),
389
+ };
390
+
391
+ const manager = new SubagentManager();
392
+ const err = await manager
393
+ .spawnAndAwait(makeConfig(), () => {}, { signal: controller.signal })
394
+ .then(
395
+ () => undefined,
396
+ (e) => e,
397
+ );
398
+ expect(err).toBeInstanceOf(SubagentAbortedError);
399
+ expect((err as SubagentAbortedError).partialText).toContain(
400
+ "partial advice so far",
401
+ );
402
+ });
403
+
404
+ test("an already-aborted signal does not run the agent loop", async () => {
405
+ nextConversationConfig = { resolveOnAbort: true };
406
+ runLoopInvoked = false;
407
+
408
+ const controller = new AbortController();
409
+ controller.abort();
410
+
411
+ const events: ServerMessage[] = [];
412
+ const manager = new SubagentManager();
413
+ await expect(
414
+ manager.spawnAndAwait(makeConfig(), (msg) => events.push(msg), {
415
+ signal: controller.signal,
416
+ }),
417
+ ).rejects.toThrow();
418
+
419
+ // The early-return guard fires before setStatus("running") and before the
420
+ // agent loop starts: no loop invocation, no "running"/"completed" broadcast.
421
+ expect(runLoopInvoked).toBe(false);
422
+ const statuses = broadcastStatuses(events);
423
+ expect(statuses).not.toContain("running");
424
+ expect(statuses).not.toContain("completed");
425
+ expect(statuses).toContain("aborted");
426
+ });
427
+
428
+ test("a failing run rejects (does not silently resolve)", async () => {
429
+ nextConversationConfig = { runError: new Error("boom") };
430
+
431
+ const manager = new SubagentManager();
432
+ await expect(manager.spawnAndAwait(makeConfig(), () => {})).rejects.toThrow(
433
+ "boom",
434
+ );
435
+ });
436
+ });
437
+
438
+ describe("SubagentManager — first user message framing", () => {
439
+ const advisorTrailingText = {
440
+ messages: [
441
+ {
442
+ role: "assistant" as const,
443
+ content: [{ type: "text" as const, text: "advice" }],
444
+ },
445
+ ],
446
+ };
447
+
448
+ beforeEach(() => {
449
+ lastPersistedUserMessage = undefined;
450
+ });
451
+
452
+ test("advisor consult sends the bare advice request (no FORK TASK wrapper)", async () => {
453
+ nextConversationConfig = advisorTrailingText;
454
+
455
+ const manager = new SubagentManager();
456
+ await manager.spawnAndAwait(
457
+ makeConfig({
458
+ objective: "Please advise.",
459
+ fork: true,
460
+ role: "advisor",
461
+ // The advisor always supplies its own framing; setUpSubagent uses it
462
+ // verbatim and never falls back to parentSystemPrompt.
463
+ systemPromptOverride: "You are a senior advisor.",
464
+ parentMessages: [
465
+ { role: "user", content: [{ type: "text", text: "prior turn" }] },
466
+ ],
467
+ }),
468
+ () => {},
469
+ );
470
+
471
+ // The advisor's user turn is the bare advice request — the generic fork
472
+ // directive would fight the advisor system prompt.
473
+ expect(lastPersistedUserMessage).toBe("Please advise.");
474
+ expect(lastPersistedUserMessage).not.toContain("FORK TASK");
475
+ });
476
+
477
+ test("a non-advisor fork still wraps the objective in FORK TASK framing", async () => {
478
+ nextConversationConfig = advisorTrailingText;
479
+
480
+ const manager = new SubagentManager();
481
+ await manager.spawnAndAwait(
482
+ makeConfig({
483
+ objective: "Investigate the bug.",
484
+ fork: true,
485
+ parentSystemPrompt: "Parent prompt.",
486
+ parentMessages: [
487
+ { role: "user", content: [{ type: "text", text: "prior turn" }] },
488
+ ],
489
+ }),
490
+ () => {},
491
+ );
492
+
493
+ expect(lastPersistedUserMessage).toContain("FORK TASK");
494
+ expect(lastPersistedUserMessage).toContain("Investigate the bug.");
495
+ });
496
+
497
+ test("a non-fork subagent sends the bare objective (no FORK TASK wrapper)", async () => {
498
+ nextConversationConfig = advisorTrailingText;
499
+
500
+ const manager = new SubagentManager();
501
+ await manager.spawnAndAwait(
502
+ makeConfig({ objective: "Do the thing." }),
503
+ () => {},
504
+ );
505
+
506
+ expect(lastPersistedUserMessage).toBe("Do the thing.");
507
+ expect(lastPersistedUserMessage).not.toContain("FORK TASK");
508
+ });
509
+ });
510
+
511
+ describe("SubagentManager.spawn (fire-and-forget) — unaffected", () => {
512
+ test("spawn returns the subagent id synchronously and does not throw on a normal run", async () => {
513
+ nextConversationConfig = {
514
+ messages: [
515
+ { role: "assistant", content: [{ type: "text", text: "ok" }] },
516
+ ],
517
+ };
518
+
519
+ const manager = new SubagentManager();
520
+ const id = await manager.spawn(makeConfig(), () => {});
521
+
522
+ expect(typeof id).toBe("string");
523
+ expect(id.length).toBeGreaterThan(0);
524
+ });
525
+
526
+ test("spawn still injects a terminal notification into the parent", async () => {
527
+ clearConversations();
528
+ const cfg = makeConfig();
529
+ const parent = registerFakeParent(cfg.parentConversationId);
530
+
531
+ nextConversationConfig = {
532
+ messages: [
533
+ { role: "assistant", content: [{ type: "text", text: "ok" }] },
534
+ ],
535
+ };
536
+
537
+ const manager = new SubagentManager();
538
+ await manager.spawn(cfg, () => {});
539
+
540
+ // The run kicks off asynchronously; let the microtask/macrotask queue drain.
541
+ await new Promise((resolve) => setTimeout(resolve, 10));
542
+
543
+ expect(parent.enqueuedCount()).toBeGreaterThan(0);
544
+ clearConversations();
545
+ });
546
+ });