vellum 0.2.13 → 0.2.14

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 (207) hide show
  1. package/README.md +32 -0
  2. package/bun.lock +2 -2
  3. package/docs/skills.md +4 -4
  4. package/package.json +2 -2
  5. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
  6. package/src/__tests__/app-git-history.test.ts +176 -0
  7. package/src/__tests__/app-git-service.test.ts +169 -0
  8. package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
  9. package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
  10. package/src/__tests__/browser-skill-endstate.test.ts +6 -6
  11. package/src/__tests__/call-bridge.test.ts +105 -13
  12. package/src/__tests__/call-domain.test.ts +163 -0
  13. package/src/__tests__/call-orchestrator.test.ts +113 -0
  14. package/src/__tests__/call-routes-http.test.ts +246 -6
  15. package/src/__tests__/channel-approval-routes.test.ts +438 -0
  16. package/src/__tests__/channel-approval.test.ts +266 -0
  17. package/src/__tests__/channel-approvals.test.ts +393 -0
  18. package/src/__tests__/channel-delivery-store.test.ts +447 -0
  19. package/src/__tests__/checker.test.ts +607 -1048
  20. package/src/__tests__/cli.test.ts +1 -56
  21. package/src/__tests__/config-schema.test.ts +137 -18
  22. package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
  23. package/src/__tests__/conflict-policy.test.ts +121 -0
  24. package/src/__tests__/conflict-store.test.ts +2 -0
  25. package/src/__tests__/contacts-tools.test.ts +3 -3
  26. package/src/__tests__/contradiction-checker.test.ts +99 -1
  27. package/src/__tests__/credential-security-invariants.test.ts +22 -6
  28. package/src/__tests__/credential-vault-unit.test.ts +780 -0
  29. package/src/__tests__/elevenlabs-client.test.ts +62 -0
  30. package/src/__tests__/ephemeral-permissions.test.ts +73 -23
  31. package/src/__tests__/filesystem-tools.test.ts +579 -0
  32. package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
  33. package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
  34. package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
  35. package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
  36. package/src/__tests__/handlers-slack-config.test.ts +2 -1
  37. package/src/__tests__/handlers-telegram-config.test.ts +855 -0
  38. package/src/__tests__/handlers-twitter-config.test.ts +141 -1
  39. package/src/__tests__/hooks-runner.test.ts +6 -2
  40. package/src/__tests__/host-file-edit-tool.test.ts +124 -0
  41. package/src/__tests__/host-file-read-tool.test.ts +62 -0
  42. package/src/__tests__/host-file-write-tool.test.ts +59 -0
  43. package/src/__tests__/host-shell-tool.test.ts +251 -0
  44. package/src/__tests__/ingress-reconcile.test.ts +581 -0
  45. package/src/__tests__/ipc-snapshot.test.ts +100 -41
  46. package/src/__tests__/ipc-validate.test.ts +50 -0
  47. package/src/__tests__/key-migration.test.ts +23 -0
  48. package/src/__tests__/memory-regressions.test.ts +99 -0
  49. package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
  50. package/src/__tests__/oauth-callback-registry.test.ts +11 -4
  51. package/src/__tests__/playbook-execution.test.ts +502 -0
  52. package/src/__tests__/playbook-tools.test.ts +4 -6
  53. package/src/__tests__/public-ingress-urls.test.ts +34 -0
  54. package/src/__tests__/qdrant-manager.test.ts +267 -0
  55. package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
  56. package/src/__tests__/recurrence-engine.test.ts +9 -0
  57. package/src/__tests__/recurrence-types.test.ts +8 -0
  58. package/src/__tests__/registry.test.ts +1 -1
  59. package/src/__tests__/runtime-runs.test.ts +1 -25
  60. package/src/__tests__/schedule-store.test.ts +16 -14
  61. package/src/__tests__/schedule-tools.test.ts +83 -0
  62. package/src/__tests__/scheduler-recurrence.test.ts +111 -10
  63. package/src/__tests__/secret-allowlist.test.ts +18 -17
  64. package/src/__tests__/secret-ingress-handler.test.ts +11 -0
  65. package/src/__tests__/secret-scanner.test.ts +43 -0
  66. package/src/__tests__/session-conflict-gate.test.ts +442 -6
  67. package/src/__tests__/session-init.benchmark.test.ts +3 -0
  68. package/src/__tests__/session-process-bridge.test.ts +242 -0
  69. package/src/__tests__/session-skill-tools.test.ts +1 -1
  70. package/src/__tests__/shell-identity.test.ts +256 -0
  71. package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
  72. package/src/__tests__/subagent-tools.test.ts +637 -54
  73. package/src/__tests__/task-management-tools.test.ts +936 -0
  74. package/src/__tests__/task-runner.test.ts +2 -2
  75. package/src/__tests__/terminal-tools.test.ts +840 -0
  76. package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
  77. package/src/__tests__/tool-executor.test.ts +85 -151
  78. package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
  79. package/src/__tests__/trust-store.test.ts +27 -453
  80. package/src/__tests__/twilio-provider.test.ts +153 -3
  81. package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
  82. package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
  83. package/src/__tests__/twilio-routes.test.ts +17 -262
  84. package/src/__tests__/twitter-auth-handler.test.ts +2 -1
  85. package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
  86. package/src/__tests__/twitter-cli-routing.test.ts +252 -0
  87. package/src/__tests__/twitter-oauth-client.test.ts +209 -0
  88. package/src/__tests__/workspace-policy.test.ts +213 -0
  89. package/src/calls/call-bridge.ts +92 -19
  90. package/src/calls/call-domain.ts +157 -5
  91. package/src/calls/call-orchestrator.ts +93 -7
  92. package/src/calls/call-store.ts +6 -0
  93. package/src/calls/elevenlabs-client.ts +8 -0
  94. package/src/calls/elevenlabs-config.ts +7 -5
  95. package/src/calls/twilio-provider.ts +91 -0
  96. package/src/calls/twilio-routes.ts +32 -37
  97. package/src/calls/types.ts +3 -1
  98. package/src/calls/voice-quality.ts +29 -7
  99. package/src/cli/twitter.ts +200 -21
  100. package/src/cli.ts +1 -20
  101. package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
  102. package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
  103. package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
  104. package/src/config/bundled-skills/messaging/SKILL.md +17 -2
  105. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
  106. package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
  107. package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
  108. package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
  109. package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
  110. package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
  111. package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
  112. package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
  113. package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
  114. package/src/config/bundled-skills/twitter/SKILL.md +103 -17
  115. package/src/config/defaults.ts +10 -4
  116. package/src/config/schema.ts +80 -21
  117. package/src/config/types.ts +1 -0
  118. package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
  119. package/src/daemon/assistant-attachments.ts +4 -2
  120. package/src/daemon/handlers/apps.ts +69 -0
  121. package/src/daemon/handlers/config.ts +543 -24
  122. package/src/daemon/handlers/index.ts +1 -0
  123. package/src/daemon/handlers/sessions.ts +22 -6
  124. package/src/daemon/handlers/shared.ts +2 -1
  125. package/src/daemon/handlers/skills.ts +5 -20
  126. package/src/daemon/ipc-contract-inventory.json +28 -0
  127. package/src/daemon/ipc-contract.ts +168 -10
  128. package/src/daemon/ipc-validate.ts +17 -0
  129. package/src/daemon/lifecycle.ts +2 -0
  130. package/src/daemon/server.ts +78 -72
  131. package/src/daemon/session-attachments.ts +1 -1
  132. package/src/daemon/session-conflict-gate.ts +62 -6
  133. package/src/daemon/session-notifiers.ts +1 -1
  134. package/src/daemon/session-process.ts +62 -3
  135. package/src/daemon/session-tool-setup.ts +1 -2
  136. package/src/daemon/tls-certs.ts +189 -0
  137. package/src/daemon/video-thumbnail.ts +5 -3
  138. package/src/hooks/manager.ts +5 -9
  139. package/src/memory/app-git-service.ts +295 -0
  140. package/src/memory/app-store.ts +21 -0
  141. package/src/memory/conflict-intent.ts +47 -4
  142. package/src/memory/conflict-policy.ts +73 -0
  143. package/src/memory/conflict-store.ts +9 -1
  144. package/src/memory/contradiction-checker.ts +28 -0
  145. package/src/memory/conversation-key-store.ts +15 -0
  146. package/src/memory/db.ts +81 -0
  147. package/src/memory/embedding-local.ts +3 -13
  148. package/src/memory/external-conversation-store.ts +234 -0
  149. package/src/memory/job-handlers/conflict.ts +22 -2
  150. package/src/memory/jobs-worker.ts +67 -28
  151. package/src/memory/runs-store.ts +54 -7
  152. package/src/memory/schema.ts +20 -0
  153. package/src/messaging/provider.ts +9 -0
  154. package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
  155. package/src/messaging/providers/telegram-bot/client.ts +104 -0
  156. package/src/messaging/providers/telegram-bot/types.ts +15 -0
  157. package/src/messaging/registry.ts +1 -0
  158. package/src/permissions/checker.ts +48 -44
  159. package/src/permissions/prompter.ts +0 -4
  160. package/src/permissions/shell-identity.ts +227 -0
  161. package/src/permissions/trust-store.ts +76 -53
  162. package/src/permissions/types.ts +0 -19
  163. package/src/permissions/workspace-policy.ts +114 -0
  164. package/src/providers/retry.ts +12 -37
  165. package/src/runtime/assistant-event-hub.ts +41 -4
  166. package/src/runtime/channel-approval-parser.ts +60 -0
  167. package/src/runtime/channel-approval-types.ts +71 -0
  168. package/src/runtime/channel-approvals.ts +145 -0
  169. package/src/runtime/gateway-client.ts +16 -0
  170. package/src/runtime/http-server.ts +29 -9
  171. package/src/runtime/routes/call-routes.ts +52 -2
  172. package/src/runtime/routes/channel-routes.ts +296 -16
  173. package/src/runtime/routes/events-routes.ts +97 -28
  174. package/src/runtime/routes/run-routes.ts +2 -7
  175. package/src/runtime/run-orchestrator.ts +0 -3
  176. package/src/schedule/recurrence-engine.ts +26 -2
  177. package/src/schedule/recurrence-types.ts +1 -1
  178. package/src/schedule/schedule-store.ts +12 -3
  179. package/src/security/secret-scanner.ts +7 -0
  180. package/src/tasks/ephemeral-permissions.ts +0 -2
  181. package/src/tasks/task-scheduler.ts +2 -1
  182. package/src/tools/calls/call-start.ts +8 -0
  183. package/src/tools/execution-target.ts +21 -0
  184. package/src/tools/execution-timeout.ts +49 -0
  185. package/src/tools/executor.ts +6 -135
  186. package/src/tools/network/web-search.ts +9 -32
  187. package/src/tools/policy-context.ts +29 -0
  188. package/src/tools/schedule/update.ts +8 -1
  189. package/src/tools/terminal/parser.ts +16 -18
  190. package/src/tools/types.ts +4 -11
  191. package/src/twitter/oauth-client.ts +102 -0
  192. package/src/twitter/router.ts +101 -0
  193. package/src/util/debounce.ts +88 -0
  194. package/src/util/network-info.ts +47 -0
  195. package/src/util/platform.ts +29 -4
  196. package/src/util/promise-guard.ts +37 -0
  197. package/src/util/retry.ts +98 -0
  198. package/src/util/truncate.ts +1 -1
  199. package/src/workspace/git-service.ts +129 -112
  200. package/src/tools/contacts/contact-merge.ts +0 -55
  201. package/src/tools/contacts/contact-search.ts +0 -58
  202. package/src/tools/contacts/contact-upsert.ts +0 -64
  203. package/src/tools/playbooks/index.ts +0 -4
  204. package/src/tools/playbooks/playbook-create.ts +0 -96
  205. package/src/tools/playbooks/playbook-delete.ts +0 -52
  206. package/src/tools/playbooks/playbook-list.ts +0 -74
  207. package/src/tools/playbooks/playbook-update.ts +0 -111
@@ -1,6 +1,14 @@
1
- import { describe, expect, test } from 'bun:test';
1
+ import { describe, expect, mock, test } from 'bun:test';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
+
5
+ // Mock conversation-store before importing tool executors that depend on it.
6
+ let mockGetMessages: (conversationId: string) => Array<{ role: string; content: string }> | null = () => null;
7
+ mock.module('../memory/conversation-store.js', () => ({
8
+ getMessages: (conversationId: string) => mockGetMessages(conversationId),
9
+ createConversation: () => ({ id: 'mock-conv' }),
10
+ }));
11
+
4
12
  import { executeSubagentSpawn } from '../tools/subagent/spawn.js';
5
13
  import { executeSubagentStatus } from '../tools/subagent/status.js';
6
14
  import { executeSubagentAbort } from '../tools/subagent/abort.js';
@@ -8,6 +16,7 @@ import { executeSubagentMessage } from '../tools/subagent/message.js';
8
16
  import { executeSubagentRead } from '../tools/subagent/read.js';
9
17
  import { SubagentManager } from '../subagent/manager.js';
10
18
  import type { SubagentState } from '../subagent/types.js';
19
+ import { getSubagentManager } from '../subagent/index.js';
11
20
 
12
21
  // Load tool definitions from the bundled skill TOOLS.json
13
22
  const toolsJson = JSON.parse(
@@ -15,6 +24,55 @@ const toolsJson = JSON.parse(
15
24
  );
16
25
  const findTool = (name: string) => toolsJson.tools.find((t: { name: string }) => t.name === name);
17
26
 
27
+ // ── Shared helpers ──────────────────────────────────────────────────
28
+
29
+ /**
30
+ * Inject a fake subagent into the singleton manager so tool executors
31
+ * can find it. Uses the same private-internals trick as the notify tests.
32
+ */
33
+ function injectSubagent(
34
+ manager: SubagentManager,
35
+ subagentId: string,
36
+ parentSessionId: string,
37
+ status: SubagentState['status'] = 'running',
38
+ overrides: Partial<SubagentState> = {},
39
+ ): SubagentState {
40
+ const internals = manager as unknown as {
41
+ subagents: Map<string, { session: unknown; state: SubagentState; parentSendToClient: () => void }>;
42
+ parentToChildren: Map<string, Set<string>>;
43
+ };
44
+ const state: SubagentState = {
45
+ config: { id: subagentId, parentSessionId, label: 'Test', objective: 'test' },
46
+ status,
47
+ conversationId: `conv-${subagentId}`,
48
+ createdAt: Date.now(),
49
+ usage: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
50
+ ...overrides,
51
+ };
52
+ const fakeSession = {
53
+ abort: () => {},
54
+ dispose: () => {},
55
+ messages: [],
56
+ sendToClient: () => {},
57
+ usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
58
+ enqueueMessage: () => ({ queued: false, rejected: false }),
59
+ persistUserMessage: () => 'msg-1',
60
+ runAgentLoop: async () => {},
61
+ };
62
+ internals.subagents.set(subagentId, { session: fakeSession, state, parentSendToClient: () => {} });
63
+ if (!internals.parentToChildren.has(parentSessionId)) {
64
+ internals.parentToChildren.set(parentSessionId, new Set());
65
+ }
66
+ internals.parentToChildren.get(parentSessionId)!.add(subagentId);
67
+ return state;
68
+ }
69
+
70
+ function makeContext(sessionId: string, extras: Record<string, unknown> = {}) {
71
+ return { workingDir: '/tmp', sessionId, conversationId: 'conv-1', ...extras };
72
+ }
73
+
74
+ // ── Tool definitions ────────────────────────────────────────────────
75
+
18
76
  describe('Subagent tool definitions', () => {
19
77
  test('spawn tool has correct definition', () => {
20
78
  const def = findTool('subagent_spawn');
@@ -39,13 +97,21 @@ describe('Subagent tool definitions', () => {
39
97
  expect(def).toBeDefined();
40
98
  expect(def.input_schema.required).toEqual(['subagent_id']);
41
99
  });
100
+
101
+ test('status tool has correct definition', () => {
102
+ const def = findTool('subagent_status');
103
+ expect(def).toBeDefined();
104
+ expect(def.input_schema.required).toEqual([]);
105
+ });
42
106
  });
43
107
 
108
+ // ── Input validation ────────────────────────────────────────────────
109
+
44
110
  describe('Subagent tool execute validation', () => {
45
111
  test('spawn returns error when no sendToClient', async () => {
46
112
  const result = await executeSubagentSpawn(
47
113
  { label: 'test', objective: 'do something' },
48
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
114
+ makeContext('sess-1'),
49
115
  );
50
116
  expect(result.isError).toBe(true);
51
117
  expect(result.content).toContain('No IPC client');
@@ -54,7 +120,25 @@ describe('Subagent tool execute validation', () => {
54
120
  test('spawn returns error when missing label', async () => {
55
121
  const result = await executeSubagentSpawn(
56
122
  { objective: 'do something' },
57
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1', sendToClient: () => {} },
123
+ makeContext('sess-1', { sendToClient: () => {} }),
124
+ );
125
+ expect(result.isError).toBe(true);
126
+ expect(result.content).toContain('required');
127
+ });
128
+
129
+ test('spawn returns error when missing objective', async () => {
130
+ const result = await executeSubagentSpawn(
131
+ { label: 'test' },
132
+ makeContext('sess-1', { sendToClient: () => {} }),
133
+ );
134
+ expect(result.isError).toBe(true);
135
+ expect(result.content).toContain('required');
136
+ });
137
+
138
+ test('spawn returns error when both label and objective missing', async () => {
139
+ const result = await executeSubagentSpawn(
140
+ {},
141
+ makeContext('sess-1', { sendToClient: () => {} }),
58
142
  );
59
143
  expect(result.isError).toBe(true);
60
144
  expect(result.content).toContain('required');
@@ -63,7 +147,7 @@ describe('Subagent tool execute validation', () => {
63
147
  test('status returns empty when no subagents', async () => {
64
148
  const result = await executeSubagentStatus(
65
149
  {},
66
- { workingDir: '/tmp', sessionId: 'nonexistent-session', conversationId: 'conv-1' },
150
+ makeContext('nonexistent-session'),
67
151
  );
68
152
  expect(result.isError).toBe(false);
69
153
  expect(result.content).toContain('No subagents found');
@@ -72,7 +156,7 @@ describe('Subagent tool execute validation', () => {
72
156
  test('status returns error for unknown subagent_id', async () => {
73
157
  const result = await executeSubagentStatus(
74
158
  { subagent_id: 'nonexistent-id' },
75
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
159
+ makeContext('sess-1'),
76
160
  );
77
161
  expect(result.isError).toBe(true);
78
162
  expect(result.content).toContain('No subagent found');
@@ -81,7 +165,7 @@ describe('Subagent tool execute validation', () => {
81
165
  test('abort returns error for unknown subagent_id', async () => {
82
166
  const result = await executeSubagentAbort(
83
167
  { subagent_id: 'nonexistent-id' },
84
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
168
+ makeContext('sess-1'),
85
169
  );
86
170
  expect(result.isError).toBe(true);
87
171
  expect(result.content).toContain('Could not abort');
@@ -90,7 +174,7 @@ describe('Subagent tool execute validation', () => {
90
174
  test('abort returns error when missing subagent_id', async () => {
91
175
  const result = await executeSubagentAbort(
92
176
  {},
93
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
177
+ makeContext('sess-1'),
94
178
  );
95
179
  expect(result.isError).toBe(true);
96
180
  expect(result.content).toContain('required');
@@ -99,7 +183,7 @@ describe('Subagent tool execute validation', () => {
99
183
  test('message returns error for unknown subagent_id', async () => {
100
184
  const result = await executeSubagentMessage(
101
185
  { subagent_id: 'nonexistent-id', content: 'hello' },
102
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
186
+ makeContext('sess-1'),
103
187
  );
104
188
  expect(result.isError).toBe(true);
105
189
  expect(result.content).toContain('Could not send');
@@ -108,65 +192,54 @@ describe('Subagent tool execute validation', () => {
108
192
  test('message returns error when missing required fields', async () => {
109
193
  const result = await executeSubagentMessage(
110
194
  { subagent_id: 'some-id' },
111
- { workingDir: '/tmp', sessionId: 'sess-1', conversationId: 'conv-1' },
195
+ makeContext('sess-1'),
196
+ );
197
+ expect(result.isError).toBe(true);
198
+ expect(result.content).toContain('required');
199
+ });
200
+
201
+ test('message returns error when missing subagent_id', async () => {
202
+ const result = await executeSubagentMessage(
203
+ { content: 'hello' },
204
+ makeContext('sess-1'),
112
205
  );
113
206
  expect(result.isError).toBe(true);
114
207
  expect(result.content).toContain('required');
115
208
  });
116
- });
117
209
 
118
- // ── Ownership validation tests ──────────────────────────────────────
210
+ test('read returns error when missing subagent_id', async () => {
211
+ const result = await executeSubagentRead(
212
+ {},
213
+ makeContext('sess-1'),
214
+ );
215
+ expect(result.isError).toBe(true);
216
+ expect(result.content).toContain('required');
217
+ });
119
218
 
120
- /**
121
- * Inject a fake subagent into the singleton manager so tool executors
122
- * can find it. Uses the same private-internals trick as the notify tests.
123
- */
124
- function injectSubagent(
125
- manager: SubagentManager,
126
- subagentId: string,
127
- parentSessionId: string,
128
- status: SubagentState['status'] = 'running',
129
- ): void {
130
- const internals = manager as unknown as {
131
- subagents: Map<string, { session: unknown; state: SubagentState; parentSendToClient: () => void }>;
132
- parentToChildren: Map<string, Set<string>>;
133
- };
134
- const state: SubagentState = {
135
- config: { id: subagentId, parentSessionId, label: 'Test', objective: 'test' },
136
- status,
137
- conversationId: `conv-${subagentId}`,
138
- createdAt: Date.now(),
139
- usage: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
140
- };
141
- const fakeSession = {
142
- abort: () => {},
143
- dispose: () => {},
144
- messages: [],
145
- sendToClient: () => {},
146
- usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 },
147
- };
148
- internals.subagents.set(subagentId, { session: fakeSession, state, parentSendToClient: () => {} });
149
- if (!internals.parentToChildren.has(parentSessionId)) {
150
- internals.parentToChildren.set(parentSessionId, new Set());
151
- }
152
- internals.parentToChildren.get(parentSessionId)!.add(subagentId);
153
- }
219
+ test('read returns error for unknown subagent_id', async () => {
220
+ const result = await executeSubagentRead(
221
+ { subagent_id: 'nonexistent-id' },
222
+ makeContext('sess-1'),
223
+ );
224
+ expect(result.isError).toBe(true);
225
+ expect(result.content).toContain('No subagent found');
226
+ });
227
+ });
154
228
 
155
- import { getSubagentManager } from '../subagent/index.js';
229
+ // ── Ownership validation ────────────────────────────────────────────
156
230
 
157
231
  describe('Subagent tool ownership validation', () => {
158
232
  const ownerSession = 'owner-sess';
159
233
  const otherSession = 'other-sess';
160
234
  const subagentId = 'owned-sub-1';
161
235
 
162
- // Inject once — all tests share this subagent.
163
236
  const manager = getSubagentManager();
164
237
  injectSubagent(manager, subagentId, ownerSession);
165
238
 
166
239
  test('status rejects non-owner session', async () => {
167
240
  const result = await executeSubagentStatus(
168
241
  { subagent_id: subagentId },
169
- { workingDir: '/tmp', sessionId: otherSession, conversationId: 'conv-1' },
242
+ makeContext(otherSession),
170
243
  );
171
244
  expect(result.isError).toBe(true);
172
245
  expect(result.content).toContain('No subagent found');
@@ -175,7 +248,7 @@ describe('Subagent tool ownership validation', () => {
175
248
  test('status succeeds for owner session', async () => {
176
249
  const result = await executeSubagentStatus(
177
250
  { subagent_id: subagentId },
178
- { workingDir: '/tmp', sessionId: ownerSession, conversationId: 'conv-1' },
251
+ makeContext(ownerSession),
179
252
  );
180
253
  expect(result.isError).toBe(false);
181
254
  });
@@ -183,7 +256,7 @@ describe('Subagent tool ownership validation', () => {
183
256
  test('message rejects non-owner session', async () => {
184
257
  const result = await executeSubagentMessage(
185
258
  { subagent_id: subagentId, content: 'hello' },
186
- { workingDir: '/tmp', sessionId: otherSession, conversationId: 'conv-1' },
259
+ makeContext(otherSession),
187
260
  );
188
261
  expect(result.isError).toBe(true);
189
262
  expect(result.content).toContain('Could not send');
@@ -192,7 +265,7 @@ describe('Subagent tool ownership validation', () => {
192
265
  test('read rejects non-owner session', async () => {
193
266
  const result = await executeSubagentRead(
194
267
  { subagent_id: subagentId },
195
- { workingDir: '/tmp', sessionId: otherSession, conversationId: 'conv-1' },
268
+ makeContext(otherSession),
196
269
  );
197
270
  expect(result.isError).toBe(true);
198
271
  expect(result.content).toContain('No subagent found');
@@ -201,7 +274,7 @@ describe('Subagent tool ownership validation', () => {
201
274
  test('abort rejects non-owner session', async () => {
202
275
  const result = await executeSubagentAbort(
203
276
  { subagent_id: subagentId },
204
- { workingDir: '/tmp', sessionId: otherSession, conversationId: 'conv-1' },
277
+ makeContext(otherSession),
205
278
  );
206
279
  expect(result.isError).toBe(true);
207
280
  expect(result.content).toContain('Could not abort');
@@ -210,9 +283,519 @@ describe('Subagent tool ownership validation', () => {
210
283
  test('abort succeeds for owner session', async () => {
211
284
  const result = await executeSubagentAbort(
212
285
  { subagent_id: subagentId },
213
- { workingDir: '/tmp', sessionId: ownerSession, conversationId: 'conv-1' },
286
+ makeContext(ownerSession),
287
+ );
288
+ expect(result.isError).toBe(false);
289
+ });
290
+ });
291
+
292
+ // ── Spawn success/failure paths ─────────────────────────────────────
293
+
294
+ describe('Subagent spawn success and failure', () => {
295
+ test('spawn returns subagentId and pending status on success', async () => {
296
+ const manager = getSubagentManager();
297
+ const originalSpawn = manager.spawn.bind(manager);
298
+ manager.spawn = async () => 'mock-subagent-id';
299
+
300
+ try {
301
+ const result = await executeSubagentSpawn(
302
+ { label: 'Research task', objective: 'Find pricing data' },
303
+ makeContext('sess-spawn-1', { sendToClient: () => {} }),
304
+ );
305
+ expect(result.isError).toBe(false);
306
+ const parsed = JSON.parse(result.content);
307
+ expect(parsed.subagentId).toBe('mock-subagent-id');
308
+ expect(parsed.label).toBe('Research task');
309
+ expect(parsed.status).toBe('pending');
310
+ expect(parsed.message).toContain('spawned');
311
+ } finally {
312
+ manager.spawn = originalSpawn;
313
+ }
314
+ });
315
+
316
+ test('spawn returns error when manager.spawn throws', async () => {
317
+ const manager = getSubagentManager();
318
+ const originalSpawn = manager.spawn.bind(manager);
319
+ manager.spawn = async () => { throw new Error('Cannot spawn subagent: parent is itself a subagent'); };
320
+
321
+ try {
322
+ const result = await executeSubagentSpawn(
323
+ { label: 'Nested spawn', objective: 'Should fail' },
324
+ makeContext('sess-spawn-2', { sendToClient: () => {} }),
325
+ );
326
+ expect(result.isError).toBe(true);
327
+ expect(result.content).toContain('Failed to spawn subagent');
328
+ expect(result.content).toContain('parent is itself a subagent');
329
+ } finally {
330
+ manager.spawn = originalSpawn;
331
+ }
332
+ });
333
+
334
+ test('spawn passes context to manager', async () => {
335
+ const manager = getSubagentManager();
336
+ const originalSpawn = manager.spawn.bind(manager);
337
+ let capturedConfig: Record<string, unknown> | undefined;
338
+
339
+ manager.spawn = async (config: Record<string, unknown>) => {
340
+ capturedConfig = config;
341
+ return 'ctx-subagent-id';
342
+ };
343
+
344
+ try {
345
+ await executeSubagentSpawn(
346
+ { label: 'Context test', objective: 'Do it', context: 'Extra info here' },
347
+ makeContext('sess-spawn-3', { sendToClient: () => {} }),
348
+ );
349
+ expect(capturedConfig).toBeDefined();
350
+ expect(capturedConfig!.label).toBe('Context test');
351
+ expect(capturedConfig!.objective).toBe('Do it');
352
+ expect(capturedConfig!.context).toBe('Extra info here');
353
+ expect(capturedConfig!.parentSessionId).toBe('sess-spawn-3');
354
+ } finally {
355
+ manager.spawn = originalSpawn;
356
+ }
357
+ });
358
+
359
+ test('spawn handles non-Error throws gracefully', async () => {
360
+ const manager = getSubagentManager();
361
+ const originalSpawn = manager.spawn.bind(manager);
362
+ manager.spawn = async () => { throw 'string error'; };
363
+
364
+ try {
365
+ const result = await executeSubagentSpawn(
366
+ { label: 'Bad spawn', objective: 'Fail oddly' },
367
+ makeContext('sess-spawn-4', { sendToClient: () => {} }),
368
+ );
369
+ expect(result.isError).toBe(true);
370
+ expect(result.content).toContain('Failed to spawn subagent');
371
+ expect(result.content).toContain('string error');
372
+ } finally {
373
+ manager.spawn = originalSpawn;
374
+ }
375
+ });
376
+ });
377
+
378
+ // ── Message success path ────────────────────────────────────────────
379
+
380
+ describe('Subagent message success path', () => {
381
+ const ownerSession = 'msg-owner-sess';
382
+ const subagentId = 'msg-sub-1';
383
+
384
+ test('message succeeds for owner session with running subagent', async () => {
385
+ const manager = getSubagentManager();
386
+ injectSubagent(manager, subagentId, ownerSession, 'running');
387
+
388
+ const result = await executeSubagentMessage(
389
+ { subagent_id: subagentId, content: 'Continue working on this' },
390
+ makeContext(ownerSession),
391
+ );
392
+ expect(result.isError).toBe(false);
393
+ const parsed = JSON.parse(result.content);
394
+ expect(parsed.subagentId).toBe(subagentId);
395
+ expect(parsed.message).toContain('Message sent');
396
+ });
397
+
398
+ test('message fails for terminal-state subagent', async () => {
399
+ const manager = getSubagentManager();
400
+ const completedId = 'msg-sub-completed';
401
+ injectSubagent(manager, completedId, ownerSession, 'completed');
402
+
403
+ const result = await executeSubagentMessage(
404
+ { subagent_id: completedId, content: 'Are you there?' },
405
+ makeContext(ownerSession),
406
+ );
407
+ expect(result.isError).toBe(true);
408
+ expect(result.content).toContain('Could not send');
409
+ });
410
+ });
411
+
412
+ // ── Status detail responses ─────────────────────────────────────────
413
+
414
+ describe('Subagent status detail responses', () => {
415
+ const ownerSession = 'status-owner-sess';
416
+
417
+ test('individual status returns full detail fields', async () => {
418
+ const manager = getSubagentManager();
419
+ const subagentId = 'status-detail-1';
420
+ const now = Date.now();
421
+ injectSubagent(manager, subagentId, ownerSession, 'running', {
422
+ config: { id: subagentId, parentSessionId: ownerSession, label: 'Detail test', objective: 'test obj' },
423
+ createdAt: now,
424
+ startedAt: now + 10,
425
+ usage: { inputTokens: 500, outputTokens: 200, estimatedCost: 0.01 },
426
+ });
427
+
428
+ const result = await executeSubagentStatus(
429
+ { subagent_id: subagentId },
430
+ makeContext(ownerSession),
431
+ );
432
+ expect(result.isError).toBe(false);
433
+ const parsed = JSON.parse(result.content);
434
+ expect(parsed.subagentId).toBe(subagentId);
435
+ expect(parsed.label).toBe('Detail test');
436
+ expect(parsed.status).toBe('running');
437
+ expect(parsed.createdAt).toBe(now);
438
+ expect(parsed.startedAt).toBe(now + 10);
439
+ expect(parsed.usage.inputTokens).toBe(500);
440
+ expect(parsed.usage.outputTokens).toBe(200);
441
+ });
442
+
443
+ test('list status returns summary of all children', async () => {
444
+ const manager = getSubagentManager();
445
+ const listSession = 'status-list-sess';
446
+ injectSubagent(manager, 'list-sub-1', listSession, 'running');
447
+ injectSubagent(manager, 'list-sub-2', listSession, 'completed');
448
+
449
+ const result = await executeSubagentStatus(
450
+ {},
451
+ makeContext(listSession),
452
+ );
453
+ expect(result.isError).toBe(false);
454
+ const parsed = JSON.parse(result.content);
455
+ expect(Array.isArray(parsed)).toBe(true);
456
+ expect(parsed).toHaveLength(2);
457
+ const ids = parsed.map((s: { subagentId: string }) => s.subagentId);
458
+ expect(ids).toContain('list-sub-1');
459
+ expect(ids).toContain('list-sub-2');
460
+ });
461
+
462
+ test('individual status includes error field for failed subagent', async () => {
463
+ const manager = getSubagentManager();
464
+ const failedId = 'status-failed-1';
465
+ injectSubagent(manager, failedId, ownerSession, 'failed', {
466
+ error: 'Rate limit exceeded',
467
+ });
468
+
469
+ const result = await executeSubagentStatus(
470
+ { subagent_id: failedId },
471
+ makeContext(ownerSession),
472
+ );
473
+ expect(result.isError).toBe(false);
474
+ const parsed = JSON.parse(result.content);
475
+ expect(parsed.status).toBe('failed');
476
+ expect(parsed.error).toBe('Rate limit exceeded');
477
+ });
478
+ });
479
+
480
+ // ── Read tool behavior ──────────────────────────────────────────────
481
+
482
+ describe('Subagent read tool', () => {
483
+ const ownerSession = 'read-owner-sess';
484
+
485
+ test('read returns wait message for non-terminal subagent', async () => {
486
+ const manager = getSubagentManager();
487
+ const subagentId = 'read-running-1';
488
+ injectSubagent(manager, subagentId, ownerSession, 'running');
489
+
490
+ const result = await executeSubagentRead(
491
+ { subagent_id: subagentId },
492
+ makeContext(ownerSession),
493
+ );
494
+ expect(result.isError).toBe(false);
495
+ expect(result.content).toContain('still running');
496
+ expect(result.content).toContain('Wait');
497
+ });
498
+
499
+ test('read returns wait message for pending subagent', async () => {
500
+ const manager = getSubagentManager();
501
+ const subagentId = 'read-pending-1';
502
+ injectSubagent(manager, subagentId, ownerSession, 'pending');
503
+
504
+ const result = await executeSubagentRead(
505
+ { subagent_id: subagentId },
506
+ makeContext(ownerSession),
507
+ );
508
+ expect(result.isError).toBe(false);
509
+ expect(result.content).toContain('still pending');
510
+ });
511
+
512
+ test('read extracts text from JSON array content blocks', async () => {
513
+ const manager = getSubagentManager();
514
+ const subagentId = 'read-json-array-1';
515
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
516
+
517
+ mockGetMessages = (convId: string) => {
518
+ if (convId !== `conv-${subagentId}`) return null;
519
+ return [
520
+ { role: 'user', content: 'Do the thing' },
521
+ { role: 'assistant', content: JSON.stringify([{ type: 'text', text: 'Here is the result' }]) },
522
+ { role: 'assistant', content: JSON.stringify([{ type: 'text', text: 'And more details' }]) },
523
+ ];
524
+ };
525
+
526
+ try {
527
+ const result = await executeSubagentRead(
528
+ { subagent_id: subagentId },
529
+ makeContext(ownerSession),
530
+ );
531
+ expect(result.isError).toBe(false);
532
+ expect(result.content).toContain('Here is the result');
533
+ expect(result.content).toContain('And more details');
534
+ } finally {
535
+ mockGetMessages = () => null;
536
+ }
537
+ });
538
+
539
+ test('read handles plain text content', async () => {
540
+ const manager = getSubagentManager();
541
+ const subagentId = 'read-plain-1';
542
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
543
+
544
+ mockGetMessages = (convId: string) => {
545
+ if (convId !== `conv-${subagentId}`) return null;
546
+ return [
547
+ { role: 'assistant', content: 'Plain text response' },
548
+ ];
549
+ };
550
+
551
+ try {
552
+ const result = await executeSubagentRead(
553
+ { subagent_id: subagentId },
554
+ makeContext(ownerSession),
555
+ );
556
+ expect(result.isError).toBe(false);
557
+ expect(result.content).toBe('Plain text response');
558
+ } finally {
559
+ mockGetMessages = () => null;
560
+ }
561
+ });
562
+
563
+ test('read handles string JSON content', async () => {
564
+ const manager = getSubagentManager();
565
+ const subagentId = 'read-str-json-1';
566
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
567
+
568
+ mockGetMessages = (convId: string) => {
569
+ if (convId !== `conv-${subagentId}`) return null;
570
+ return [
571
+ { role: 'assistant', content: JSON.stringify('A JSON string value') },
572
+ ];
573
+ };
574
+
575
+ try {
576
+ const result = await executeSubagentRead(
577
+ { subagent_id: subagentId },
578
+ makeContext(ownerSession),
579
+ );
580
+ expect(result.isError).toBe(false);
581
+ expect(result.content).toBe('A JSON string value');
582
+ } finally {
583
+ mockGetMessages = () => null;
584
+ }
585
+ });
586
+
587
+ test('read skips non-text content blocks', async () => {
588
+ const manager = getSubagentManager();
589
+ const subagentId = 'read-skip-blocks-1';
590
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
591
+
592
+ mockGetMessages = (convId: string) => {
593
+ if (convId !== `conv-${subagentId}`) return null;
594
+ return [
595
+ {
596
+ role: 'assistant',
597
+ content: JSON.stringify([
598
+ { type: 'tool_use', id: 'tool-1', name: 'bash', input: {} },
599
+ { type: 'text', text: 'Actual output' },
600
+ ]),
601
+ },
602
+ ];
603
+ };
604
+
605
+ try {
606
+ const result = await executeSubagentRead(
607
+ { subagent_id: subagentId },
608
+ makeContext(ownerSession),
609
+ );
610
+ expect(result.isError).toBe(false);
611
+ expect(result.content).toBe('Actual output');
612
+ expect(result.content).not.toContain('tool_use');
613
+ } finally {
614
+ mockGetMessages = () => null;
615
+ }
616
+ });
617
+
618
+ test('read returns no-output message when only user/tool messages exist', async () => {
619
+ const manager = getSubagentManager();
620
+ const subagentId = 'read-no-output-1';
621
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
622
+
623
+ mockGetMessages = (convId: string) => {
624
+ if (convId !== `conv-${subagentId}`) return null;
625
+ return [
626
+ { role: 'user', content: 'Do something' },
627
+ { role: 'tool', content: 'tool result' },
628
+ ];
629
+ };
630
+
631
+ try {
632
+ const result = await executeSubagentRead(
633
+ { subagent_id: subagentId },
634
+ makeContext(ownerSession),
635
+ );
636
+ expect(result.isError).toBe(false);
637
+ expect(result.content).toContain('no text output');
638
+ } finally {
639
+ mockGetMessages = () => null;
640
+ }
641
+ });
642
+
643
+ test('read returns error when no messages in DB', async () => {
644
+ const manager = getSubagentManager();
645
+ const subagentId = 'read-empty-db-1';
646
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
647
+
648
+ mockGetMessages = () => [];
649
+
650
+ try {
651
+ const result = await executeSubagentRead(
652
+ { subagent_id: subagentId },
653
+ makeContext(ownerSession),
654
+ );
655
+ expect(result.isError).toBe(true);
656
+ expect(result.content).toContain('No messages found');
657
+ } finally {
658
+ mockGetMessages = () => null;
659
+ }
660
+ });
661
+
662
+ test('read returns error when getMessages returns null', async () => {
663
+ const manager = getSubagentManager();
664
+ const subagentId = 'read-null-db-1';
665
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
666
+
667
+ mockGetMessages = () => null;
668
+
669
+ const result = await executeSubagentRead(
670
+ { subagent_id: subagentId },
671
+ makeContext(ownerSession),
672
+ );
673
+ expect(result.isError).toBe(true);
674
+ expect(result.content).toContain('No messages found');
675
+ });
676
+
677
+ test('read works for failed subagent (terminal state)', async () => {
678
+ const manager = getSubagentManager();
679
+ const subagentId = 'read-failed-1';
680
+ injectSubagent(manager, subagentId, ownerSession, 'failed');
681
+
682
+ mockGetMessages = (convId: string) => {
683
+ if (convId !== `conv-${subagentId}`) return null;
684
+ return [
685
+ { role: 'assistant', content: JSON.stringify([{ type: 'text', text: 'Partial output before failure' }]) },
686
+ ];
687
+ };
688
+
689
+ try {
690
+ const result = await executeSubagentRead(
691
+ { subagent_id: subagentId },
692
+ makeContext(ownerSession),
693
+ );
694
+ expect(result.isError).toBe(false);
695
+ expect(result.content).toContain('Partial output before failure');
696
+ } finally {
697
+ mockGetMessages = () => null;
698
+ }
699
+ });
700
+
701
+ test('read works for aborted subagent (terminal state)', async () => {
702
+ const manager = getSubagentManager();
703
+ const subagentId = 'read-aborted-1';
704
+ injectSubagent(manager, subagentId, ownerSession, 'aborted');
705
+
706
+ mockGetMessages = (convId: string) => {
707
+ if (convId !== `conv-${subagentId}`) return null;
708
+ return [
709
+ { role: 'assistant', content: 'Output before abort' },
710
+ ];
711
+ };
712
+
713
+ try {
714
+ const result = await executeSubagentRead(
715
+ { subagent_id: subagentId },
716
+ makeContext(ownerSession),
717
+ );
718
+ expect(result.isError).toBe(false);
719
+ expect(result.content).toBe('Output before abort');
720
+ } finally {
721
+ mockGetMessages = () => null;
722
+ }
723
+ });
724
+
725
+ test('read concatenates multiple assistant messages', async () => {
726
+ const manager = getSubagentManager();
727
+ const subagentId = 'read-multi-1';
728
+ injectSubagent(manager, subagentId, ownerSession, 'completed');
729
+
730
+ mockGetMessages = (convId: string) => {
731
+ if (convId !== `conv-${subagentId}`) return null;
732
+ return [
733
+ { role: 'assistant', content: 'First response' },
734
+ { role: 'user', content: 'Follow up question' },
735
+ { role: 'assistant', content: 'Second response' },
736
+ { role: 'assistant', content: 'Third response' },
737
+ ];
738
+ };
739
+
740
+ try {
741
+ const result = await executeSubagentRead(
742
+ { subagent_id: subagentId },
743
+ makeContext(ownerSession),
744
+ );
745
+ expect(result.isError).toBe(false);
746
+ expect(result.content).toContain('First response');
747
+ expect(result.content).toContain('Second response');
748
+ expect(result.content).toContain('Third response');
749
+ // Messages are joined with double newline
750
+ expect(result.content).toBe('First response\n\nSecond response\n\nThird response');
751
+ } finally {
752
+ mockGetMessages = () => null;
753
+ }
754
+ });
755
+ });
756
+
757
+ // ── Abort success path details ──────────────────────────────────────
758
+
759
+ describe('Subagent abort success responses', () => {
760
+ test('abort returns subagentId and aborted status on success', async () => {
761
+ const manager = getSubagentManager();
762
+ const subagentId = 'abort-detail-1';
763
+ injectSubagent(manager, subagentId, 'abort-owner-sess', 'running');
764
+
765
+ const result = await executeSubagentAbort(
766
+ { subagent_id: subagentId },
767
+ makeContext('abort-owner-sess'),
214
768
  );
215
- // Abort succeeds (subagent was running)
216
769
  expect(result.isError).toBe(false);
770
+ const parsed = JSON.parse(result.content);
771
+ expect(parsed.subagentId).toBe(subagentId);
772
+ expect(parsed.status).toBe('aborted');
773
+ expect(parsed.message).toContain('aborted successfully');
774
+ });
775
+
776
+ test('abort fails for already-completed subagent', async () => {
777
+ const manager = getSubagentManager();
778
+ const subagentId = 'abort-completed-1';
779
+ injectSubagent(manager, subagentId, 'abort-owner-sess', 'completed');
780
+
781
+ const result = await executeSubagentAbort(
782
+ { subagent_id: subagentId },
783
+ makeContext('abort-owner-sess'),
784
+ );
785
+ expect(result.isError).toBe(true);
786
+ expect(result.content).toContain('Could not abort');
787
+ });
788
+
789
+ test('abort fails for already-failed subagent', async () => {
790
+ const manager = getSubagentManager();
791
+ const subagentId = 'abort-failed-1';
792
+ injectSubagent(manager, subagentId, 'abort-owner-sess', 'failed');
793
+
794
+ const result = await executeSubagentAbort(
795
+ { subagent_id: subagentId },
796
+ makeContext('abort-owner-sess'),
797
+ );
798
+ expect(result.isError).toBe(true);
799
+ expect(result.content).toContain('Could not abort');
217
800
  });
218
801
  });