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.
- package/README.md +32 -0
- package/bun.lock +2 -2
- package/docs/skills.md +4 -4
- package/package.json +2 -2
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +213 -3
- package/src/__tests__/app-git-history.test.ts +176 -0
- package/src/__tests__/app-git-service.test.ts +169 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +315 -0
- package/src/__tests__/browser-skill-baseline-tool-payload.test.ts +8 -8
- package/src/__tests__/browser-skill-endstate.test.ts +6 -6
- package/src/__tests__/call-bridge.test.ts +105 -13
- package/src/__tests__/call-domain.test.ts +163 -0
- package/src/__tests__/call-orchestrator.test.ts +113 -0
- package/src/__tests__/call-routes-http.test.ts +246 -6
- package/src/__tests__/channel-approval-routes.test.ts +438 -0
- package/src/__tests__/channel-approval.test.ts +266 -0
- package/src/__tests__/channel-approvals.test.ts +393 -0
- package/src/__tests__/channel-delivery-store.test.ts +447 -0
- package/src/__tests__/checker.test.ts +607 -1048
- package/src/__tests__/cli.test.ts +1 -56
- package/src/__tests__/config-schema.test.ts +137 -18
- package/src/__tests__/conflict-intent-tokenization.test.ts +141 -0
- package/src/__tests__/conflict-policy.test.ts +121 -0
- package/src/__tests__/conflict-store.test.ts +2 -0
- package/src/__tests__/contacts-tools.test.ts +3 -3
- package/src/__tests__/contradiction-checker.test.ts +99 -1
- package/src/__tests__/credential-security-invariants.test.ts +22 -6
- package/src/__tests__/credential-vault-unit.test.ts +780 -0
- package/src/__tests__/elevenlabs-client.test.ts +62 -0
- package/src/__tests__/ephemeral-permissions.test.ts +73 -23
- package/src/__tests__/filesystem-tools.test.ts +579 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +114 -4
- package/src/__tests__/handlers-add-trust-rule-metadata.test.ts +202 -0
- package/src/__tests__/handlers-cu-observation-blob.test.ts +2 -1
- package/src/__tests__/handlers-ipc-blob-probe.test.ts +2 -1
- package/src/__tests__/handlers-slack-config.test.ts +2 -1
- package/src/__tests__/handlers-telegram-config.test.ts +855 -0
- package/src/__tests__/handlers-twitter-config.test.ts +141 -1
- package/src/__tests__/hooks-runner.test.ts +6 -2
- package/src/__tests__/host-file-edit-tool.test.ts +124 -0
- package/src/__tests__/host-file-read-tool.test.ts +62 -0
- package/src/__tests__/host-file-write-tool.test.ts +59 -0
- package/src/__tests__/host-shell-tool.test.ts +251 -0
- package/src/__tests__/ingress-reconcile.test.ts +581 -0
- package/src/__tests__/ipc-snapshot.test.ts +100 -41
- package/src/__tests__/ipc-validate.test.ts +50 -0
- package/src/__tests__/key-migration.test.ts +23 -0
- package/src/__tests__/memory-regressions.test.ts +99 -0
- package/src/__tests__/memory-retrieval.benchmark.test.ts +1 -1
- package/src/__tests__/oauth-callback-registry.test.ts +11 -4
- package/src/__tests__/playbook-execution.test.ts +502 -0
- package/src/__tests__/playbook-tools.test.ts +4 -6
- package/src/__tests__/public-ingress-urls.test.ts +34 -0
- package/src/__tests__/qdrant-manager.test.ts +267 -0
- package/src/__tests__/recurrence-engine-rruleset.test.ts +97 -0
- package/src/__tests__/recurrence-engine.test.ts +9 -0
- package/src/__tests__/recurrence-types.test.ts +8 -0
- package/src/__tests__/registry.test.ts +1 -1
- package/src/__tests__/runtime-runs.test.ts +1 -25
- package/src/__tests__/schedule-store.test.ts +16 -14
- package/src/__tests__/schedule-tools.test.ts +83 -0
- package/src/__tests__/scheduler-recurrence.test.ts +111 -10
- package/src/__tests__/secret-allowlist.test.ts +18 -17
- package/src/__tests__/secret-ingress-handler.test.ts +11 -0
- package/src/__tests__/secret-scanner.test.ts +43 -0
- package/src/__tests__/session-conflict-gate.test.ts +442 -6
- package/src/__tests__/session-init.benchmark.test.ts +3 -0
- package/src/__tests__/session-process-bridge.test.ts +242 -0
- package/src/__tests__/session-skill-tools.test.ts +1 -1
- package/src/__tests__/shell-identity.test.ts +256 -0
- package/src/__tests__/skill-projection.benchmark.test.ts +11 -1
- package/src/__tests__/subagent-tools.test.ts +637 -54
- package/src/__tests__/task-management-tools.test.ts +936 -0
- package/src/__tests__/task-runner.test.ts +2 -2
- package/src/__tests__/terminal-tools.test.ts +840 -0
- package/src/__tests__/tool-executor-shell-integration.test.ts +301 -0
- package/src/__tests__/tool-executor.test.ts +85 -151
- package/src/__tests__/tool-permission-simulate-handler.test.ts +336 -0
- package/src/__tests__/trust-store.test.ts +27 -453
- package/src/__tests__/twilio-provider.test.ts +153 -3
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +375 -0
- package/src/__tests__/twilio-routes-twiml.test.ts +4 -4
- package/src/__tests__/twilio-routes.test.ts +17 -262
- package/src/__tests__/twitter-auth-handler.test.ts +2 -1
- package/src/__tests__/twitter-cli-error-shaping.test.ts +208 -0
- package/src/__tests__/twitter-cli-routing.test.ts +252 -0
- package/src/__tests__/twitter-oauth-client.test.ts +209 -0
- package/src/__tests__/workspace-policy.test.ts +213 -0
- package/src/calls/call-bridge.ts +92 -19
- package/src/calls/call-domain.ts +157 -5
- package/src/calls/call-orchestrator.ts +93 -7
- package/src/calls/call-store.ts +6 -0
- package/src/calls/elevenlabs-client.ts +8 -0
- package/src/calls/elevenlabs-config.ts +7 -5
- package/src/calls/twilio-provider.ts +91 -0
- package/src/calls/twilio-routes.ts +32 -37
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-quality.ts +29 -7
- package/src/cli/twitter.ts +200 -21
- package/src/cli.ts +1 -20
- package/src/config/bundled-skills/contacts/tools/contact-merge.ts +52 -4
- package/src/config/bundled-skills/contacts/tools/contact-search.ts +55 -4
- package/src/config/bundled-skills/contacts/tools/contact-upsert.ts +61 -4
- package/src/config/bundled-skills/messaging/SKILL.md +17 -2
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +4 -1
- package/src/config/bundled-skills/messaging/tools/messaging-send.ts +5 -1
- package/src/config/bundled-skills/messaging/tools/shared.ts +5 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +142 -34
- package/src/config/bundled-skills/playbooks/tools/playbook-create.ts +95 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-delete.ts +51 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-list.ts +73 -6
- package/src/config/bundled-skills/playbooks/tools/playbook-update.ts +110 -6
- package/src/config/bundled-skills/public-ingress/SKILL.md +22 -5
- package/src/config/bundled-skills/twitter/SKILL.md +103 -17
- package/src/config/defaults.ts +10 -4
- package/src/config/schema.ts +80 -21
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/telegram-setup/SKILL.md +56 -61
- package/src/daemon/assistant-attachments.ts +4 -2
- package/src/daemon/handlers/apps.ts +69 -0
- package/src/daemon/handlers/config.ts +543 -24
- package/src/daemon/handlers/index.ts +1 -0
- package/src/daemon/handlers/sessions.ts +22 -6
- package/src/daemon/handlers/shared.ts +2 -1
- package/src/daemon/handlers/skills.ts +5 -20
- package/src/daemon/ipc-contract-inventory.json +28 -0
- package/src/daemon/ipc-contract.ts +168 -10
- package/src/daemon/ipc-validate.ts +17 -0
- package/src/daemon/lifecycle.ts +2 -0
- package/src/daemon/server.ts +78 -72
- package/src/daemon/session-attachments.ts +1 -1
- package/src/daemon/session-conflict-gate.ts +62 -6
- package/src/daemon/session-notifiers.ts +1 -1
- package/src/daemon/session-process.ts +62 -3
- package/src/daemon/session-tool-setup.ts +1 -2
- package/src/daemon/tls-certs.ts +189 -0
- package/src/daemon/video-thumbnail.ts +5 -3
- package/src/hooks/manager.ts +5 -9
- package/src/memory/app-git-service.ts +295 -0
- package/src/memory/app-store.ts +21 -0
- package/src/memory/conflict-intent.ts +47 -4
- package/src/memory/conflict-policy.ts +73 -0
- package/src/memory/conflict-store.ts +9 -1
- package/src/memory/contradiction-checker.ts +28 -0
- package/src/memory/conversation-key-store.ts +15 -0
- package/src/memory/db.ts +81 -0
- package/src/memory/embedding-local.ts +3 -13
- package/src/memory/external-conversation-store.ts +234 -0
- package/src/memory/job-handlers/conflict.ts +22 -2
- package/src/memory/jobs-worker.ts +67 -28
- package/src/memory/runs-store.ts +54 -7
- package/src/memory/schema.ts +20 -0
- package/src/messaging/provider.ts +9 -0
- package/src/messaging/providers/telegram-bot/adapter.ts +162 -0
- package/src/messaging/providers/telegram-bot/client.ts +104 -0
- package/src/messaging/providers/telegram-bot/types.ts +15 -0
- package/src/messaging/registry.ts +1 -0
- package/src/permissions/checker.ts +48 -44
- package/src/permissions/prompter.ts +0 -4
- package/src/permissions/shell-identity.ts +227 -0
- package/src/permissions/trust-store.ts +76 -53
- package/src/permissions/types.ts +0 -19
- package/src/permissions/workspace-policy.ts +114 -0
- package/src/providers/retry.ts +12 -37
- package/src/runtime/assistant-event-hub.ts +41 -4
- package/src/runtime/channel-approval-parser.ts +60 -0
- package/src/runtime/channel-approval-types.ts +71 -0
- package/src/runtime/channel-approvals.ts +145 -0
- package/src/runtime/gateway-client.ts +16 -0
- package/src/runtime/http-server.ts +29 -9
- package/src/runtime/routes/call-routes.ts +52 -2
- package/src/runtime/routes/channel-routes.ts +296 -16
- package/src/runtime/routes/events-routes.ts +97 -28
- package/src/runtime/routes/run-routes.ts +2 -7
- package/src/runtime/run-orchestrator.ts +0 -3
- package/src/schedule/recurrence-engine.ts +26 -2
- package/src/schedule/recurrence-types.ts +1 -1
- package/src/schedule/schedule-store.ts +12 -3
- package/src/security/secret-scanner.ts +7 -0
- package/src/tasks/ephemeral-permissions.ts +0 -2
- package/src/tasks/task-scheduler.ts +2 -1
- package/src/tools/calls/call-start.ts +8 -0
- package/src/tools/execution-target.ts +21 -0
- package/src/tools/execution-timeout.ts +49 -0
- package/src/tools/executor.ts +6 -135
- package/src/tools/network/web-search.ts +9 -32
- package/src/tools/policy-context.ts +29 -0
- package/src/tools/schedule/update.ts +8 -1
- package/src/tools/terminal/parser.ts +16 -18
- package/src/tools/types.ts +4 -11
- package/src/twitter/oauth-client.ts +102 -0
- package/src/twitter/router.ts +101 -0
- package/src/util/debounce.ts +88 -0
- package/src/util/network-info.ts +47 -0
- package/src/util/platform.ts +29 -4
- package/src/util/promise-guard.ts +37 -0
- package/src/util/retry.ts +98 -0
- package/src/util/truncate.ts +1 -1
- package/src/workspace/git-service.ts +129 -112
- package/src/tools/contacts/contact-merge.ts +0 -55
- package/src/tools/contacts/contact-search.ts +0 -58
- package/src/tools/contacts/contact-upsert.ts +0 -64
- package/src/tools/playbooks/index.ts +0 -4
- package/src/tools/playbooks/playbook-create.ts +0 -96
- package/src/tools/playbooks/playbook-delete.ts +0 -52
- package/src/tools/playbooks/playbook-list.ts +0 -74
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|