joonecli 0.1.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 (279) hide show
  1. package/AGENTS.md +56 -0
  2. package/Handover.md +115 -0
  3. package/LICENSE +201 -0
  4. package/PROGRESS.md +160 -0
  5. package/README.md +114 -0
  6. package/dist/__tests__/bootstrap.test.d.ts +1 -0
  7. package/dist/__tests__/bootstrap.test.js +76 -0
  8. package/dist/__tests__/bootstrap.test.js.map +1 -0
  9. package/dist/__tests__/config.test.d.ts +1 -0
  10. package/dist/__tests__/config.test.js +84 -0
  11. package/dist/__tests__/config.test.js.map +1 -0
  12. package/dist/__tests__/m55.test.d.ts +1 -0
  13. package/dist/__tests__/m55.test.js +160 -0
  14. package/dist/__tests__/m55.test.js.map +1 -0
  15. package/dist/__tests__/middleware.test.d.ts +1 -0
  16. package/dist/__tests__/middleware.test.js +169 -0
  17. package/dist/__tests__/middleware.test.js.map +1 -0
  18. package/dist/__tests__/modelFactory.test.d.ts +1 -0
  19. package/dist/__tests__/modelFactory.test.js +50 -0
  20. package/dist/__tests__/modelFactory.test.js.map +1 -0
  21. package/dist/__tests__/optimizations.test.d.ts +1 -0
  22. package/dist/__tests__/optimizations.test.js +136 -0
  23. package/dist/__tests__/optimizations.test.js.map +1 -0
  24. package/dist/__tests__/promptBuilder.test.d.ts +1 -0
  25. package/dist/__tests__/promptBuilder.test.js +108 -0
  26. package/dist/__tests__/promptBuilder.test.js.map +1 -0
  27. package/dist/__tests__/sandbox.test.d.ts +1 -0
  28. package/dist/__tests__/sandbox.test.js +78 -0
  29. package/dist/__tests__/sandbox.test.js.map +1 -0
  30. package/dist/__tests__/security.test.d.ts +1 -0
  31. package/dist/__tests__/security.test.js +86 -0
  32. package/dist/__tests__/security.test.js.map +1 -0
  33. package/dist/__tests__/streaming.test.d.ts +1 -0
  34. package/dist/__tests__/streaming.test.js +71 -0
  35. package/dist/__tests__/streaming.test.js.map +1 -0
  36. package/dist/__tests__/toolRouter.test.d.ts +1 -0
  37. package/dist/__tests__/toolRouter.test.js +37 -0
  38. package/dist/__tests__/toolRouter.test.js.map +1 -0
  39. package/dist/__tests__/tools.test.d.ts +1 -0
  40. package/dist/__tests__/tools.test.js +112 -0
  41. package/dist/__tests__/tools.test.js.map +1 -0
  42. package/dist/__tests__/tracing.test.d.ts +1 -0
  43. package/dist/__tests__/tracing.test.js +147 -0
  44. package/dist/__tests__/tracing.test.js.map +1 -0
  45. package/dist/cli/config.d.ts +49 -0
  46. package/dist/cli/config.js +86 -0
  47. package/dist/cli/config.js.map +1 -0
  48. package/dist/cli/index.d.ts +2 -0
  49. package/dist/cli/index.js +625 -0
  50. package/dist/cli/index.js.map +1 -0
  51. package/dist/cli/modelFactory.d.ts +9 -0
  52. package/dist/cli/modelFactory.js +154 -0
  53. package/dist/cli/modelFactory.js.map +1 -0
  54. package/dist/cli/providers.d.ts +18 -0
  55. package/dist/cli/providers.js +94 -0
  56. package/dist/cli/providers.js.map +1 -0
  57. package/dist/core/agentLoop.d.ts +43 -0
  58. package/dist/core/agentLoop.js +245 -0
  59. package/dist/core/agentLoop.js.map +1 -0
  60. package/dist/core/errors.d.ts +62 -0
  61. package/dist/core/errors.js +139 -0
  62. package/dist/core/errors.js.map +1 -0
  63. package/dist/core/promptBuilder.d.ts +49 -0
  64. package/dist/core/promptBuilder.js +84 -0
  65. package/dist/core/promptBuilder.js.map +1 -0
  66. package/dist/core/reasoningRouter.d.ts +62 -0
  67. package/dist/core/reasoningRouter.js +102 -0
  68. package/dist/core/reasoningRouter.js.map +1 -0
  69. package/dist/core/retry.d.ts +25 -0
  70. package/dist/core/retry.js +49 -0
  71. package/dist/core/retry.js.map +1 -0
  72. package/dist/core/sessionResumer.d.ts +17 -0
  73. package/dist/core/sessionResumer.js +78 -0
  74. package/dist/core/sessionResumer.js.map +1 -0
  75. package/dist/core/sessionStore.d.ts +45 -0
  76. package/dist/core/sessionStore.js +167 -0
  77. package/dist/core/sessionStore.js.map +1 -0
  78. package/dist/core/tokenCounter.d.ts +17 -0
  79. package/dist/core/tokenCounter.js +54 -0
  80. package/dist/core/tokenCounter.js.map +1 -0
  81. package/dist/evals/dataset.d.ts +4 -0
  82. package/dist/evals/dataset.js +61 -0
  83. package/dist/evals/dataset.js.map +1 -0
  84. package/dist/evals/evaluator.d.ts +21 -0
  85. package/dist/evals/evaluator.js +68 -0
  86. package/dist/evals/evaluator.js.map +1 -0
  87. package/dist/hitl/bridge.d.ts +65 -0
  88. package/dist/hitl/bridge.js +120 -0
  89. package/dist/hitl/bridge.js.map +1 -0
  90. package/dist/middleware/commandSanitizer.d.ts +18 -0
  91. package/dist/middleware/commandSanitizer.js +50 -0
  92. package/dist/middleware/commandSanitizer.js.map +1 -0
  93. package/dist/middleware/loopDetection.d.ts +28 -0
  94. package/dist/middleware/loopDetection.js +49 -0
  95. package/dist/middleware/loopDetection.js.map +1 -0
  96. package/dist/middleware/permission.d.ts +17 -0
  97. package/dist/middleware/permission.js +59 -0
  98. package/dist/middleware/permission.js.map +1 -0
  99. package/dist/middleware/pipeline.d.ts +31 -0
  100. package/dist/middleware/pipeline.js +62 -0
  101. package/dist/middleware/pipeline.js.map +1 -0
  102. package/dist/middleware/preCompletion.d.ts +29 -0
  103. package/dist/middleware/preCompletion.js +82 -0
  104. package/dist/middleware/preCompletion.js.map +1 -0
  105. package/dist/middleware/types.d.ts +40 -0
  106. package/dist/middleware/types.js +8 -0
  107. package/dist/middleware/types.js.map +1 -0
  108. package/dist/sandbox/bootstrap.d.ts +38 -0
  109. package/dist/sandbox/bootstrap.js +107 -0
  110. package/dist/sandbox/bootstrap.js.map +1 -0
  111. package/dist/sandbox/manager.d.ts +72 -0
  112. package/dist/sandbox/manager.js +180 -0
  113. package/dist/sandbox/manager.js.map +1 -0
  114. package/dist/sandbox/sync.d.ts +55 -0
  115. package/dist/sandbox/sync.js +135 -0
  116. package/dist/sandbox/sync.js.map +1 -0
  117. package/dist/skills/loader.d.ts +55 -0
  118. package/dist/skills/loader.js +132 -0
  119. package/dist/skills/loader.js.map +1 -0
  120. package/dist/skills/tools.d.ts +5 -0
  121. package/dist/skills/tools.js +78 -0
  122. package/dist/skills/tools.js.map +1 -0
  123. package/dist/skills/types.d.ts +13 -0
  124. package/dist/skills/types.js +2 -0
  125. package/dist/skills/types.js.map +1 -0
  126. package/dist/test_cache.d.ts +1 -0
  127. package/dist/test_cache.js +55 -0
  128. package/dist/test_cache.js.map +1 -0
  129. package/dist/test_google.js +93 -0
  130. package/dist/tools/askUser.d.ts +10 -0
  131. package/dist/tools/askUser.js +42 -0
  132. package/dist/tools/askUser.js.map +1 -0
  133. package/dist/tools/browser.d.ts +19 -0
  134. package/dist/tools/browser.js +111 -0
  135. package/dist/tools/browser.js.map +1 -0
  136. package/dist/tools/index.d.ts +27 -0
  137. package/dist/tools/index.js +184 -0
  138. package/dist/tools/index.js.map +1 -0
  139. package/dist/tools/registry.d.ts +31 -0
  140. package/dist/tools/registry.js +168 -0
  141. package/dist/tools/registry.js.map +1 -0
  142. package/dist/tools/router.d.ts +34 -0
  143. package/dist/tools/router.js +73 -0
  144. package/dist/tools/router.js.map +1 -0
  145. package/dist/tools/security.d.ts +28 -0
  146. package/dist/tools/security.js +183 -0
  147. package/dist/tools/security.js.map +1 -0
  148. package/dist/tools/webSearch.d.ts +6 -0
  149. package/dist/tools/webSearch.js +120 -0
  150. package/dist/tools/webSearch.js.map +1 -0
  151. package/dist/tracing/analyzer.d.ts +58 -0
  152. package/dist/tracing/analyzer.js +190 -0
  153. package/dist/tracing/analyzer.js.map +1 -0
  154. package/dist/tracing/langsmith.d.ts +38 -0
  155. package/dist/tracing/langsmith.js +50 -0
  156. package/dist/tracing/langsmith.js.map +1 -0
  157. package/dist/tracing/sessionTracer.d.ts +73 -0
  158. package/dist/tracing/sessionTracer.js +157 -0
  159. package/dist/tracing/sessionTracer.js.map +1 -0
  160. package/dist/tracing/types.d.ts +46 -0
  161. package/dist/tracing/types.js +5 -0
  162. package/dist/tracing/types.js.map +1 -0
  163. package/dist/ui/App.d.ts +24 -0
  164. package/dist/ui/App.js +172 -0
  165. package/dist/ui/App.js.map +1 -0
  166. package/dist/ui/components/HITLPrompt.d.ts +15 -0
  167. package/dist/ui/components/HITLPrompt.js +35 -0
  168. package/dist/ui/components/HITLPrompt.js.map +1 -0
  169. package/dist/ui/components/Header.d.ts +8 -0
  170. package/dist/ui/components/Header.js +6 -0
  171. package/dist/ui/components/Header.js.map +1 -0
  172. package/dist/ui/components/MessageBubble.d.ts +13 -0
  173. package/dist/ui/components/MessageBubble.js +17 -0
  174. package/dist/ui/components/MessageBubble.js.map +1 -0
  175. package/dist/ui/components/StatusBar.d.ts +21 -0
  176. package/dist/ui/components/StatusBar.js +34 -0
  177. package/dist/ui/components/StatusBar.js.map +1 -0
  178. package/dist/ui/components/StreamingText.d.ts +13 -0
  179. package/dist/ui/components/StreamingText.js +24 -0
  180. package/dist/ui/components/StreamingText.js.map +1 -0
  181. package/dist/ui/components/ToolCallPanel.d.ts +15 -0
  182. package/dist/ui/components/ToolCallPanel.js +18 -0
  183. package/dist/ui/components/ToolCallPanel.js.map +1 -0
  184. package/docs/01_insights_and_patterns.md +27 -0
  185. package/docs/02_edge_cases_and_mitigations.md +143 -0
  186. package/docs/03_initial_implementation_plan.md +66 -0
  187. package/docs/04_tech_stack_proposal.md +20 -0
  188. package/docs/05_prd.md +87 -0
  189. package/docs/06_user_stories.md +72 -0
  190. package/docs/07_system_architecture.md +138 -0
  191. package/docs/08_roadmap.md +200 -0
  192. package/e2b/Dockerfile +26 -0
  193. package/package.json +57 -0
  194. package/src/__tests__/bootstrap.test.ts +111 -0
  195. package/src/__tests__/config.test.ts +97 -0
  196. package/src/__tests__/m55.test.ts +238 -0
  197. package/src/__tests__/middleware.test.ts +219 -0
  198. package/src/__tests__/modelFactory.test.ts +63 -0
  199. package/src/__tests__/optimizations.test.ts +201 -0
  200. package/src/__tests__/promptBuilder.test.ts +141 -0
  201. package/src/__tests__/sandbox.test.ts +102 -0
  202. package/src/__tests__/security.test.ts +122 -0
  203. package/src/__tests__/streaming.test.ts +82 -0
  204. package/src/__tests__/toolRouter.test.ts +52 -0
  205. package/src/__tests__/tools.test.ts +146 -0
  206. package/src/__tests__/tracing.test.ts +196 -0
  207. package/src/agents/agentRegistry.ts +69 -0
  208. package/src/agents/agentSpec.ts +67 -0
  209. package/src/agents/builtinAgents.ts +142 -0
  210. package/src/cli/config.ts +124 -0
  211. package/src/cli/index.ts +730 -0
  212. package/src/cli/modelFactory.ts +174 -0
  213. package/src/cli/providers.ts +107 -0
  214. package/src/commands/builtinCommands.ts +293 -0
  215. package/src/commands/commandRegistry.ts +194 -0
  216. package/src/core/agentLoop.d.ts.map +1 -0
  217. package/src/core/agentLoop.ts +312 -0
  218. package/src/core/autoSave.ts +95 -0
  219. package/src/core/compactor.ts +252 -0
  220. package/src/core/contextGuard.ts +129 -0
  221. package/src/core/errors.ts +202 -0
  222. package/src/core/promptBuilder.d.ts.map +1 -0
  223. package/src/core/promptBuilder.ts +139 -0
  224. package/src/core/reasoningRouter.ts +121 -0
  225. package/src/core/retry.ts +75 -0
  226. package/src/core/sessionResumer.ts +90 -0
  227. package/src/core/sessionStore.ts +215 -0
  228. package/src/core/subAgent.ts +339 -0
  229. package/src/core/tokenCounter.ts +64 -0
  230. package/src/evals/dataset.ts +67 -0
  231. package/src/evals/evaluator.ts +81 -0
  232. package/src/hitl/bridge.ts +160 -0
  233. package/src/middleware/commandSanitizer.ts +60 -0
  234. package/src/middleware/loopDetection.ts +63 -0
  235. package/src/middleware/permission.ts +72 -0
  236. package/src/middleware/pipeline.ts +75 -0
  237. package/src/middleware/preCompletion.ts +94 -0
  238. package/src/middleware/types.ts +45 -0
  239. package/src/sandbox/bootstrap.ts +121 -0
  240. package/src/sandbox/manager.ts +239 -0
  241. package/src/sandbox/sync.ts +157 -0
  242. package/src/skills/loader.ts +143 -0
  243. package/src/skills/tools.ts +99 -0
  244. package/src/skills/types.ts +13 -0
  245. package/src/test_cache.ts +72 -0
  246. package/src/test_google.js +40 -0
  247. package/src/test_google.ts +40 -0
  248. package/src/tools/askUser.ts +47 -0
  249. package/src/tools/browser.ts +137 -0
  250. package/src/tools/index.d.ts.map +1 -0
  251. package/src/tools/index.ts +237 -0
  252. package/src/tools/registry.ts +198 -0
  253. package/src/tools/router.ts +78 -0
  254. package/src/tools/security.ts +220 -0
  255. package/src/tools/spawnAgent.ts +158 -0
  256. package/src/tools/webSearch.ts +142 -0
  257. package/src/tracing/analyzer.ts +265 -0
  258. package/src/tracing/langsmith.ts +63 -0
  259. package/src/tracing/sessionTracer.ts +202 -0
  260. package/src/tracing/types.ts +49 -0
  261. package/src/types/valyu.d.ts +37 -0
  262. package/src/ui/App.tsx +404 -0
  263. package/src/ui/components/HITLPrompt.tsx +119 -0
  264. package/src/ui/components/Header.tsx +51 -0
  265. package/src/ui/components/MessageBubble.tsx +46 -0
  266. package/src/ui/components/StatusBar.tsx +138 -0
  267. package/src/ui/components/StreamingText.tsx +48 -0
  268. package/src/ui/components/ToolCallPanel.tsx +80 -0
  269. package/tests/commands/commands.test.ts +356 -0
  270. package/tests/core/compactor.test.ts +217 -0
  271. package/tests/core/retryAndErrors.test.ts +164 -0
  272. package/tests/core/sessionResumer.test.ts +95 -0
  273. package/tests/core/sessionStore.test.ts +84 -0
  274. package/tests/core/stability.test.ts +165 -0
  275. package/tests/core/subAgent.test.ts +238 -0
  276. package/tests/hitl/hitlBridge.test.ts +115 -0
  277. package/tsconfig.json +16 -0
  278. package/vitest.config.ts +10 -0
  279. package/vitest.out +48 -0
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+
6
+ // We will import these once they exist — test-first.
7
+ import { loadConfig, saveConfig, DEFAULT_CONFIG } from "../cli/config.js";
8
+
9
+ describe("Config Manager", () => {
10
+ // Use a temp directory so we never touch the real ~/.joone
11
+ let tempDir: string;
12
+ let configPath: string;
13
+ let savedAnthropicKey: string | undefined;
14
+
15
+ beforeEach(() => {
16
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "joone-test-"));
17
+ configPath = path.join(tempDir, "config.json");
18
+ // Isolate from vitest.config.ts env vars
19
+ savedAnthropicKey = process.env.ANTHROPIC_API_KEY;
20
+ delete process.env.ANTHROPIC_API_KEY;
21
+ });
22
+
23
+ afterEach(() => {
24
+ fs.rmSync(tempDir, { recursive: true, force: true });
25
+ // Restore env var
26
+ if (savedAnthropicKey !== undefined) {
27
+ process.env.ANTHROPIC_API_KEY = savedAnthropicKey;
28
+ }
29
+ });
30
+
31
+ // ─── RED Test #1: loadConfig returns defaults when file doesn't exist ───
32
+
33
+ it("returns default config when config file does not exist", () => {
34
+ const config = loadConfig(configPath);
35
+
36
+ expect(config.provider).toBe("anthropic");
37
+ expect(config.model).toBe("claude-sonnet-4-20250514");
38
+ expect(config.apiKey).toBeUndefined();
39
+ expect(config.maxTokens).toBe(4096);
40
+ expect(config.temperature).toBe(0);
41
+ expect(config.streaming).toBe(true);
42
+ });
43
+
44
+ // ─── RED Test #2: saveConfig roundtrips with loadConfig ───
45
+
46
+ it("saves config to disk and loads it back correctly", () => {
47
+ const custom = {
48
+ ...DEFAULT_CONFIG,
49
+ provider: "openai",
50
+ model: "gpt-4o",
51
+ apiKey: "sk-test-key-123",
52
+ streaming: false,
53
+ };
54
+
55
+ saveConfig(configPath, custom);
56
+
57
+ // File should exist now
58
+ expect(fs.existsSync(configPath)).toBe(true);
59
+
60
+ // Load it back — should match what was saved
61
+ const loaded = loadConfig(configPath);
62
+ expect(loaded.provider).toBe("openai");
63
+ expect(loaded.model).toBe("gpt-4o");
64
+ expect(loaded.apiKey).toBe("sk-test-key-123");
65
+ expect(loaded.streaming).toBe(false);
66
+ // Fields we didn't override should keep defaults
67
+ expect(loaded.maxTokens).toBe(4096);
68
+ expect(loaded.temperature).toBe(0);
69
+ });
70
+
71
+ // ─── RED Test #3: loadConfig uses env var fallback for API key ───
72
+
73
+ it("falls back to ANTHROPIC_API_KEY env var when apiKey is missing from config", () => {
74
+ // Save a config WITHOUT an apiKey
75
+ const noKeyConfig = {
76
+ ...DEFAULT_CONFIG,
77
+ provider: "anthropic",
78
+ };
79
+ saveConfig(configPath, noKeyConfig);
80
+
81
+ // Set the env var
82
+ const originalEnv = process.env.ANTHROPIC_API_KEY;
83
+ process.env.ANTHROPIC_API_KEY = "sk-ant-from-env";
84
+
85
+ try {
86
+ const config = loadConfig(configPath);
87
+ expect(config.apiKey).toBe("sk-ant-from-env");
88
+ } finally {
89
+ // Restore env
90
+ if (originalEnv === undefined) {
91
+ delete process.env.ANTHROPIC_API_KEY;
92
+ } else {
93
+ process.env.ANTHROPIC_API_KEY = originalEnv;
94
+ }
95
+ }
96
+ });
97
+ });
@@ -0,0 +1,238 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import * as os from "node:os";
5
+ import { BrowserTool } from "../tools/browser.js";
6
+ import { WebSearchTool, bindValyuApiKey } from "../tools/webSearch.js";
7
+ import { SkillLoader } from "../skills/loader.js";
8
+ import {
9
+ SearchSkillsTool,
10
+ LoadSkillTool,
11
+ bindSkillLoader,
12
+ } from "../skills/tools.js";
13
+ import { ToolRouter, ToolTarget } from "../tools/router.js";
14
+
15
+ // ═══════════════════════════════════════════════════════════════════════════════
16
+ // 5.5a: Browser Tool
17
+ // ═══════════════════════════════════════════════════════════════════════════════
18
+
19
+ describe("BrowserTool", () => {
20
+ // ─── Test #70: Builds navigate command ───
21
+
22
+ it("has the correct schema with all supported actions", () => {
23
+ expect(BrowserTool.name).toBe("browser");
24
+ expect(BrowserTool.schema.properties.action.enum).toContain("navigate");
25
+ expect(BrowserTool.schema.properties.action.enum).toContain("snapshot");
26
+ expect(BrowserTool.schema.properties.action.enum).toContain("click");
27
+ expect(BrowserTool.schema.properties.action.enum).toContain("type");
28
+ expect(BrowserTool.schema.properties.action.enum).toContain("screenshot");
29
+ expect(BrowserTool.schema.properties.action.enum).toContain("scroll");
30
+ });
31
+
32
+ // ─── Test #71: Rejects without sandbox ───
33
+
34
+ it("throws when sandbox is not active", async () => {
35
+ const result = await BrowserTool.execute({ action: "navigate", url: "https://example.com" });
36
+ expect(result.isError).toBe(true);
37
+ expect(result.content).toMatch(/sandbox/i);
38
+ });
39
+ });
40
+
41
+ // ═══════════════════════════════════════════════════════════════════════════════
42
+ // 5.5b: Web Search Tool
43
+ // ═══════════════════════════════════════════════════════════════════════════════
44
+
45
+ describe("WebSearchTool", () => {
46
+ // ─── Test #72: Returns error if no API key ───
47
+
48
+ it("returns error when API key is not configured", async () => {
49
+ bindValyuApiKey(undefined);
50
+ const result = await WebSearchTool.execute({ query: "test" });
51
+
52
+ expect(result.content).toMatch(/api key not configured/i);
53
+ });
54
+
55
+ // ─── Test #73: Schema includes all sources ───
56
+
57
+ it("schema includes all search sources", () => {
58
+ const sources = WebSearchTool.schema.properties.source.enum;
59
+
60
+ expect(sources).toContain("web");
61
+ expect(sources).toContain("papers");
62
+ expect(sources).toContain("finance");
63
+ expect(sources).toContain("patents");
64
+ expect(sources).toContain("sec");
65
+ expect(sources).toContain("companies");
66
+ });
67
+ });
68
+
69
+ // ═══════════════════════════════════════════════════════════════════════════════
70
+ // 5.5c: Skills System
71
+ // ═══════════════════════════════════════════════════════════════════════════════
72
+
73
+ describe("SkillLoader", () => {
74
+ let tmpDir: string;
75
+
76
+ beforeEach(() => {
77
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "joone-skills-test-"));
78
+ });
79
+
80
+ afterEach(() => {
81
+ fs.rmSync(tmpDir, { recursive: true, force: true });
82
+ });
83
+
84
+ const createSkill = (
85
+ dir: string,
86
+ name: string,
87
+ content: string
88
+ ): void => {
89
+ const skillDir = path.join(dir, name);
90
+ fs.mkdirSync(skillDir, { recursive: true });
91
+ fs.writeFileSync(path.join(skillDir, "SKILL.md"), content);
92
+ };
93
+
94
+ // ─── Test #74: Discovers skills from project root ───
95
+
96
+ it("discovers skills from a directory", () => {
97
+ const skillsDir = path.join(tmpDir, "skills");
98
+ createSkill(
99
+ skillsDir,
100
+ "deploy",
101
+ "---\nname: deploy\ndescription: Deploy to Vercel\n---\n## Steps\n1. Run vercel deploy"
102
+ );
103
+
104
+ const loader = new SkillLoader(tmpDir);
105
+ const skills = loader.discoverSkills();
106
+
107
+ expect(skills.length).toBeGreaterThanOrEqual(1);
108
+ const deploy = skills.find((s) => s.name === "deploy");
109
+ expect(deploy).toBeDefined();
110
+ expect(deploy!.description).toBe("Deploy to Vercel");
111
+ expect(deploy!.source).toBe("project");
112
+ });
113
+
114
+ // ─── Test #75: Parses YAML frontmatter ───
115
+
116
+ it("parses YAML frontmatter correctly", () => {
117
+ const loader = new SkillLoader(tmpDir);
118
+ const result = loader.parseFrontmatter(
119
+ "---\nname: test-skill\ndescription: A test skill\n---\n# Instructions"
120
+ );
121
+
122
+ expect(result.name).toBe("test-skill");
123
+ expect(result.description).toBe("A test skill");
124
+ });
125
+
126
+ // ─── Test #76: Project skills override user skills with same name ───
127
+
128
+ it("project skills override user-level skills with the same name", () => {
129
+ // Create user-level skill in the mocked home directory (tmpDir)
130
+ const userSkillsDir = path.join(tmpDir, ".joone", "skills");
131
+ createSkill(
132
+ userSkillsDir,
133
+ "deploy",
134
+ "---\nname: deploy\ndescription: User deploy\n---\n"
135
+ );
136
+
137
+ // Create project-level skill
138
+ const projectSkillsDir = path.join(tmpDir, "skills");
139
+ createSkill(
140
+ projectSkillsDir,
141
+ "deploy",
142
+ "---\nname: deploy\ndescription: Project deploy\n---\n"
143
+ );
144
+
145
+ // Pass tmpDir as both projectRoot and userHome
146
+ const loader = new SkillLoader(tmpDir, tmpDir);
147
+ const skills = loader.discoverSkills();
148
+
149
+ const deploy = skills.find((s) => s.name === "deploy");
150
+ expect(deploy).toBeDefined();
151
+ expect(deploy!.source).toBe("project");
152
+ expect(deploy!.description).toBe("Project deploy");
153
+
154
+ // Ensure we only discovered one deploy skill
155
+ const deployCount = skills.filter((s) => s.name === "deploy").length;
156
+ expect(deployCount).toBe(1);
157
+ });
158
+
159
+ // ─── Test #77: loadSkill reads full content ───
160
+
161
+ it("loads the full content of a skill", () => {
162
+ const skillsDir = path.join(tmpDir, "skills");
163
+ const content =
164
+ "---\nname: tdd\ndescription: Test-driven development\n---\n## Red-Green-Refactor\n1. Write failing test";
165
+ createSkill(skillsDir, "tdd", content);
166
+
167
+ const loader = new SkillLoader(tmpDir);
168
+ const loaded = loader.loadSkill("tdd");
169
+
170
+ expect(loaded).toBe(content);
171
+ });
172
+ });
173
+
174
+ describe("Skills Tools", () => {
175
+ let tmpDir: string;
176
+
177
+ beforeEach(() => {
178
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "joone-skills-tools-test-"));
179
+
180
+ const skillsDir = path.join(tmpDir, "skills");
181
+ const skillDir = path.join(skillsDir, "deploy");
182
+ fs.mkdirSync(skillDir, { recursive: true });
183
+ fs.writeFileSync(
184
+ path.join(skillDir, "SKILL.md"),
185
+ "---\nname: deploy\ndescription: Deploy to production\n---\n## Steps"
186
+ );
187
+
188
+ bindSkillLoader(new SkillLoader(tmpDir));
189
+ });
190
+
191
+ afterEach(() => {
192
+ fs.rmSync(tmpDir, { recursive: true, force: true });
193
+ });
194
+
195
+ // ─── Test #78: search_skills returns matching skills ───
196
+
197
+ it("search_skills finds matching skills", async () => {
198
+ const result = await SearchSkillsTool.execute({ query: "deploy" });
199
+
200
+ expect(result.content).toContain("deploy");
201
+ expect(result.content).toContain("production");
202
+ });
203
+
204
+ // ─── Test #79: load_skill reads full content ───
205
+
206
+ it("load_skill returns the full SKILL.md content", async () => {
207
+ const result = await LoadSkillTool.execute({ name: "deploy" });
208
+
209
+ expect(result.content).toContain("## Steps");
210
+ });
211
+ });
212
+
213
+ // ═══════════════════════════════════════════════════════════════════════════════
214
+ // Routing
215
+ // ═══════════════════════════════════════════════════════════════════════════════
216
+
217
+ describe("ToolRouter (M5.5 additions)", () => {
218
+ const router = new ToolRouter();
219
+
220
+ // ─── Test #80: Browser routes to sandbox ───
221
+
222
+ it("routes browser to sandbox", () => {
223
+ expect(router.getTarget("browser")).toBe(ToolTarget.SANDBOX);
224
+ });
225
+
226
+ // ─── Test #81: web_search routes to host ───
227
+
228
+ it("routes web_search to host", () => {
229
+ expect(router.getTarget("web_search")).toBe(ToolTarget.HOST);
230
+ });
231
+
232
+ // ─── Test #82: skills tools route to host ───
233
+
234
+ it("routes search_skills and load_skill to host", () => {
235
+ expect(router.getTarget("search_skills")).toBe(ToolTarget.HOST);
236
+ expect(router.getTarget("load_skill")).toBe(ToolTarget.HOST);
237
+ });
238
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { MiddlewarePipeline } from "../middleware/pipeline.js";
3
+ import { ToolCallContext, ToolMiddleware } from "../middleware/types.js";
4
+ import { LoopDetectionMiddleware } from "../middleware/loopDetection.js";
5
+ import { CommandSanitizerMiddleware } from "../middleware/commandSanitizer.js";
6
+ import { PreCompletionMiddleware } from "../middleware/preCompletion.js";
7
+
8
+ // ═══════════════════════════════════════════════════════════════════════════════
9
+ // Pipeline Core
10
+ // ═══════════════════════════════════════════════════════════════════════════════
11
+
12
+ describe("MiddlewarePipeline", () => {
13
+ const makeCtx = (overrides?: Partial<ToolCallContext>): ToolCallContext => ({
14
+ toolName: "bash",
15
+ args: { command: "echo hello" },
16
+ callId: "call-1",
17
+ ...overrides,
18
+ });
19
+
20
+ // ─── Test #44: Runs before/after hooks in order ───
21
+
22
+ it("runs before hooks in registration order and after hooks in reverse", async () => {
23
+ const order: string[] = [];
24
+
25
+ const pipeline = new MiddlewarePipeline();
26
+ pipeline.use({
27
+ name: "A",
28
+ before: (ctx) => { order.push("A:before"); return ctx; },
29
+ after: (_ctx, r) => { order.push("A:after"); return r; },
30
+ });
31
+ pipeline.use({
32
+ name: "B",
33
+ before: (ctx) => { order.push("B:before"); return ctx; },
34
+ after: (_ctx, r) => { order.push("B:after"); return r; },
35
+ });
36
+
37
+ const executeFn = vi.fn(async () => ({ content: "result" }));
38
+ await pipeline.run(makeCtx(), executeFn);
39
+
40
+ expect(order).toEqual(["A:before", "B:before", "B:after", "A:after"]);
41
+ expect(executeFn).toHaveBeenCalledOnce();
42
+ });
43
+
44
+ // ─── Test #45: Short-circuits when before returns string ───
45
+
46
+ it("short-circuits and does NOT execute the tool when before returns a string", async () => {
47
+ const pipeline = new MiddlewarePipeline();
48
+ pipeline.use({
49
+ name: "Blocker",
50
+ before: () => "⚠ Blocked!",
51
+ });
52
+
53
+ const executeFn = vi.fn(async () => ({ content: "should not reach this" }));
54
+ const result = await pipeline.run(makeCtx(), executeFn);
55
+
56
+ expect(result).toBe("⚠ Blocked!");
57
+ expect(executeFn).not.toHaveBeenCalled();
58
+ });
59
+
60
+ // ─── Test #46: After hooks can transform the result ───
61
+
62
+ it("after hooks can transform the tool result", async () => {
63
+ const pipeline = new MiddlewarePipeline();
64
+ pipeline.use({
65
+ name: "Uppercaser",
66
+ after: (_ctx, result) => { result.content = result.content.toUpperCase(); return result; },
67
+ });
68
+
69
+ const result = await pipeline.run(makeCtx(), async () => ({ content: "hello" }));
70
+
71
+ expect(result).toBe("HELLO");
72
+ });
73
+ });
74
+
75
+ // ═══════════════════════════════════════════════════════════════════════════════
76
+ // LoopDetectionMiddleware
77
+ // ═══════════════════════════════════════════════════════════════════════════════
78
+
79
+ describe("LoopDetectionMiddleware", () => {
80
+ const makeCtx = (cmd = "echo hello"): ToolCallContext => ({
81
+ toolName: "bash",
82
+ args: { command: cmd },
83
+ callId: "call-x",
84
+ });
85
+
86
+ // ─── Test #47: Allows first 2 identical calls ───
87
+
88
+ it("allows calls below the threshold", () => {
89
+ const mw = new LoopDetectionMiddleware(3);
90
+
91
+ expect(mw.before(makeCtx())).toEqual(makeCtx());
92
+ expect(mw.before(makeCtx())).toEqual(makeCtx());
93
+ });
94
+
95
+ // ─── Test #48: Blocks on 3rd identical call ───
96
+
97
+ it("blocks on the Nth identical consecutive call", () => {
98
+ const mw = new LoopDetectionMiddleware(3);
99
+
100
+ mw.before(makeCtx());
101
+ mw.before(makeCtx());
102
+ const result = mw.before(makeCtx());
103
+
104
+ expect(typeof result).toBe("string");
105
+ expect(result as string).toMatch(/loop detected/i);
106
+ });
107
+
108
+ // ─── Test #49: Resets when args change ───
109
+
110
+ it("resets the count when a different call is made", () => {
111
+ const mw = new LoopDetectionMiddleware(3);
112
+
113
+ mw.before(makeCtx("echo a"));
114
+ mw.before(makeCtx("echo a"));
115
+ // Different call breaks the streak
116
+ mw.before(makeCtx("echo b"));
117
+ // Back to "echo a" — only 1 in a row now
118
+ const result = mw.before(makeCtx("echo a"));
119
+
120
+ expect(typeof result).not.toBe("string");
121
+ });
122
+ });
123
+
124
+ // ═══════════════════════════════════════════════════════════════════════════════
125
+ // CommandSanitizerMiddleware
126
+ // ═══════════════════════════════════════════════════════════════════════════════
127
+
128
+ describe("CommandSanitizerMiddleware", () => {
129
+ const mw = new CommandSanitizerMiddleware();
130
+
131
+ const makeCtx = (cmd: string): ToolCallContext => ({
132
+ toolName: "bash",
133
+ args: { command: cmd },
134
+ callId: "call-x",
135
+ });
136
+
137
+ // ─── Test #50: Blocks rm -rf / ───
138
+
139
+ it("blocks rm -rf /", () => {
140
+ const result = mw.before(makeCtx("rm -rf /"));
141
+ expect(typeof result).toBe("string");
142
+ expect(result as string).toMatch(/blocked/i);
143
+ });
144
+
145
+ // ─── Test #51: Blocks interactive commands ───
146
+
147
+ it("blocks interactive commands like vim", () => {
148
+ const result = mw.before(makeCtx("vim src/index.ts"));
149
+ expect(typeof result).toBe("string");
150
+ expect(result as string).toMatch(/interactive/i);
151
+ });
152
+
153
+ // ─── Test #52: Allows safe commands ───
154
+
155
+ it("allows safe commands through", () => {
156
+ const result = mw.before(makeCtx("npm test"));
157
+ expect(result).toEqual(makeCtx("npm test"));
158
+ });
159
+
160
+ // ─── Test #53: Ignores non-bash tools ───
161
+
162
+ it("ignores non-bash tool calls entirely", () => {
163
+ const ctx: ToolCallContext = {
164
+ toolName: "read_file",
165
+ args: { path: "/etc/passwd" },
166
+ callId: "call-x",
167
+ };
168
+ expect(mw.before(ctx)).toEqual(ctx);
169
+ });
170
+ });
171
+
172
+ // ═══════════════════════════════════════════════════════════════════════════════
173
+ // PreCompletionMiddleware
174
+ // ═══════════════════════════════════════════════════════════════════════════════
175
+
176
+ describe("PreCompletionMiddleware", () => {
177
+ // ─── Test #54: Blocks completion without tests ───
178
+
179
+ it("blocks task_complete when no tests have been run", () => {
180
+ const mw = new PreCompletionMiddleware();
181
+
182
+ const ctx: ToolCallContext = {
183
+ toolName: "task_complete",
184
+ args: {},
185
+ callId: "call-x",
186
+ };
187
+ const result = mw.before(ctx);
188
+
189
+ expect(typeof result).toBe("string");
190
+ expect(result as string).toMatch(/must run tests/i);
191
+ });
192
+
193
+ // ─── Test #55: Allows completion after tests ───
194
+
195
+ it("allows task_complete after a test command has been run", () => {
196
+ const mw = new PreCompletionMiddleware();
197
+
198
+ // Simulate running tests
199
+ const testCtx: ToolCallContext = {
200
+ toolName: "bash",
201
+ args: { command: "npm test" },
202
+ callId: "call-1",
203
+ };
204
+ mw.before(testCtx);
205
+ mw.after(testCtx, { content: "tests passed", metadata: { exitCode: 0 }, isError: false });
206
+
207
+ expect(mw.hasPassedTests()).toBe(true);
208
+
209
+ // Now try completion
210
+ const completeCtx: ToolCallContext = {
211
+ toolName: "task_complete",
212
+ args: {},
213
+ callId: "call-2",
214
+ };
215
+ const result = mw.before(completeCtx);
216
+
217
+ expect(result).toEqual(completeCtx);
218
+ });
219
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { JooneConfig, DEFAULT_CONFIG } from "../cli/config.js";
3
+ import { createModel } from "../cli/modelFactory.js";
4
+
5
+ describe("Model Factory", () => {
6
+ // ─── RED Test #4: createModel returns a ChatAnthropic for "anthropic" ───
7
+
8
+ it("creates an Anthropic model when provider is 'anthropic'", async () => {
9
+ const config: JooneConfig = {
10
+ ...DEFAULT_CONFIG,
11
+ provider: "anthropic",
12
+ model: "claude-sonnet-4-20250514",
13
+ apiKey: "sk-ant-test-key",
14
+ };
15
+
16
+ const model = await createModel(config);
17
+
18
+ // The model should have the correct type identifier
19
+ expect(model).toBeDefined();
20
+ expect(model.constructor.name).toContain("ChatAnthropic");
21
+ }, 15000);
22
+
23
+ // ─── RED Test #5: createModel returns a ChatOpenAI for "openai" ───
24
+
25
+ it("creates an OpenAI model when provider is 'openai'", async () => {
26
+ const config: JooneConfig = {
27
+ ...DEFAULT_CONFIG,
28
+ provider: "openai",
29
+ model: "gpt-4o",
30
+ apiKey: "sk-openai-test-key",
31
+ };
32
+
33
+ const model = await createModel(config);
34
+
35
+ expect(model).toBeDefined();
36
+ expect(model.constructor.name).toContain("ChatOpenAI");
37
+ }, 15000);
38
+
39
+ // ─── RED Test #6: createModel throws if API key is missing ───
40
+
41
+ it("throws a descriptive error when API key is missing for a cloud provider", async () => {
42
+ const config: JooneConfig = {
43
+ ...DEFAULT_CONFIG,
44
+ provider: "anthropic",
45
+ model: "claude-sonnet-4-20250514",
46
+ apiKey: undefined,
47
+ };
48
+
49
+ await expect(createModel(config)).rejects.toThrow(/API key/i);
50
+ });
51
+
52
+ // ─── RED Test #7: createModel throws with install command for missing package ───
53
+
54
+ it("throws an error with install instructions for unsupported/missing provider", async () => {
55
+ const config: JooneConfig = {
56
+ ...DEFAULT_CONFIG,
57
+ provider: "unknown-provider",
58
+ apiKey: "some-key",
59
+ };
60
+
61
+ await expect(createModel(config)).rejects.toThrow(/unsupported provider/i);
62
+ });
63
+ });