@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.
- package/openapi.yaml +73 -56
- package/package.json +1 -1
- package/src/__tests__/actor-trust-resolver-address-fallback.test.ts +83 -31
- package/src/__tests__/assistant-stream-state.test.ts +3 -76
- package/src/__tests__/background-workers-disk-pressure.test.ts +4 -2
- package/src/__tests__/channel-approval-routes.test.ts +21 -26
- package/src/__tests__/channel-delivery-store.test.ts +28 -0
- package/src/__tests__/channel-guardian.test.ts +82 -32
- package/src/__tests__/channel-inbound-disk-pressure.test.ts +11 -19
- package/src/__tests__/channel-reply-delivery.test.ts +6 -2
- package/src/__tests__/compaction-ledger-store.test.ts +128 -0
- package/src/__tests__/config-loader-backfill.test.ts +148 -0
- package/src/__tests__/consult-deadline.test.ts +60 -0
- package/src/__tests__/contact-store-interaction-info.test.ts +156 -0
- package/src/__tests__/contact-store-user-file.test.ts +7 -10
- package/src/__tests__/contacts-relay-reads.test.ts +6 -9
- package/src/__tests__/contacts-write.test.ts +0 -2
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +4 -2
- package/src/__tests__/conversation-agent-loop.test.ts +98 -7
- package/src/__tests__/conversation-attention-telegram.test.ts +9 -11
- package/src/__tests__/conversation-error.test.ts +18 -0
- package/src/__tests__/conversation-fork-crud.test.ts +354 -24
- package/src/__tests__/conversation-title-service.test.ts +222 -201
- package/src/__tests__/db-compaction-events-migration.test.ts +129 -0
- package/src/__tests__/delete-propagation.test.ts +5 -3
- package/src/__tests__/dm-backfill.test.ts +6 -4
- package/src/__tests__/emit-signal-routing-intent.test.ts +2 -6
- package/src/__tests__/guardian-binding-drift-heal.test.ts +43 -23
- package/src/__tests__/guardian-dispatch.test.ts +50 -5
- package/src/__tests__/guardian-routing-state.test.ts +6 -10
- package/src/__tests__/helpers/channel-test-adapter.ts +45 -12
- package/src/__tests__/helpers/create-guardian-binding.ts +15 -23
- package/src/__tests__/helpers/mock-logger.ts +1 -0
- package/src/__tests__/helpers/seed-contact-channel.ts +96 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +87 -10
- package/src/__tests__/invite-redemption-service.test.ts +273 -53
- package/src/__tests__/invite-routes-http.test.ts +34 -0
- package/src/__tests__/invite-service-ipc.test.ts +65 -2
- package/src/__tests__/list-messages-page-latest.test.ts +173 -4
- package/src/__tests__/mcp-config-secret-boundary.test.ts +3 -0
- package/src/__tests__/non-member-access-request.test.ts +15 -13
- package/src/__tests__/onboarding-persona-write.test.ts +52 -22
- package/src/__tests__/persist-onboarding-artifacts.test.ts +1 -0
- package/src/__tests__/persona-resolver.test.ts +75 -45
- package/src/__tests__/plugin-bootstrap.test.ts +13 -5
- package/src/__tests__/plugin-disabled-state.test.ts +190 -0
- package/src/__tests__/provider-usage-tracking.test.ts +1 -1
- package/src/__tests__/reaction-intercept-cold-cache-warm.test.ts +135 -0
- package/src/__tests__/reaction-intercept-member-verdict-warm.test.ts +158 -0
- package/src/__tests__/reaction-persistence.test.ts +51 -4
- package/src/__tests__/relay-server.test.ts +88 -31
- package/src/__tests__/runtime-attachment-metadata.test.ts +9 -11
- package/src/__tests__/settings-routes.test.ts +32 -0
- package/src/__tests__/slack-block-formatting.test.ts +1 -38
- package/src/__tests__/sse-actor-principal-guardian-source.test.ts +13 -36
- package/src/__tests__/stt-hints.test.ts +6 -3
- package/src/__tests__/subagent-fork-prompt-role.test.ts +195 -0
- package/src/__tests__/subagent-fork-spawn.test.ts +6 -7
- package/src/__tests__/subagent-role-registry.test.ts +17 -4
- package/src/__tests__/subagent-spawn-and-await.test.ts +546 -0
- package/src/__tests__/subagent-tools.test.ts +398 -3
- package/src/__tests__/thread-backfill.test.ts +3 -3
- package/src/__tests__/tool-preview-lifecycle.test.ts +26 -10
- package/src/__tests__/tool-start-timestamp.test.ts +4 -3
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +37 -51
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +2 -2
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +9 -7
- package/src/__tests__/trusted-contact-multichannel.test.ts +16 -7
- package/src/__tests__/trusted-contact-verification.test.ts +79 -54
- package/src/__tests__/voice-guardian-cold-cache-warm.test.ts +137 -0
- package/src/__tests__/voice-invite-redemption.test.ts +183 -20
- package/src/__tests__/workspace-migration-102-preserve-heartbeat-enabled-for-existing-workspaces.test.ts +3 -3
- package/src/__tests__/workspace-migration-111-prune-seeded-callsite-defaults.test.ts +2 -2
- package/src/__tests__/workspace-migration-112-remove-advisor-callsite-override.test.ts +170 -0
- package/src/__tests__/workspace-migration-drop-user-md.test.ts +196 -238
- package/src/a2a/__tests__/e2e-a2a-channel.test.ts +35 -47
- package/src/agent/loop-exclusive-tool.test.ts +19 -15
- package/src/agent/loop-native-web-search.test.ts +200 -0
- package/src/agent/loop.ts +108 -1
- package/src/api/responses/conversation-message.ts +9 -0
- package/src/approvals/guardian-request-resolvers.ts +16 -4
- package/src/calls/__tests__/relay-setup-router.test.ts +10 -18
- package/src/calls/guardian-dispatch.ts +14 -11
- package/src/calls/inbound-trust-reader.ts +7 -1
- package/src/calls/relay-access-wait.ts +6 -6
- package/src/calls/relay-server.ts +22 -2
- package/src/calls/relay-setup-router.ts +10 -10
- package/src/cli/commands/__tests__/conversations-slack.test.ts +1 -0
- package/src/cli/commands/contacts.ts +10 -7
- package/src/cli/commands/memory/__tests__/worker.test.ts +147 -17
- package/src/cli/commands/memory/worker.ts +97 -30
- package/src/cli/commands/plugins.ts +3 -146
- package/src/cli/lib/__tests__/list-installed-plugins.test.ts +17 -17
- package/src/cli/lib/__tests__/publish-plugin.test.ts +98 -0
- package/src/cli/lib/publish-plugin.ts +231 -1
- package/src/config/__tests__/sync-gated-profiles.test.ts +5 -7
- package/src/config/bundled-skills/subagent/SKILL.md +16 -1
- package/src/config/bundled-skills/subagent/TOOLS.json +5 -4
- package/src/config/call-site-defaults.ts +0 -6
- package/src/config/llm-resolver.ts +0 -3
- package/src/config/schemas/call-site-catalog.ts +0 -7
- package/src/config/schemas/heartbeat.ts +2 -5
- package/src/config/schemas/llm.ts +3 -12
- package/src/config/schemas/memory-lifecycle.ts +1 -1
- package/src/config/seed-inference-profiles.ts +76 -35
- package/src/config/sync-gated-profiles.ts +0 -3
- package/src/contacts/__tests__/contacts-write-revoke-relay.test.ts +7 -8
- package/src/contacts/__tests__/member-write-relay.test.ts +35 -11
- package/src/contacts/contact-store.ts +27 -237
- package/src/contacts/contacts-write.ts +18 -58
- package/src/contacts/gateway-channel-read.ts +51 -0
- package/src/contacts/member-write-relay.ts +25 -31
- package/src/contacts/types.ts +3 -15
- package/src/daemon/__tests__/conversation-tool-setup.test.ts +0 -44
- package/src/daemon/conversation-agent-loop-handlers.ts +29 -10
- package/src/daemon/conversation-agent-loop.ts +68 -61
- package/src/daemon/conversation-error.ts +7 -10
- package/src/daemon/conversation-tool-setup.ts +0 -10
- package/src/daemon/conversation.ts +10 -0
- package/src/daemon/external-plugins-bootstrap.ts +8 -2
- package/src/daemon/handlers/__tests__/config-a2a-accept.test.ts +0 -1
- package/src/daemon/handlers/__tests__/config-a2a-complete.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-a2a-redeem.test.ts +0 -2
- package/src/daemon/handlers/__tests__/config-channels.test.ts +9 -14
- package/src/daemon/handlers/config-channels.ts +14 -29
- package/src/daemon/lifecycle.ts +16 -4
- package/src/daemon/message-types/surfaces.ts +2 -0
- package/src/heartbeat/heartbeat-service.ts +5 -0
- package/src/home/relationship-state-writer.ts +5 -0
- package/src/memory/__tests__/embedding-cache.test.ts +136 -0
- package/src/memory/compaction-ledger-store.ts +107 -0
- package/src/memory/conversation-crud.ts +136 -61
- package/src/memory/conversation-title-service.ts +173 -24
- package/src/memory/embedding-backend.ts +8 -1
- package/src/memory/embedding-cache.ts +139 -0
- package/src/memory/jobs-worker.ts +75 -29
- package/src/memory/memory-retrospective-job.ts +5 -0
- package/src/memory/migrations/209-strip-thinking-from-consolidated.ts +27 -5
- package/src/memory/migrations/302-create-compaction-events.ts +107 -0
- package/src/memory/migrations/303-add-conversation-creation-seq.ts +33 -0
- package/src/memory/migrations/__tests__/209-strip-thinking-from-consolidated.test.ts +79 -6
- package/src/memory/schema/contacts.ts +6 -2
- package/src/memory/schema/conversations.ts +39 -0
- package/src/memory/steps.ts +1090 -367
- package/src/memory/worker-control.ts +104 -18
- package/src/memory/worker-process.ts +17 -0
- package/src/messaging/channel-binding-metadata.ts +31 -0
- package/src/messaging/channel-binding-schema.ts +51 -0
- package/src/messaging/providers/__tests__/callback-routing.test.ts +45 -0
- package/src/messaging/providers/__tests__/transport-dispatch.test.ts +195 -0
- package/src/messaging/providers/a2a/__tests__/deliver.test.ts +11 -0
- package/src/messaging/providers/a2a/deliver.ts +5 -1
- package/src/messaging/providers/a2a/transport.ts +10 -0
- package/src/messaging/providers/callback-routing.ts +48 -0
- package/src/messaging/providers/channel-transport.ts +55 -0
- package/src/messaging/providers/index.ts +65 -241
- package/src/messaging/providers/slack/binding-metadata.ts +62 -0
- package/src/messaging/providers/slack/transport.ts +92 -0
- package/src/messaging/providers/telegram-bot/transport.ts +51 -0
- package/src/messaging/providers/whatsapp/transport.ts +38 -0
- package/src/notifications/__tests__/broadcaster.test.ts +0 -8
- package/src/notifications/__tests__/connected-channels.test.ts +8 -36
- package/src/notifications/__tests__/destination-resolver.test.ts +12 -117
- package/src/notifications/destination-resolver.ts +7 -23
- package/src/notifications/emit-signal.ts +5 -11
- package/src/plugins/defaults/index.ts +0 -35
- package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +11 -0
- package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +243 -2
- package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +167 -14
- package/src/plugins/disabled-state.ts +31 -0
- package/src/plugins/registry.ts +55 -12
- package/src/prompts/persona-resolver.ts +43 -11
- package/src/providers/call-site-routing.ts +41 -0
- package/src/providers/provider-send-message.ts +6 -0
- package/src/providers/ratelimit.ts +6 -0
- package/src/providers/registry.ts +1 -1
- package/src/providers/retry.ts +6 -0
- package/src/providers/types.ts +13 -0
- package/src/providers/usage-tracking.ts +6 -0
- package/src/runtime/__tests__/guardian-vellum-migration.test.ts +30 -27
- package/src/runtime/__tests__/local-principal-trust.test.ts +16 -18
- package/src/runtime/__tests__/member-verdict-cache.test.ts +119 -0
- package/src/runtime/__tests__/trust-verdict-consumer.test.ts +115 -168
- package/src/runtime/access-request-helper.ts +1 -2
- package/src/runtime/actor-trust-resolver.ts +44 -17
- package/src/runtime/anchored-guardian.test.ts +7 -54
- package/src/runtime/anchored-guardian.ts +4 -53
- package/src/runtime/assistant-stream-state.ts +12 -74
- package/src/runtime/channel-reply-delivery.ts +3 -8
- package/src/runtime/guardian-vellum-migration.ts +18 -16
- package/src/runtime/invite-redemption-service.ts +25 -10
- package/src/runtime/local-actor-identity.test.ts +108 -0
- package/src/runtime/local-actor-identity.ts +27 -20
- package/src/runtime/member-verdict-cache.ts +0 -0
- package/src/runtime/routes/__tests__/contact-routes.test.ts +100 -7
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +1 -2
- package/src/runtime/routes/__tests__/surface-action-routes.test.ts +2 -1
- package/src/runtime/routes/contact-routes.ts +40 -25
- package/src/runtime/routes/conversation-list-routes.ts +1 -29
- package/src/runtime/routes/conversation-routes.ts +27 -7
- package/src/runtime/routes/inbound-stages/acl-enforcement.ts +0 -10
- package/src/runtime/routes/inbound-stages/background-dispatch.ts +4 -8
- package/src/runtime/routes/inbound-stages/reaction-intercept.ts +19 -0
- package/src/runtime/routes/settings-routes.ts +8 -3
- package/src/runtime/services/conversation-serializer.ts +6 -49
- package/src/runtime/slack-block-formatting.ts +0 -15
- package/src/runtime/trust-verdict-consumer.ts +36 -41
- package/src/subagent/__tests__/consult-prompt.test.ts +35 -0
- package/src/{plugins/defaults/advisor/__tests__/transcript.test.ts → subagent/__tests__/consult-transcript.test.ts} +47 -10
- package/src/{plugins/defaults/advisor/steering.ts → subagent/consult-prompt.ts} +17 -39
- package/src/{plugins/defaults/advisor/transcript.ts → subagent/consult-transcript.ts} +18 -8
- package/src/subagent/index.ts +1 -1
- package/src/subagent/manager.ts +245 -33
- package/src/subagent/types.ts +8 -1
- package/src/tools/registry.ts +10 -3
- package/src/tools/subagent/consult-deadline.ts +49 -0
- package/src/tools/subagent/spawn.ts +234 -5
- package/src/util/logger.ts +9 -0
- package/src/util/platform.ts +14 -0
- package/src/workspace/migrations/031-drop-user-md.ts +232 -148
- package/src/workspace/migrations/112-remove-advisor-callsite-override.ts +64 -0
- package/src/workspace/migrations/registry.ts +2 -0
- package/src/plugins/defaults/advisor/__tests__/advisor-gate.test.ts +0 -56
- package/src/plugins/defaults/advisor/__tests__/advisor-state-store.test.ts +0 -43
- package/src/plugins/defaults/advisor/__tests__/agent-loop-integration.test.ts +0 -137
- package/src/plugins/defaults/advisor/__tests__/consult.test.ts +0 -314
- package/src/plugins/defaults/advisor/__tests__/context-pack-gating.test.ts +0 -106
- package/src/plugins/defaults/advisor/__tests__/context-pack.test.ts +0 -60
- package/src/plugins/defaults/advisor/__tests__/hooks.test.ts +0 -138
- package/src/plugins/defaults/advisor/advisor-gate.ts +0 -29
- package/src/plugins/defaults/advisor/advisor-state-store.ts +0 -94
- package/src/plugins/defaults/advisor/config.ts +0 -21
- package/src/plugins/defaults/advisor/consult.ts +0 -197
- package/src/plugins/defaults/advisor/context-pack.ts +0 -288
- package/src/plugins/defaults/advisor/hooks/post-model-call.ts +0 -34
- package/src/plugins/defaults/advisor/hooks/pre-model-call.ts +0 -30
- package/src/plugins/defaults/advisor/hooks/user-prompt-submit.ts +0 -19
- package/src/plugins/defaults/advisor/package.json +0 -14
- 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
|
+
});
|