@stigmer/runner 3.10.0 → 3.11.0

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 (260) hide show
  1. package/README.md +12 -1
  2. package/dist/.build-fingerprint +1 -1
  3. package/dist/activities/call-llm.js +9 -10
  4. package/dist/activities/call-llm.js.map +1 -1
  5. package/dist/activities/classify-tool-approvals.d.ts +2 -1
  6. package/dist/activities/classify-tool-approvals.js +28 -2
  7. package/dist/activities/classify-tool-approvals.js.map +1 -1
  8. package/dist/activities/discover-mcp-server.d.ts +32 -0
  9. package/dist/activities/discover-mcp-server.js +162 -27
  10. package/dist/activities/discover-mcp-server.js.map +1 -1
  11. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.d.ts +8 -0
  12. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js +1 -1
  13. package/dist/activities/execute-cursor/__test-utils__/cursor-hook-harness.js.map +1 -1
  14. package/dist/activities/execute-cursor/approval-state.d.ts +28 -2
  15. package/dist/activities/execute-cursor/approval-state.js +7 -1
  16. package/dist/activities/execute-cursor/approval-state.js.map +1 -1
  17. package/dist/activities/execute-cursor/attachment-resolver.d.ts +14 -0
  18. package/dist/activities/execute-cursor/attachment-resolver.js +18 -4
  19. package/dist/activities/execute-cursor/attachment-resolver.js.map +1 -1
  20. package/dist/activities/execute-cursor/blueprint-resolver.d.ts +1 -9
  21. package/dist/activities/execute-cursor/blueprint-resolver.js +6 -22
  22. package/dist/activities/execute-cursor/blueprint-resolver.js.map +1 -1
  23. package/dist/activities/execute-cursor/env-resolver.js +3 -1
  24. package/dist/activities/execute-cursor/env-resolver.js.map +1 -1
  25. package/dist/activities/execute-cursor/error-classifier.d.ts +40 -3
  26. package/dist/activities/execute-cursor/error-classifier.js +81 -3
  27. package/dist/activities/execute-cursor/error-classifier.js.map +1 -1
  28. package/dist/activities/execute-cursor/extract-structured-output.d.ts +29 -0
  29. package/dist/activities/execute-cursor/extract-structured-output.js +58 -0
  30. package/dist/activities/execute-cursor/extract-structured-output.js.map +1 -0
  31. package/dist/activities/execute-cursor/hook-script.d.ts +14 -3
  32. package/dist/activities/execute-cursor/hook-script.js +72 -10
  33. package/dist/activities/execute-cursor/hook-script.js.map +1 -1
  34. package/dist/activities/execute-cursor/index.d.ts +5 -1
  35. package/dist/activities/execute-cursor/index.js +51 -57
  36. package/dist/activities/execute-cursor/index.js.map +1 -1
  37. package/dist/activities/execute-cursor/mcp-resolver.d.ts +24 -1
  38. package/dist/activities/execute-cursor/mcp-resolver.js +5 -2
  39. package/dist/activities/execute-cursor/mcp-resolver.js.map +1 -1
  40. package/dist/activities/execute-cursor/prompt-builder.d.ts +18 -4
  41. package/dist/activities/execute-cursor/prompt-builder.js +12 -7
  42. package/dist/activities/execute-cursor/prompt-builder.js.map +1 -1
  43. package/dist/activities/execute-cursor/turn-stream.js +4 -1
  44. package/dist/activities/execute-cursor/turn-stream.js.map +1 -1
  45. package/dist/activities/execute-deep-agent/attachment-injector.d.ts +18 -1
  46. package/dist/activities/execute-deep-agent/attachment-injector.js +68 -23
  47. package/dist/activities/execute-deep-agent/attachment-injector.js.map +1 -1
  48. package/dist/activities/execute-deep-agent/environment.js +3 -1
  49. package/dist/activities/execute-deep-agent/environment.js.map +1 -1
  50. package/dist/activities/execute-deep-agent/index.js +15 -0
  51. package/dist/activities/execute-deep-agent/index.js.map +1 -1
  52. package/dist/activities/execute-deep-agent/prompt-builder.d.ts +7 -7
  53. package/dist/activities/execute-deep-agent/prompt-builder.js +8 -2
  54. package/dist/activities/execute-deep-agent/prompt-builder.js.map +1 -1
  55. package/dist/activities/execute-deep-agent/setup.d.ts +10 -0
  56. package/dist/activities/execute-deep-agent/setup.js +55 -23
  57. package/dist/activities/execute-deep-agent/setup.js.map +1 -1
  58. package/dist/activities/execute-deep-agent/subagent-transformer.d.ts +18 -1
  59. package/dist/activities/execute-deep-agent/subagent-transformer.js +8 -1
  60. package/dist/activities/execute-deep-agent/subagent-transformer.js.map +1 -1
  61. package/dist/activities/execute-deep-agent/subagent-wiring.d.ts +11 -4
  62. package/dist/activities/execute-deep-agent/subagent-wiring.js +13 -4
  63. package/dist/activities/execute-deep-agent/subagent-wiring.js.map +1 -1
  64. package/dist/activities/hydrate-workflow-execution.js +3 -1
  65. package/dist/activities/hydrate-workflow-execution.js.map +1 -1
  66. package/dist/activities/workflow-event-activities.d.ts +28 -10
  67. package/dist/activities/workflow-event-activities.js +87 -58
  68. package/dist/activities/workflow-event-activities.js.map +1 -1
  69. package/dist/claimcheck/payload-codec.js +21 -1
  70. package/dist/claimcheck/payload-codec.js.map +1 -1
  71. package/dist/client/stigmer-client.d.ts +9 -4
  72. package/dist/client/stigmer-client.js +28 -15
  73. package/dist/client/stigmer-client.js.map +1 -1
  74. package/dist/encryption/config.d.ts +32 -0
  75. package/dist/encryption/config.js +68 -0
  76. package/dist/encryption/config.js.map +1 -0
  77. package/dist/encryption/index.d.ts +3 -0
  78. package/dist/encryption/index.js +3 -0
  79. package/dist/encryption/index.js.map +1 -0
  80. package/dist/encryption/payload-codec.d.ts +41 -0
  81. package/dist/encryption/payload-codec.js +130 -0
  82. package/dist/encryption/payload-codec.js.map +1 -0
  83. package/dist/payload-codecs.d.ts +16 -0
  84. package/dist/payload-codecs.js +38 -0
  85. package/dist/payload-codecs.js.map +1 -0
  86. package/dist/preflight.d.ts +31 -0
  87. package/dist/preflight.js +43 -0
  88. package/dist/preflight.js.map +1 -1
  89. package/dist/runner-manager.js +5 -15
  90. package/dist/runner-manager.js.map +1 -1
  91. package/dist/runner.js +5 -16
  92. package/dist/runner.js.map +1 -1
  93. package/dist/shared/approval-policy.d.ts +9 -3
  94. package/dist/shared/approval-policy.js +15 -6
  95. package/dist/shared/approval-policy.js.map +1 -1
  96. package/dist/shared/attachment-naming.d.ts +53 -0
  97. package/dist/shared/attachment-naming.js +59 -0
  98. package/dist/shared/attachment-naming.js.map +1 -0
  99. package/dist/shared/caller-identity.d.ts +23 -2
  100. package/dist/shared/caller-identity.js +36 -5
  101. package/dist/shared/caller-identity.js.map +1 -1
  102. package/dist/shared/channel-attachment.js +1 -0
  103. package/dist/shared/channel-attachment.js.map +1 -1
  104. package/dist/shared/checkpointer/http-saver.d.ts +42 -1
  105. package/dist/shared/checkpointer/http-saver.js +96 -8
  106. package/dist/shared/checkpointer/http-saver.js.map +1 -1
  107. package/dist/shared/conversation-attachment.js +1 -0
  108. package/dist/shared/conversation-attachment.js.map +1 -1
  109. package/dist/shared/datastore-attachment.d.ts +50 -7
  110. package/dist/shared/datastore-attachment.js +93 -11
  111. package/dist/shared/datastore-attachment.js.map +1 -1
  112. package/dist/shared/http-retry.d.ts +43 -0
  113. package/dist/shared/http-retry.js +50 -0
  114. package/dist/shared/http-retry.js.map +1 -0
  115. package/dist/shared/llm-backend.d.ts +275 -0
  116. package/dist/shared/llm-backend.js +425 -0
  117. package/dist/shared/llm-backend.js.map +1 -0
  118. package/dist/shared/llm-proxy.d.ts +8 -0
  119. package/dist/shared/llm-proxy.js +15 -0
  120. package/dist/shared/llm-proxy.js.map +1 -1
  121. package/dist/shared/mcp-enabled-tools.d.ts +57 -0
  122. package/dist/shared/mcp-enabled-tools.js +86 -0
  123. package/dist/shared/mcp-enabled-tools.js.map +1 -0
  124. package/dist/shared/mcp-manager.d.ts +3 -1
  125. package/dist/shared/mcp-manager.js +17 -4
  126. package/dist/shared/mcp-manager.js.map +1 -1
  127. package/dist/shared/mcp-resolver.d.ts +39 -2
  128. package/dist/shared/mcp-resolver.js +38 -2
  129. package/dist/shared/mcp-resolver.js.map +1 -1
  130. package/dist/shared/model-client.d.ts +12 -5
  131. package/dist/shared/model-client.js +138 -18
  132. package/dist/shared/model-client.js.map +1 -1
  133. package/dist/shared/model-error.js +198 -5
  134. package/dist/shared/model-error.js.map +1 -1
  135. package/dist/shared/plan-mode-permissions.d.ts +26 -0
  136. package/dist/shared/plan-mode-permissions.js +28 -0
  137. package/dist/shared/plan-mode-permissions.js.map +1 -0
  138. package/dist/worker.d.ts +2 -1
  139. package/dist/worker.js +2 -4
  140. package/dist/worker.js.map +1 -1
  141. package/dist/workflow-engine/types.d.ts +18 -0
  142. package/dist/workflow-engine/types.js.map +1 -1
  143. package/dist/workflows/call-agent-orchestrator.d.ts +9 -0
  144. package/dist/workflows/call-agent-orchestrator.js +1 -0
  145. package/dist/workflows/call-agent-orchestrator.js.map +1 -1
  146. package/dist/workflows/connect-mcp-server.js +7 -0
  147. package/dist/workflows/connect-mcp-server.js.map +1 -1
  148. package/dist/workflows/engine-core.js +23 -2
  149. package/dist/workflows/engine-core.js.map +1 -1
  150. package/dist/workflows/execute-from-execution.d.ts +1 -1
  151. package/dist/workflows/execute-from-execution.js +11 -1
  152. package/dist/workflows/execute-from-execution.js.map +1 -1
  153. package/package.json +8 -2
  154. package/src/__tests__/claimcheck-codec.test.ts +36 -0
  155. package/src/__tests__/encryption-codec.test.ts +234 -0
  156. package/src/__tests__/fixtures/encrypted-payload-fixture.json +15 -0
  157. package/src/__tests__/history-encryption-e2e.test.ts +243 -0
  158. package/src/__tests__/preflight.test.ts +50 -2
  159. package/src/activities/__tests__/call-llm.test.ts +75 -0
  160. package/src/activities/__tests__/classify-tool-approvals.test.ts +117 -1
  161. package/src/activities/__tests__/discover-mcp-server.hang.test.ts +103 -0
  162. package/src/activities/__tests__/discover-mcp-server.test.ts +203 -0
  163. package/src/activities/__tests__/workflow-event-activities.test.ts +107 -8
  164. package/src/activities/call-llm.ts +9 -16
  165. package/src/activities/classify-tool-approvals.ts +34 -4
  166. package/src/activities/discover-mcp-server.ts +190 -32
  167. package/src/activities/execute-cursor/__test-utils__/cursor-hook-harness.ts +9 -0
  168. package/src/activities/execute-cursor/__tests__/approval-gate.test.ts +14 -0
  169. package/src/activities/execute-cursor/__tests__/attachment-resolver.test.ts +53 -0
  170. package/src/activities/execute-cursor/__tests__/build-prompt.test.ts +40 -14
  171. package/src/activities/execute-cursor/__tests__/error-classifier-extraction.test.ts +208 -0
  172. package/src/activities/execute-cursor/__tests__/extract-structured-output.test.ts +120 -0
  173. package/src/activities/execute-cursor/__tests__/hook-script.test.ts +93 -0
  174. package/src/activities/execute-cursor/__tests__/mcp-resolver.test.ts +125 -0
  175. package/src/activities/execute-cursor/__tests__/prompt-builder-delegation.test.ts +1 -1
  176. package/src/activities/execute-cursor/__tests__/turn-stream.test.ts +13 -0
  177. package/src/activities/execute-cursor/approval-state.ts +30 -1
  178. package/src/activities/execute-cursor/attachment-resolver.ts +31 -3
  179. package/src/activities/execute-cursor/blueprint-resolver.ts +7 -27
  180. package/src/activities/execute-cursor/env-resolver.ts +3 -1
  181. package/src/activities/execute-cursor/error-classifier.ts +91 -4
  182. package/src/activities/execute-cursor/extract-structured-output.ts +72 -0
  183. package/src/activities/execute-cursor/hook-script.ts +74 -10
  184. package/src/activities/execute-cursor/index.ts +55 -71
  185. package/src/activities/execute-cursor/mcp-resolver.ts +36 -2
  186. package/src/activities/execute-cursor/prompt-builder.ts +34 -9
  187. package/src/activities/execute-cursor/turn-stream.ts +5 -2
  188. package/src/activities/execute-deep-agent/__tests__/attachment-injector.test.ts +110 -8
  189. package/src/activities/execute-deep-agent/__tests__/datastore-degradation.test.ts +104 -0
  190. package/src/activities/execute-deep-agent/__tests__/hitl-reject.test.ts +2 -0
  191. package/src/activities/execute-deep-agent/__tests__/hitl-resume-approve-all.test.ts +1 -0
  192. package/src/activities/execute-deep-agent/__tests__/hitl-resume-history.test.ts +1 -0
  193. package/src/activities/execute-deep-agent/__tests__/prompt-builder.test.ts +34 -5
  194. package/src/activities/execute-deep-agent/__tests__/sequential-gate-resume.test.ts +1 -0
  195. package/src/activities/execute-deep-agent/__tests__/subagent-plan-mode-permissions.test.ts +173 -0
  196. package/src/activities/execute-deep-agent/__tests__/subagent-wiring.test.ts +12 -7
  197. package/src/activities/execute-deep-agent/attachment-injector.ts +94 -30
  198. package/src/activities/execute-deep-agent/environment.ts +3 -1
  199. package/src/activities/execute-deep-agent/index.ts +20 -0
  200. package/src/activities/execute-deep-agent/prompt-builder.ts +20 -10
  201. package/src/activities/execute-deep-agent/setup.ts +76 -28
  202. package/src/activities/execute-deep-agent/subagent-transformer.ts +23 -1
  203. package/src/activities/execute-deep-agent/subagent-wiring.ts +14 -4
  204. package/src/activities/hydrate-workflow-execution.ts +3 -1
  205. package/src/activities/workflow-event-activities.ts +96 -69
  206. package/src/claimcheck/payload-codec.ts +33 -1
  207. package/src/client/__tests__/stigmer-client.test.ts +8 -8
  208. package/src/client/stigmer-client.ts +32 -18
  209. package/src/encryption/config.ts +91 -0
  210. package/src/encryption/index.ts +3 -0
  211. package/src/encryption/payload-codec.ts +152 -0
  212. package/src/payload-codecs.ts +56 -0
  213. package/src/preflight.ts +45 -0
  214. package/src/runner-manager.ts +6 -24
  215. package/src/runner.ts +6 -25
  216. package/src/shared/__tests__/approval-policy.test.ts +82 -39
  217. package/src/shared/__tests__/attachment-naming.test.ts +159 -0
  218. package/src/shared/__tests__/bedrock-adapter.test.ts +213 -0
  219. package/src/shared/__tests__/bedrock-seam.test.ts +390 -0
  220. package/src/shared/__tests__/caller-identity.test.ts +25 -0
  221. package/src/shared/__tests__/channel-attachment.test.ts +1 -1
  222. package/src/shared/__tests__/connect-backfill.test.ts +1 -0
  223. package/src/shared/__tests__/conversation-attachment.test.ts +1 -1
  224. package/src/shared/__tests__/datastore-attachment.test.ts +129 -1
  225. package/src/shared/__tests__/foundry-adapter.test.ts +276 -0
  226. package/src/shared/__tests__/foundry-seam.test.ts +482 -0
  227. package/src/shared/__tests__/http-retry.test.ts +67 -0
  228. package/src/shared/__tests__/llm-backend.test.ts +616 -0
  229. package/src/shared/__tests__/mcp-enabled-tools.test.ts +86 -0
  230. package/src/shared/__tests__/mcp-manager.test.ts +84 -2
  231. package/src/shared/__tests__/mcp-resolver.test.ts +146 -3
  232. package/src/shared/__tests__/model-client.test.ts +154 -0
  233. package/src/shared/__tests__/model-error.test.ts +289 -1
  234. package/src/shared/__tests__/synthesized-attachment.test.ts +1 -0
  235. package/src/shared/__tests__/vertex-adapter.test.ts +169 -0
  236. package/src/shared/__tests__/vertex-seam.test.ts +295 -0
  237. package/src/shared/approval-policy.ts +14 -7
  238. package/src/shared/attachment-naming.ts +78 -0
  239. package/src/shared/caller-identity.ts +40 -5
  240. package/src/shared/channel-attachment.ts +1 -0
  241. package/src/shared/checkpointer/__tests__/http-saver.test.ts +196 -1
  242. package/src/shared/checkpointer/http-saver.ts +117 -9
  243. package/src/shared/conversation-attachment.ts +1 -0
  244. package/src/shared/datastore-attachment.ts +106 -11
  245. package/src/shared/http-retry.ts +50 -0
  246. package/src/shared/llm-backend.ts +544 -0
  247. package/src/shared/llm-proxy.ts +15 -0
  248. package/src/shared/mcp-enabled-tools.ts +105 -0
  249. package/src/shared/mcp-manager.ts +21 -4
  250. package/src/shared/mcp-resolver.ts +73 -2
  251. package/src/shared/model-client.ts +161 -19
  252. package/src/shared/model-error.ts +222 -4
  253. package/src/shared/plan-mode-permissions.ts +30 -0
  254. package/src/worker.ts +4 -5
  255. package/src/workflow-engine/types.ts +18 -0
  256. package/src/workflows/__tests__/execute-serverless-workflow.test.ts +68 -2
  257. package/src/workflows/call-agent-orchestrator.ts +10 -0
  258. package/src/workflows/connect-mcp-server.ts +7 -0
  259. package/src/workflows/engine-core.ts +23 -2
  260. package/src/workflows/execute-from-execution.ts +12 -2
@@ -11,8 +11,12 @@
11
11
  * the desktop Run dialog via the IPC handshake.
12
12
  */
13
13
 
14
- import { describe, expect, it } from "vitest";
15
- import { isNodeSqliteAvailable, preflightNodeRuntime } from "../preflight.js";
14
+ import { afterEach, describe, expect, it, vi } from "vitest";
15
+ import {
16
+ isNodeSqliteAvailable,
17
+ preflightNodeRuntime,
18
+ assertLlmBackendsPreflight,
19
+ } from "../preflight.js";
16
20
 
17
21
  describe("preflightNodeRuntime", () => {
18
22
  it("passes (returns null) when node:sqlite is available", () => {
@@ -39,3 +43,47 @@ describe("preflightNodeRuntime", () => {
39
43
  expect(preflightNodeRuntime() === null).toBe(isNodeSqliteAvailable());
40
44
  });
41
45
  });
46
+
47
+ describe("assertLlmBackendsPreflight", () => {
48
+ // This is the gate both runner factories run at construction — the throw
49
+ // is what a misconfigured deployment sees instead of accepting work. The
50
+ // per-case parse/prereq tables live in shared/__tests__/llm-backend.test.ts;
51
+ // here we pin the wrapper's contract: throw on fatal, warn on precedence,
52
+ // silent when clean, and options-proxy treated like the env proxy.
53
+ afterEach(() => {
54
+ vi.unstubAllEnvs();
55
+ vi.restoreAllMocks();
56
+ });
57
+
58
+ /** Pin the vars this gate reads so ambient shell env cannot leak in. */
59
+ function stubBackendEnv(vars: Record<string, string>) {
60
+ vi.stubEnv("STIGMER_PROXY_ENDPOINT", "");
61
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
62
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
63
+ vi.stubEnv("CLOUD_ML_REGION", "");
64
+ for (const [key, value] of Object.entries(vars)) {
65
+ vi.stubEnv(key, value);
66
+ }
67
+ }
68
+
69
+ it("passes silently for an unconfigured deployment", () => {
70
+ stubBackendEnv({});
71
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
72
+ expect(() => assertLlmBackendsPreflight(null)).not.toThrow();
73
+ expect(warn).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("throws the actionable message for a misconfigured backend", () => {
77
+ stubBackendEnv({ STIGMER_ANTHROPIC_BACKEND: "vertex" });
78
+ expect(() => assertLlmBackendsPreflight(null)).toThrow(/CLOUD_ML_REGION/);
79
+ });
80
+
81
+ it("warns instead of throwing when an options-supplied proxy makes the var inert", () => {
82
+ stubBackendEnv({ STIGMER_ANTHROPIC_BACKEND: "vertex" });
83
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
84
+ expect(() => assertLlmBackendsPreflight("https://api.stigmer.ai")).not.toThrow();
85
+ expect(warn).toHaveBeenCalledWith(
86
+ expect.stringContaining("STIGMER_ANTHROPIC_BACKEND=vertex is ignored"),
87
+ );
88
+ });
89
+ });
@@ -5,6 +5,45 @@ import { _resetRegistryCache } from "../../shared/model-registry.js";
5
5
  const originalFetch = globalThis.fetch;
6
6
  const originalEnv = { ...process.env };
7
7
 
8
+ const { vertexCreateRequests } = vi.hoisted(() => ({
9
+ vertexCreateRequests: [] as Array<Record<string, unknown>>,
10
+ }));
11
+
12
+ // Mocked at the SDK class (the vertex-adapter.test.ts pattern) so no real
13
+ // google-auth credential resolution ever runs inside a unit test — locally
14
+ // it fails fast, but in CI it can probe the GCE metadata server. ChatAnthropic
15
+ // consumes the client's parsed event stream, so the streaming arm returns a
16
+ // plain async generator of Anthropic stream events.
17
+ vi.mock("@anthropic-ai/vertex-sdk", () => ({
18
+ AnthropicVertex: class {
19
+ readonly messages = {
20
+ create: async (request: Record<string, unknown>) => {
21
+ vertexCreateRequests.push(request);
22
+ if (request.stream === true) {
23
+ return (async function* () {
24
+ yield { type: "message_start", message: { id: "msg_vertex_call_llm", type: "message", role: "assistant", model: request.model, content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 11, output_tokens: 1 } } };
25
+ yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } };
26
+ yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Vertex direct." } };
27
+ yield { type: "content_block_stop", index: 0 };
28
+ yield { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 4 } };
29
+ yield { type: "message_stop" };
30
+ })();
31
+ }
32
+ return {
33
+ id: "msg_vertex_call_llm",
34
+ type: "message",
35
+ role: "assistant",
36
+ model: request.model,
37
+ content: [{ type: "text", text: "Vertex direct." }],
38
+ stop_reason: "end_turn",
39
+ stop_sequence: null,
40
+ usage: { input_tokens: 11, output_tokens: 4 },
41
+ };
42
+ },
43
+ };
44
+ },
45
+ }));
46
+
8
47
  describe("callLlmAction", () => {
9
48
  let mockFetch: ReturnType<typeof vi.fn>;
10
49
 
@@ -16,6 +55,12 @@ describe("callLlmAction", () => {
16
55
  delete process.env.STIGMER_TOKEN;
17
56
  delete process.env.OPENAI_API_KEY;
18
57
  delete process.env.ANTHROPIC_API_KEY;
58
+ // Deterministic regardless of the developer's shell: a leaked
59
+ // STIGMER_ANTHROPIC_BACKEND=vertex would reroute every direct-mode test.
60
+ delete process.env.STIGMER_ANTHROPIC_BACKEND;
61
+ delete process.env.STIGMER_OPENAI_BACKEND;
62
+ delete process.env.CLOUD_ML_REGION;
63
+ vertexCreateRequests.length = 0;
19
64
  });
20
65
 
21
66
  afterEach(() => {
@@ -234,6 +279,36 @@ describe("callLlmAction", () => {
234
279
  });
235
280
  });
236
281
 
282
+ describe("direct mode — Anthropic on the vertex backend", () => {
283
+ it("serves the call with no ANTHROPIC_API_KEY anywhere (ADC is the credential path)", async () => {
284
+ // The regression pin for the backend-blind key check: a correctly
285
+ // configured Vertex deployment holds no Anthropic key, and llm_call
286
+ // tasks must not fail with LLM_MISSING_API_KEY before construction.
287
+ process.env.STIGMER_ANTHROPIC_BACKEND = "vertex";
288
+ process.env.CLOUD_ML_REGION = "global";
289
+ mockFetchWithRegistry(() => {
290
+ throw new Error("unexpected fetch: vertex traffic must go through the SDK client");
291
+ });
292
+
293
+ const result = await callLlmAction(baseAnthropicConfig, {}, "exec-1");
294
+
295
+ expect(result.result).toBe("Vertex direct.");
296
+ expect(result.provider).toBe("anthropic");
297
+ expect(result.input_tokens).toBe(11);
298
+ expect(result.output_tokens).toBe(4);
299
+ expect(vertexCreateRequests.length).toBeGreaterThan(0);
300
+ });
301
+
302
+ it("keeps the LLM_MISSING_API_KEY contract on the public backend, now naming the backend remedy", async () => {
303
+ const err = await callLlmAction(baseAnthropicConfig, {}, "exec-1").catch((e: unknown) => e as Error & { type?: string });
304
+
305
+ expect(err).toBeInstanceOf(Error);
306
+ expect((err as { type?: string }).type).toBe("LLM_MISSING_API_KEY");
307
+ expect((err as Error).message).toContain("ANTHROPIC_API_KEY");
308
+ expect((err as Error).message).toContain("STIGMER_ANTHROPIC_BACKEND=vertex");
309
+ });
310
+ });
311
+
237
312
  describe("proxy mode", () => {
238
313
  it("routes through proxy when endpoint is set", async () => {
239
314
  process.env.STIGMER_PROXY_ENDPOINT = "https://proxy.stigmer.ai";
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi, beforeEach } from "vitest";
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
2
  import type { ClassifyToolApprovalsInput, ToolDescriptor } from "../classify-tool-approvals.js";
3
3
 
4
4
  vi.mock("../../idle-watchdog.js", () => ({
@@ -597,6 +597,122 @@ describe("ClassifyToolApprovals activity", () => {
597
597
  expect(headers).not.toHaveProperty("X-Stigmer-Mcp-Server-Id");
598
598
  });
599
599
  });
600
+
601
+ describe("direct mode (no proxy)", () => {
602
+ // The regression suite for the stigmerBackendEndpoint-as-LLM-proxy bug:
603
+ // an unproxied runner must call the provider directly with the real key,
604
+ // and must fail closed with one clear message when it has none.
605
+ const directOptions = { proxyEndpoint: null, stigmerToken: null, primaryModel: "gpt-4.1" };
606
+
607
+ beforeEach(() => {
608
+ // Deterministic regardless of the developer's shell: blank reads as
609
+ // missing, and backend/proxy vars must not leak in from outside.
610
+ vi.stubEnv("OPENAI_API_KEY", "");
611
+ vi.stubEnv("ANTHROPIC_API_KEY", "");
612
+ vi.stubEnv("STIGMER_ANTHROPIC_BACKEND", "");
613
+ vi.stubEnv("STIGMER_OPENAI_BACKEND", "");
614
+ vi.stubEnv("STIGMER_PROXY_ENDPOINT", "");
615
+ });
616
+
617
+ afterEach(() => {
618
+ vi.unstubAllEnvs();
619
+ });
620
+
621
+ it("fails closed without any credential path: every tool gated, no client constructed", async () => {
622
+ const { ChatAnthropic } = await import("@langchain/anthropic");
623
+ const { ChatOpenAI } = await import("@langchain/openai");
624
+ const { classifyTools } = await import("../classify-tool-approvals.js");
625
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
626
+
627
+ const tools: ToolDescriptor[] = [
628
+ { name: "search_code", description: "Search code" },
629
+ { name: "delete_file", description: "Delete a file" },
630
+ ];
631
+
632
+ const result = await classifyTools(
633
+ { tools, serverName: "github", serverDescription: "GitHub", mcpServerId: null },
634
+ directOptions,
635
+ );
636
+
637
+ expect(result).toEqual([
638
+ { tool_name: "search_code", requires_approval: true, message: "Execute search_code" },
639
+ { tool_name: "delete_file", requires_approval: true, message: "Execute delete_file" },
640
+ ]);
641
+ expect(ChatOpenAI).not.toHaveBeenCalled();
642
+ expect(ChatAnthropic).not.toHaveBeenCalled();
643
+ expect(mockInvoke).not.toHaveBeenCalled();
644
+ // The message must name the model and provider (classification's
645
+ // provider follows the economy model, not the operator's key) and
646
+ // carry the predicate's remedy text.
647
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("'gpt-4o-mini' (openai)"));
648
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("OPENAI_API_KEY"));
649
+ warn.mockRestore();
650
+ });
651
+
652
+ it("uses the provider key directly — no proxy baseURL, no Authorization header", async () => {
653
+ vi.stubEnv("OPENAI_API_KEY", "sk-direct-test");
654
+ const { ChatOpenAI } = await import("@langchain/openai");
655
+ const { classifyTools } = await import("../classify-tool-approvals.js");
656
+
657
+ mockInvoke.mockResolvedValueOnce({
658
+ approvals: [{ tool_name: "t", requires_approval: false, message: "" }],
659
+ });
660
+
661
+ const result = await classifyTools(
662
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
663
+ directOptions,
664
+ );
665
+
666
+ expect(result).toEqual([]);
667
+ const args = vi.mocked(ChatOpenAI).mock.calls.at(-1)?.[0] as Record<string, unknown>;
668
+ expect(args.apiKey).toBe("sk-direct-test");
669
+ // The bug this suite pins: the gRPC control-plane endpoint must never
670
+ // reappear as a baseURL/Authorization transport override.
671
+ expect(args).not.toHaveProperty("configuration");
672
+ });
673
+
674
+ it("routes an Anthropic economy model directly through ChatAnthropic with the real key", async () => {
675
+ vi.stubEnv("ANTHROPIC_API_KEY", "sk-ant-direct");
676
+ const { ChatAnthropic } = await import("@langchain/anthropic");
677
+ const { getSummarizationModel } = await import("../../shared/model-registry.js");
678
+ vi.mocked(getSummarizationModel).mockResolvedValueOnce("claude-haiku-4.5");
679
+ const { classifyTools } = await import("../classify-tool-approvals.js");
680
+
681
+ mockInvoke.mockResolvedValueOnce({
682
+ approvals: [{ tool_name: "t", requires_approval: false, message: "" }],
683
+ });
684
+
685
+ await classifyTools(
686
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
687
+ directOptions,
688
+ );
689
+
690
+ const args = vi.mocked(ChatAnthropic).mock.calls.at(-1)?.[0] as Record<string, unknown>;
691
+ expect(args.apiKey).toBe("sk-ant-direct");
692
+ expect(args).not.toHaveProperty("clientOptions");
693
+ });
694
+
695
+ it("defers an un-inferable economy model to the per-batch fail-closed path", async () => {
696
+ // Real scenario: the registry-empty fallback returns the primary model
697
+ // verbatim (model-registry.ts), whatever its name. The pre-check must
698
+ // not guess a provider — the batch path owns the precise error.
699
+ const { getSummarizationModel } = await import("../../shared/model-registry.js");
700
+ vi.mocked(getSummarizationModel).mockResolvedValueOnce("mystery-model");
701
+ const { classifyTools } = await import("../classify-tool-approvals.js");
702
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
703
+
704
+ const result = await classifyTools(
705
+ { tools: [{ name: "t", description: "d" }], serverName: "s", serverDescription: "", mcpServerId: null },
706
+ directOptions,
707
+ );
708
+
709
+ expect(result).toEqual([
710
+ { tool_name: "t", requires_approval: true, message: "Execute t" },
711
+ ]);
712
+ expect(error).toHaveBeenCalled();
713
+ error.mockRestore();
714
+ });
715
+ });
600
716
  });
601
717
 
602
718
  // ─────────────────────────────────────────────────────────────────────────────
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Regression pin for issue #239's hang mechanism, against the REAL MCP client
3
+ * stack (no @langchain/mcp-adapters mock — that's why this lives in its own
4
+ * file, apart from discover-mcp-server.test.ts's mocked suite).
5
+ *
6
+ * The monday.com failure shape: the endpoint answers the streamable-HTTP
7
+ * initialize POST with a 4xx, mcp-adapters automatically falls back to SSE at
8
+ * the same URL, the endpoint accepts the stream and never sends the legacy
9
+ * `endpoint` event — and the MCP SDK's SSEClientTransport has no timer of its
10
+ * own, so initialization hangs forever. Before the transport-aware init bound,
11
+ * that hang rode until Temporal killed the activity opaquely; this test pins
12
+ * that discovery now fails fast (in fake time) with an error that names the
13
+ * endpoint.
14
+ */
15
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
16
+ import { createServer, type Server } from "node:http";
17
+
18
+ vi.mock("../../idle-watchdog.js", () => ({
19
+ activityStarted: vi.fn(),
20
+ activityFinished: vi.fn(),
21
+ }));
22
+
23
+ // classifyHttpOAuthFailure re-probes the endpoint on the failure path; keep it
24
+ // deterministic here (its own behavior is covered by mcp-oauth-detect tests).
25
+ vi.mock("../../shared/mcp-oauth-detect.js", () => ({
26
+ detectOAuthChallenge: vi.fn().mockResolvedValue(null),
27
+ }));
28
+
29
+ describe("discovery against a 4xx-then-silent-SSE endpoint (issue #239)", () => {
30
+ let server: Server;
31
+ let url: string;
32
+
33
+ beforeEach(async () => {
34
+ server = createServer((req, res) => {
35
+ if (req.method === "POST") {
36
+ // monday-shaped: reject the streamable-HTTP initialize outright.
37
+ res.writeHead(405, { "Content-Type": "application/json" });
38
+ res.end(JSON.stringify({ error: "method not allowed" }));
39
+ return;
40
+ }
41
+ // The SSE fallback: accept the stream, never send an `endpoint` event.
42
+ res.writeHead(200, {
43
+ "Content-Type": "text/event-stream",
44
+ "Cache-Control": "no-cache",
45
+ Connection: "keep-alive",
46
+ });
47
+ res.write(": silent stream\n\n");
48
+ });
49
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
50
+ const address = server.address();
51
+ if (typeof address === "string" || address === null) throw new Error("no port");
52
+ url = `http://127.0.0.1:${address.port}/mcp`;
53
+ });
54
+
55
+ afterEach(() => {
56
+ // closeAllConnections: the hung SSE stream is a live socket that would
57
+ // otherwise keep server.close() (and the vitest worker) waiting.
58
+ server.closeAllConnections();
59
+ server.close();
60
+ vi.useRealTimers();
61
+ });
62
+
63
+ it("fails within the HTTP init bound with an endpoint-naming error", async () => {
64
+ const { discoverMcpServer } = await import("../discover-mcp-server.js");
65
+
66
+ const stigmerClient = {
67
+ getMcpServer: vi.fn().mockResolvedValue({
68
+ metadata: { slug: "monday", id: "mcp-monday" },
69
+ spec: {
70
+ serverType: {
71
+ case: "http",
72
+ value: { url, headers: {}, queryParams: {}, timeoutSeconds: 0 },
73
+ },
74
+ env: {},
75
+ pinnedToolApprovals: [],
76
+ },
77
+ status: undefined,
78
+ }),
79
+ getExecutionContextByExecutionId: vi.fn(),
80
+ };
81
+
82
+ // shouldAdvanceTime keeps real I/O flowing (the POST → 405 → SSE fallback
83
+ // happens over real sockets) while letting the test jump the 60s bound.
84
+ vi.useFakeTimers({ shouldAdvanceTime: true });
85
+
86
+ const promise = discoverMcpServer(
87
+ { mcpServerId: "mcp-monday" },
88
+ { stigmerClient: stigmerClient as never, transportPosture: "stdio-forbidden" },
89
+ );
90
+ // Attach the rejection expectation BEFORE advancing so the rejection is
91
+ // never momentarily unhandled.
92
+ const expectation = expect(promise).rejects.toThrow(
93
+ new RegExp(`at ${url.replaceAll(".", "\\.")}.*did not complete MCP initialization`, "s"),
94
+ );
95
+
96
+ // Give the real client a beat to reach the hang (POST + fallback), then
97
+ // jump past the 30s HTTP bound in fake time.
98
+ await new Promise((resolve) => setTimeout(resolve, 250));
99
+ await vi.advanceTimersByTimeAsync(31_000);
100
+
101
+ await expectation;
102
+ }, 15_000);
103
+ });
@@ -10,6 +10,14 @@ vi.mock("../../idle-watchdog.js", () => ({
10
10
  activityFinished: vi.fn(),
11
11
  }));
12
12
 
13
+ // The factory starts a Temporal heartbeat loop; outside an activity context
14
+ // its ticks would throw, so replace it with a stop-spy and assert the
15
+ // start/stop contract instead.
16
+ const mockHeartbeatStop = vi.fn();
17
+ vi.mock("../../shared/heartbeat.js", () => ({
18
+ startHeartbeat: vi.fn(() => ({ stop: mockHeartbeatStop, cancelled: false, workerShutdown: false })),
19
+ }));
20
+
13
21
  const mockInitializeConnections = vi.fn();
14
22
  const mockGetClient = vi.fn();
15
23
  const mockClose = vi.fn().mockResolvedValue(undefined);
@@ -600,6 +608,122 @@ describe("DiscoverMcpServer activity", () => {
600
608
  expect(result.newToolsFingerprint).toBe("");
601
609
  });
602
610
 
611
+ it("fails closed when the EC read errors and the server requires credentials", async () => {
612
+ // Issue #239: limping ahead with an empty env either died later as an
613
+ // opaque PlaceholderResolutionError or dialed the endpoint with a
614
+ // garbage credential and wedged in the 4xx → SSE-fallback limbo. The
615
+ // failure must name the root cause: credential delivery.
616
+ const { discoverMcpServer, CredentialResolutionError } = await import("../discover-mcp-server.js");
617
+
618
+ const spec = makeHttpSpec("https://mcp.monday.com/mcp");
619
+ spec.env = { MONDAY_ACCESS_TOKEN: { isSecret: true } };
620
+ const mockClient = makeMockStigmerClient({
621
+ mcpServer: makeMcpServer({ metadata: { slug: "monday" }, spec }),
622
+ });
623
+ mockClient.getExecutionContextByExecutionId = vi
624
+ .fn()
625
+ .mockRejectedValue(new Error("PERMISSION_DENIED: not scope-bound"));
626
+
627
+ await expect(
628
+ discoverMcpServer(
629
+ { mcpServerId: "mcp-monday", executionContextId: "ctx-1" },
630
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
631
+ ),
632
+ ).rejects.toThrow(CredentialResolutionError);
633
+ expect(mockInitializeConnections).not.toHaveBeenCalled();
634
+ });
635
+
636
+ it("keeps the lenient path when the EC read errors but no env is declared", async () => {
637
+ const { discoverMcpServer } = await import("../discover-mcp-server.js");
638
+
639
+ const mockClient = makeMockStigmerClient({
640
+ mcpServer: makeMcpServer({
641
+ metadata: { slug: "no-env" },
642
+ spec: makeHttpSpec("https://mcp.example.com"),
643
+ }),
644
+ });
645
+ mockClient.getExecutionContextByExecutionId = vi
646
+ .fn()
647
+ .mockRejectedValue(new Error("transient"));
648
+
649
+ mockInitializeConnections.mockResolvedValue({});
650
+ mockGetClient.mockResolvedValue(makeMockMcpClient({ tools: [] }));
651
+
652
+ const result = await discoverMcpServer(
653
+ { mcpServerId: "mcp-lenient", executionContextId: "ctx-2" },
654
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
655
+ );
656
+ expect(result.tools).toEqual([]);
657
+ });
658
+
659
+ it("keeps the lenient path when all declared env vars are optional", async () => {
660
+ // Caller-identity-style servers declare only optional keys; discovery
661
+ // legitimately proceeds without an EC for them (the injections below
662
+ // resolveEnvVarsForDiscovery supply the values).
663
+ const { discoverMcpServer } = await import("../discover-mcp-server.js");
664
+
665
+ const spec = makeHttpSpec("https://mcp.example.com");
666
+ spec.env = { STIGMER_CALLER_IDENTITY_KIND: { optional: true } };
667
+ const mockClient = makeMockStigmerClient({
668
+ mcpServer: makeMcpServer({ metadata: { slug: "optional-only" }, spec }),
669
+ });
670
+ mockClient.getExecutionContextByExecutionId = vi
671
+ .fn()
672
+ .mockRejectedValue(new Error("transient"));
673
+
674
+ mockInitializeConnections.mockResolvedValue({});
675
+ mockGetClient.mockResolvedValue(makeMockMcpClient({ tools: [] }));
676
+
677
+ const result = await discoverMcpServer(
678
+ { mcpServerId: "mcp-optional", executionContextId: "ctx-3" },
679
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
680
+ );
681
+ expect(result.tools).toEqual([]);
682
+ });
683
+
684
+ it("fails closed when the EC is empty and the server requires credentials", async () => {
685
+ const { discoverMcpServer, CredentialResolutionError } = await import("../discover-mcp-server.js");
686
+
687
+ const spec = makeHttpSpec("https://mcp.monday.com/mcp");
688
+ spec.env = { MONDAY_ACCESS_TOKEN: { isSecret: true } };
689
+ const mockClient = makeMockStigmerClient({
690
+ mcpServer: makeMcpServer({ metadata: { slug: "monday" }, spec }),
691
+ executionContext: { spec: { data: {} } },
692
+ });
693
+
694
+ const promise = discoverMcpServer(
695
+ { mcpServerId: "mcp-monday", executionContextId: "ctx-4" },
696
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
697
+ );
698
+ await expect(promise).rejects.toThrow(CredentialResolutionError);
699
+ await expect(promise).rejects.toThrow(/delivered no credentials/);
700
+ expect(mockInitializeConnections).not.toHaveBeenCalled();
701
+ });
702
+
703
+ it("fails closed when a delivered credential is the redaction sentinel", async () => {
704
+ // A redacted value means the server-side decrypt gate refused this
705
+ // runner's credential class/scope — dialing with the literal sentinel
706
+ // can only produce a misleading 401 (the issue-#239 M2 mechanism).
707
+ const { discoverMcpServer, CredentialResolutionError } = await import("../discover-mcp-server.js");
708
+
709
+ const spec = makeHttpSpec("https://mcp.monday.com/mcp");
710
+ spec.env = { MONDAY_ACCESS_TOKEN: { isSecret: true } };
711
+ const mockClient = makeMockStigmerClient({
712
+ mcpServer: makeMcpServer({ metadata: { slug: "monday" }, spec }),
713
+ executionContext: {
714
+ spec: { data: { MONDAY_ACCESS_TOKEN: { value: "***REDACTED***", isSecret: true } } },
715
+ },
716
+ });
717
+
718
+ const promise = discoverMcpServer(
719
+ { mcpServerId: "mcp-monday", executionContextId: "ctx-5" },
720
+ { stigmerClient: mockClient as any, transportPosture: "stdio-forbidden" },
721
+ );
722
+ await expect(promise).rejects.toThrow(CredentialResolutionError);
723
+ await expect(promise).rejects.toThrow(/MONDAY_ACCESS_TOKEN.*delivered redacted|delivered redacted.*MONDAY_ACCESS_TOKEN/s);
724
+ expect(mockInitializeConnections).not.toHaveBeenCalled();
725
+ });
726
+
603
727
  it("closes MCP client even when discovery fails", async () => {
604
728
  const { discoverMcpServer } = await import("../discover-mcp-server.js");
605
729
 
@@ -620,6 +744,85 @@ describe("DiscoverMcpServer activity", () => {
620
744
  });
621
745
  });
622
746
 
747
+ // ─────────────────────────────────────────────────────────────────────────
748
+ // Transport-aware init bounds (issue #239)
749
+ // ─────────────────────────────────────────────────────────────────────────
750
+
751
+ describe("transport-aware init bounds", () => {
752
+ it("bounds HTTP endpoints at 30s and stdio at 270s", async () => {
753
+ // HTTP has no cold-start excuse: a healthy endpoint completes the MCP
754
+ // handshake in seconds, so a short bound converts the silent-SSE hang
755
+ // into a fast actionable failure. stdio keeps the cold-start allowance
756
+ // (issue #243). The OSS server's connect-workflow budget (connectTimeout,
757
+ // controller/connect.go — pinned there) is derived from the stdio bound
758
+ // + the classification floor, so both bounds stay reachable under it.
759
+ const { initTimeoutMsFor } = await import("../discover-mcp-server.js");
760
+ expect(initTimeoutMsFor("http")).toBe(30_000);
761
+ expect(initTimeoutMsFor("sse")).toBe(30_000);
762
+ expect(initTimeoutMsFor("stdio")).toBe(270_000);
763
+ });
764
+
765
+ it("names the endpoint URL in the HTTP timeout message", async () => {
766
+ const { initTimeoutMessageFor } = await import("../discover-mcp-server.js");
767
+ const message = initTimeoutMessageFor("monday", {
768
+ slug: "monday",
769
+ connectionType: "http",
770
+ url: "https://mcp.monday.com/mcp",
771
+ toolApprovals: [],
772
+ pinnedToolApprovals: [],
773
+ toolApprovalOverrides: [],
774
+ discoveredCapabilitiesEmpty: true,
775
+ });
776
+ expect(message).toContain("https://mcp.monday.com/mcp");
777
+ expect(message).toContain("30s");
778
+ expect(message).toContain("SSE fallback");
779
+ });
780
+
781
+ it("names the command in the stdio timeout message", async () => {
782
+ const { initTimeoutMessageFor } = await import("../discover-mcp-server.js");
783
+ const message = initTimeoutMessageFor("filesystem", {
784
+ slug: "filesystem",
785
+ connectionType: "stdio",
786
+ command: "npx",
787
+ toolApprovals: [],
788
+ pinnedToolApprovals: [],
789
+ toolApprovalOverrides: [],
790
+ discoveredCapabilitiesEmpty: true,
791
+ });
792
+ expect(message).toContain("npx");
793
+ expect(message).toContain("270s");
794
+ expect(message).toContain("cold start");
795
+ });
796
+ });
797
+
798
+ // ─────────────────────────────────────────────────────────────────────────
799
+ // Heartbeat contract (issue #239)
800
+ // ─────────────────────────────────────────────────────────────────────────
801
+
802
+ describe("heartbeat contract", () => {
803
+ it("starts a heartbeat for the activity and stops it on completion", async () => {
804
+ // The connect workflow proxies this activity with a 60s heartbeatTimeout.
805
+ // Without a heartbeat loop, any discovery slower than 60s was killed by
806
+ // Temporal with an opaque heartbeat timeout — never reaching the
807
+ // actionable init-timeout errors (issues #239/#243).
808
+ const { createDiscoverMcpServerActivities } = await import("../discover-mcp-server.js");
809
+ const { startHeartbeat } = await import("../../shared/heartbeat.js");
810
+
811
+ vi.mocked(startHeartbeat).mockClear();
812
+ mockHeartbeatStop.mockClear();
813
+
814
+ const { DiscoverMcpServerCapabilities } = createDiscoverMcpServerActivities(makeConfig());
815
+ try {
816
+ await DiscoverMcpServerCapabilities({ mcpServerId: "test" });
817
+ } catch {
818
+ // Expected — no real backend behind the factory's own client.
819
+ }
820
+
821
+ expect(startHeartbeat).toHaveBeenCalledTimes(1);
822
+ expect(mockHeartbeatStop).toHaveBeenCalledTimes(1);
823
+ });
824
+ });
825
+
623
826
  // ─────────────────────────────────────────────────────────────────────────
624
827
  // Idle Watchdog Integration
625
828
  // ─────────────────────────────────────────────────────────────────────────