@akanjs/devkit 3.0.0-beta.9 → 3.0.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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -0,0 +1,677 @@
1
+ import { describe, expect, spyOn, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { codeAgentPresets } from "akanjs/common";
4
+ import { makeCliTempWorkspace, tempRoots, writeText } from "../../testHelpers";
5
+ import { CodeAgent, type CodeAgentOptions } from "../agent/CodeAgent";
6
+ import { SubagentPool } from "../agent/SubagentPool";
7
+ import { DevLogFeedback } from "../feedback/DevLogFeedback";
8
+ import { PreviewView } from "../feedback/PreviewView";
9
+ import { TurnFeedback, type TurnFeedbackSource } from "../feedback/TurnFeedback";
10
+ import { AkanCodePlugins } from "./AkanCodePlugins";
11
+ import { AkanEditScope } from "./AkanEditScope";
12
+ import { AkanEnvKeys } from "./AkanEnvKeys";
13
+ import { AkanVerifier } from "./AkanVerifier";
14
+ import { McpClient } from "./McpClient";
15
+ import { McpToolPack } from "./McpToolPack";
16
+ import { SessionToolPack } from "./SessionToolPack";
17
+ import { WebToolPack } from "./WebToolPack";
18
+
19
+ const track = tempRoots();
20
+ const tempRoot = async () => track(await makeCliTempWorkspace()).root;
21
+
22
+ type ToolExecute = (
23
+ id: string,
24
+ params: never,
25
+ signal?: AbortSignal,
26
+ ) => Promise<{ content: { text: string }[]; isError?: boolean }>;
27
+
28
+ const toolsOf = (extension: unknown) => {
29
+ const tools = new Map<string, ToolExecute>();
30
+ (extension as { factory: (pi: unknown) => void }).factory({
31
+ registerTool: (tool: { name: string; execute: ToolExecute }) => tools.set(tool.name, tool.execute),
32
+ });
33
+ return tools;
34
+ };
35
+
36
+ const source = (key: string, findings: (string | undefined)[]): TurnFeedbackSource => {
37
+ let call = 0;
38
+ return {
39
+ key,
40
+ begin: () => {},
41
+ observe: () => {
42
+ const finding = findings[Math.min(call, findings.length - 1)];
43
+ call += 1;
44
+ return finding;
45
+ },
46
+ };
47
+ };
48
+
49
+ describe("TurnFeedback", () => {
50
+ test("reports a finding as one message the agent can act on", async () => {
51
+ const feedback = new TurnFeedback([source("a", ["dev server died"])], { budget: 2 });
52
+ const message = await feedback.collect();
53
+ expect(message).toContain("dev server died");
54
+ expect(message).toContain("Fix them, then stop.");
55
+ });
56
+
57
+ test("says nothing when every source is quiet", async () => {
58
+ const feedback = new TurnFeedback([source("a", [undefined])], { budget: 2 });
59
+ expect(await feedback.collect()).toBeUndefined();
60
+ });
61
+
62
+ test("stops after the budget so one unfixable error cannot loop forever", async () => {
63
+ const notices: string[] = [];
64
+ const feedback = new TurnFeedback([source("a", ["still broken"])], { budget: 2, onNotice: (m) => notices.push(m) });
65
+ expect(await feedback.collect()).toContain("still broken");
66
+ expect(await feedback.collect()).toContain("still broken");
67
+ expect(await feedback.collect()).toBeUndefined();
68
+ expect(notices[0]).toContain("a reopened the turn");
69
+ expect(notices.at(-1)).toContain("still failing after 2 attempts");
70
+ });
71
+
72
+ test("a source that goes quiet gets its budget back", async () => {
73
+ const feedback = new TurnFeedback([source("a", ["broken", undefined, "broken again"])], { budget: 1 });
74
+ expect(await feedback.collect()).toContain("broken");
75
+ expect(await feedback.collect()).toBeUndefined();
76
+ expect(await feedback.collect()).toContain("broken again");
77
+ });
78
+
79
+ test("budgets are per source, so one stuck source does not silence another", async () => {
80
+ const feedback = new TurnFeedback([source("a", ["stuck"]), source("b", [undefined, "new"])], { budget: 1 });
81
+ expect(await feedback.collect()).toContain("stuck");
82
+ expect(await feedback.collect()).toContain("new");
83
+ });
84
+
85
+ test("a source that throws is skipped rather than taking the turn down", async () => {
86
+ const angry: TurnFeedbackSource = {
87
+ key: "angry",
88
+ begin: () => {},
89
+ observe: () => {
90
+ throw new Error("no dev server");
91
+ },
92
+ };
93
+ const feedback = new TurnFeedback([angry, source("b", ["real finding"])], { budget: 2 });
94
+ expect(await feedback.collect()).toContain("real finding");
95
+ });
96
+
97
+ test("attaches images only when a source supplies them", async () => {
98
+ const withImage: TurnFeedbackSource = {
99
+ key: "preview",
100
+ begin: () => {},
101
+ observe: () => ({ text: "page is blank", images: [{ data: "AAA", mimeType: "image/png" }] }),
102
+ };
103
+ const message = await new TurnFeedback([withImage], { budget: 2 }).collect();
104
+ expect(Array.isArray(message)).toBe(true);
105
+ expect(message).toEqual([
106
+ { type: "text", text: expect.stringContaining("page is blank") },
107
+ { type: "image", data: "AAA", mimeType: "image/png" },
108
+ ]);
109
+ });
110
+ });
111
+
112
+ describe("AkanVerifier.summarize", () => {
113
+ const scope = { paths: ["apps/a/x.ts"], apps: ["a"], libs: [], needsSync: false, touchesTsx: false };
114
+
115
+ test("names the failing step and keeps only its tail", () => {
116
+ const output = [...Array(100).keys()].map((n) => `line ${n}`).join("\n");
117
+ const text = AkanVerifier.summarize(
118
+ {
119
+ ok: false,
120
+ scope,
121
+ steps: [
122
+ { command: "akan lint a", ok: true, exitCode: 0, output: "" },
123
+ { command: "akan typecheck a", ok: false, exitCode: 1, output },
124
+ ],
125
+ },
126
+ { tailLines: 5 },
127
+ );
128
+ expect(text).toContain("Verification failed: akan typecheck a");
129
+ expect(text).toContain("Passed: akan lint a");
130
+ expect(text).toContain("line 99");
131
+ expect(text).not.toContain("line 10\n");
132
+ });
133
+
134
+ test("says so plainly when everything passed", () => {
135
+ const text = AkanVerifier.summarize({
136
+ ok: true,
137
+ scope,
138
+ steps: [{ command: "akan lint a", ok: true, exitCode: 0, output: "" }],
139
+ });
140
+ expect(text).toContain("Verification passed");
141
+ });
142
+
143
+ test("an untouched workspace is not a failure", () => {
144
+ expect(AkanVerifier.summarize({ ok: true, scope, steps: [] })).toContain("nothing to verify");
145
+ });
146
+ });
147
+
148
+ describe("AkanEditScope", () => {
149
+ test("derives apps, libs, sync need and tsx from a git status diff", async () => {
150
+ const root = await tempRoot();
151
+ await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
152
+ const baseline = await AkanEditScope.baseline(root);
153
+ await writeText(path.join(root, "apps", "demo", "lib", "post", "post.service.ts"), "export {};\n");
154
+ await writeText(path.join(root, "libs", "shared", "ui", "Card.tsx"), "export const Card = () => null;\n");
155
+ const scope = await AkanEditScope.since(root, baseline);
156
+ expect(scope.apps).toEqual(["demo"]);
157
+ expect(scope.libs).toEqual(["shared"]);
158
+ expect(scope.needsSync).toBe(true);
159
+ expect(scope.touchesTsx).toBe(true);
160
+ });
161
+
162
+ test("an unchanged tree implicates no target", async () => {
163
+ const root = await tempRoot();
164
+ await Bun.spawn(["git", "init", "-q"], { cwd: root, stdout: "ignore", stderr: "ignore" }).exited;
165
+ const baseline = await AkanEditScope.baseline(root);
166
+ const scope = await AkanEditScope.since(root, baseline);
167
+ expect(scope).toEqual({ paths: [], apps: [], libs: [], needsSync: false, touchesTsx: false });
168
+ });
169
+ });
170
+
171
+ describe("DevLogFeedback", () => {
172
+ const logPath = (root: string, app: string) => path.join(root, "local", "apps", app, "runtime", "dev.log");
173
+
174
+ const armed = async (root: string, feedback: DevLogFeedback) => {
175
+ Bun.spawnSync(["git", "init", "-q"], { cwd: root });
176
+ await feedback.begin();
177
+ await writeText(path.join(root, "apps", "demo", "touched.ts"), "export const a = 1;\n");
178
+ };
179
+
180
+ test("reports only the errors written after the watermark", async () => {
181
+ const root = await tempRoot();
182
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n");
183
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
184
+ await armed(root, feedback);
185
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n[AkanApp] ERROR Port 8282 is already in use\n");
186
+ const finding = await feedback.observe();
187
+ expect(finding).toContain("Port 8282 is already in use");
188
+ expect(finding).not.toContain("boot ok");
189
+ });
190
+
191
+ test("stays quiet when the new lines are only debug noise", async () => {
192
+ const root = await tempRoot();
193
+ await writeText(logPath(root, "demo"), "start\n");
194
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
195
+ await armed(root, feedback);
196
+ await writeText(logPath(root, "demo"), "start\n[McpRouter] DEBUG MCP catalogue: tools=3\n");
197
+ expect(await feedback.observe()).toBeUndefined();
198
+ });
199
+
200
+ test("no dev server means nothing to say, not an error", async () => {
201
+ const root = await tempRoot();
202
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
203
+ await armed(root, feedback);
204
+ expect(await feedback.observe()).toBeUndefined();
205
+ });
206
+
207
+ test("a turn that changed nothing is not told about a dev server somebody else is driving", async () => {
208
+ const root = await tempRoot();
209
+ Bun.spawnSync(["git", "init", "-q"], { cwd: root });
210
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n");
211
+ const feedback = new DevLogFeedback({ workspaceRoot: root, cwd: root, apps: ["demo"], graceMs: 0 });
212
+ await feedback.begin();
213
+ await writeText(logPath(root, "demo"), "[demo] boot ok\n[AkanApp] ERROR Port 8282 is already in use\n");
214
+ expect(await feedback.observe()).toBeUndefined();
215
+ });
216
+
217
+ test("reads the dev server's allocated URL back out of its own log", async () => {
218
+ const root = await tempRoot();
219
+ await writeText(
220
+ logPath(root, "demo"),
221
+ "[demo] demo ready (pid=1) — http://localhost:3000\n[demo] demo ready (pid=2) — http://localhost:8282\n",
222
+ );
223
+ expect(await DevLogFeedback.previewUrl(root, "demo")).toBe("http://localhost:8282");
224
+ expect(await DevLogFeedback.previewUrl(root, "absent")).toBeUndefined();
225
+ });
226
+ });
227
+
228
+ describe("AkanEnvKeys", () => {
229
+ test("maps <PROVIDER>_API_KEY onto the engine's provider id without persisting it", async () => {
230
+ const root = await tempRoot();
231
+ await writeText(path.join(root, ".env"), '# comment\nDEEPSEEK_API_KEY="sk-test"\nSOMETHING_ELSE=1\n');
232
+ const applied: [string, string][] = [];
233
+ const fake = {
234
+ setRuntimeApiKey: (provider: string, key: string) => {
235
+ applied.push([provider, key]);
236
+ return Promise.resolve();
237
+ },
238
+ };
239
+ const providers = await AkanEnvKeys.apply(fake as never, root);
240
+ expect(providers).toContain("deepseek");
241
+ expect(applied).toContainEqual(["deepseek", "sk-test"]);
242
+ });
243
+ });
244
+
245
+ describe("PreviewView", () => {
246
+ test("reports whether this Bun build can drive a headless page at all", () => {
247
+ expect(typeof PreviewView.available).toBe("boolean");
248
+ });
249
+
250
+ test.skipIf(!PreviewView.available)("finds an empty body and a console error on a real page", async () => {
251
+ const server = Bun.serve({
252
+ port: 0,
253
+ fetch: () =>
254
+ new Response('<!doctype html><html><body></body><script>console.error("boom")</script></html>', {
255
+ headers: { "content-type": "text/html" },
256
+ }),
257
+ });
258
+ try {
259
+ const probe = await PreviewView.probe(`http://localhost:${server.port}/`);
260
+ expect(probe.pageErrors.join(" ")).toContain("rendered empty");
261
+ expect(probe.consoleErrors.join(" ")).toContain("boom");
262
+ } finally {
263
+ server.stop(true);
264
+ }
265
+ });
266
+ });
267
+
268
+ describe("SubagentPool", () => {
269
+ const workspace = { workspaceRoot: "/tmp/akan" } as never;
270
+
271
+ test("is absent when the profile disables sub-agents", () => {
272
+ const profile = codeAgentPresets.local("/tmp/akan");
273
+ const off = { ...profile, tools: { ...profile.tools, subagent: false as const } };
274
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent: off, depth: 0 })).toBeUndefined();
275
+ });
276
+
277
+ test("is absent once the depth limit is reached, so a task cannot recurse forever", () => {
278
+ const parent = codeAgentPresets.local("/tmp/akan");
279
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 0 })).toBeDefined();
280
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 2 })).toBeUndefined();
281
+ });
282
+
283
+ test("review allows one level only", () => {
284
+ const parent = codeAgentPresets.review("/tmp/akan");
285
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 0 })).toBeDefined();
286
+ expect(SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent, depth: 1 })).toBeUndefined();
287
+ });
288
+
289
+ test("an explore child cannot write or run anything, whatever its parent could", () => {
290
+ const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
291
+ expect(child.tools.builtin).toEqual(["read", "ls", "grep", "find"]);
292
+ });
293
+
294
+ test("a code child keeps the parent's tools, and never more than them", () => {
295
+ const full = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "code");
296
+ expect(full.tools.builtin).toContain("write");
297
+ const reader = SubagentPool.childOf(codeAgentPresets.review("/tmp/akan"), "code");
298
+ expect(reader.tools.builtin).not.toContain("write");
299
+ expect(reader.tools.builtin).not.toContain("bash");
300
+ });
301
+
302
+ test("a refusal reports no running sub-agent", async () => {
303
+ const parent = codeAgentPresets.local("/tmp/akan");
304
+ const spent = { ...parent, tools: { ...parent.tools, subagent: { maxConcurrent: 0, maxDepth: 2, budget: 0 } } };
305
+ const frames: unknown[][] = [];
306
+ const extension = SubagentPool.extensionFor({
307
+ workspace,
308
+ cwd: "/tmp/akan",
309
+ parent: spent,
310
+ depth: 0,
311
+ onAgents: (agents) => frames.push(agents),
312
+ });
313
+ const tools: { execute: (id: string, params: never) => Promise<{ isError?: boolean }> }[] = [];
314
+ if (extension && "factory" in extension)
315
+ extension.factory({ registerTool: (spec: never) => tools.push(spec) } as never);
316
+ const result = await tools[0]?.execute("c0", { description: "d", prompt: "p" } as never);
317
+ expect(result?.isError).toBe(true);
318
+ expect(frames).toEqual([]);
319
+ });
320
+
321
+ const budgeted = (budget: number, maxConcurrent = 3) => {
322
+ const parent = codeAgentPresets.local("/tmp/akan");
323
+ return {
324
+ ...parent,
325
+ tools: { ...parent.tools, mcp: "off" as const, subagent: { maxConcurrent, maxDepth: 2, budget } },
326
+ };
327
+ };
328
+ const openFakeAgents = (tokens: number[]) => {
329
+ const opened: CodeAgentOptions[] = [];
330
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
331
+ opened.push(options);
332
+ const tokensUsed = tokens[opened.length - 1] ?? 0;
333
+ return {
334
+ on: () => {},
335
+ prompt: async () => {},
336
+ waitForIdle: async () => {},
337
+ dispose: () => {},
338
+ tokensUsed,
339
+ } as never;
340
+ });
341
+ return { opened, spy };
342
+ };
343
+ const runTask = async (extension: unknown, id: string, signal?: AbortSignal) =>
344
+ await toolsOf(extension).get("task")?.(id, { description: "d", prompt: "p" } as never, signal);
345
+ const until = async (done: () => boolean) => {
346
+ for (let tries = 0; tries < 500 && !done(); tries += 1) await Bun.sleep(2);
347
+ };
348
+ const poolOf = (child: CodeAgentOptions) =>
349
+ SubagentPool.extensionFor({
350
+ workspace,
351
+ cwd: "/tmp/akan",
352
+ parent: child.profile,
353
+ depth: child.depth ?? 0,
354
+ ...(child.subagentSpend ? { spend: child.subagentSpend } : {}),
355
+ });
356
+
357
+ test("every pool of one tree draws on one token budget", async () => {
358
+ const { opened, spy } = openFakeAgents([150, 100]);
359
+ try {
360
+ const root = SubagentPool.extensionFor({ workspace, cwd: "/tmp/akan", parent: budgeted(200), depth: 0 });
361
+ expect((await runTask(root, "c1"))?.isError).toBeUndefined();
362
+ const [child] = opened;
363
+ if (!child) throw new Error("no sub-agent was opened");
364
+ const inner = SubagentPool.extensionFor({
365
+ workspace,
366
+ cwd: "/tmp/akan",
367
+ parent: child.profile,
368
+ depth: child.depth ?? 0,
369
+ ...(child.subagentSpend ? { spend: child.subagentSpend } : {}),
370
+ });
371
+ expect((await runTask(inner, "c2"))?.isError).toBeUndefined();
372
+ for (const pool of [root, inner]) {
373
+ const refused = await runTask(pool, "c3");
374
+ expect(refused?.isError).toBe(true);
375
+ expect(refused?.content[0]?.text).toContain("token budget (200) is spent");
376
+ }
377
+ expect(opened).toHaveLength(2);
378
+ } finally {
379
+ spy.mockRestore();
380
+ }
381
+ });
382
+
383
+ test("the plugins a sub-agent builds hand its pool the tree's tally", async () => {
384
+ const { opened, spy } = openFakeAgents([]);
385
+ try {
386
+ const plugins = await AkanCodePlugins.build({
387
+ workspace,
388
+ cwd: "/tmp/akan",
389
+ profile: budgeted(200),
390
+ apps: [],
391
+ canSeeImages: false,
392
+ depth: 1,
393
+ currentSessionId: () => "",
394
+ mailbox: () => undefined,
395
+ subagentSpend: { tokens: 200, running: 0 },
396
+ });
397
+ const pool = plugins.extensions.find((extension) => extension.name === "akan-subagent");
398
+ const refused = await runTask(pool, "c1");
399
+ expect(refused?.isError).toBe(true);
400
+ expect(opened).toHaveLength(0);
401
+ plugins.dispose();
402
+ } finally {
403
+ spy.mockRestore();
404
+ }
405
+ });
406
+
407
+ test("maxConcurrent counts every running sub-agent of the tree, and a full tree refuses a child's task instead of queueing it", async () => {
408
+ const held = Promise.withResolvers<void>();
409
+ const opened: CodeAgentOptions[] = [];
410
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
411
+ opened.push(options);
412
+ return {
413
+ on: () => {},
414
+ prompt: async () => await held.promise,
415
+ waitForIdle: async () => {},
416
+ dispose: () => {},
417
+ tokensUsed: 0,
418
+ } as never;
419
+ });
420
+ try {
421
+ const root = SubagentPool.extensionFor({
422
+ workspace,
423
+ cwd: "/tmp/akan",
424
+ parent: budgeted(1_000_000, 2),
425
+ depth: 0,
426
+ });
427
+ const running = [runTask(root, "c1"), runTask(root, "c2")];
428
+ await until(() => opened.length === 2);
429
+ const [child] = opened;
430
+ if (!child) throw new Error("no sub-agent was opened");
431
+ const refused = await Promise.race([runTask(poolOf(child), "c3"), Bun.sleep(500).then(() => undefined)]);
432
+ expect(refused?.isError).toBe(true);
433
+ expect(refused?.content[0]?.text).toContain("limit 2 at once");
434
+ expect(refused?.content[0]?.text).not.toContain("Wait");
435
+ expect((await runTask(root, "c4"))?.content[0]?.text).toContain("Wait for one of yours");
436
+ expect(opened).toHaveLength(2);
437
+ held.resolve();
438
+ await Promise.all(running);
439
+ expect((await runTask(poolOf(child), "c5"))?.isError).toBeUndefined();
440
+ expect(opened).toHaveLength(3);
441
+ } finally {
442
+ held.resolve();
443
+ spy.mockRestore();
444
+ }
445
+ });
446
+
447
+ test("a sub-agent that fails still spends from the tree's budget, once, and the notice says it failed", async () => {
448
+ const opened: CodeAgentOptions[] = [];
449
+ const spy = spyOn(CodeAgent, "create").mockImplementation(async (options) => {
450
+ opened.push(options);
451
+ return {
452
+ on: () => {},
453
+ prompt: async () => {
454
+ throw new Error("provider went away");
455
+ },
456
+ waitForIdle: async () => {},
457
+ dispose: () => {},
458
+ tokensUsed: 150,
459
+ } as never;
460
+ });
461
+ const notices: string[] = [];
462
+ try {
463
+ const root = SubagentPool.extensionFor({
464
+ workspace,
465
+ cwd: "/tmp/akan",
466
+ parent: budgeted(100),
467
+ depth: 0,
468
+ onNotice: (message) => notices.push(message),
469
+ });
470
+ await expect(runTask(root, "c1")).rejects.toThrow("provider went away");
471
+ expect(notices.at(-1)).toBe("explore sub-agent failed, 150 tokens (150 of 100 spent)");
472
+ expect((await runTask(root, "c2"))?.content[0]?.text).toContain("token budget (100) is spent");
473
+ expect(opened).toHaveLength(1);
474
+ } finally {
475
+ spy.mockRestore();
476
+ }
477
+ });
478
+
479
+ test("a sub-agent its parent stops is counted once, and the notice says it stopped", async () => {
480
+ const prompted = Promise.withResolvers<void>();
481
+ const stopped = Promise.withResolvers<void>();
482
+ const spy = spyOn(CodeAgent, "create").mockImplementation(
483
+ async () =>
484
+ ({
485
+ on: () => {},
486
+ prompt: async () => {
487
+ prompted.resolve();
488
+ await stopped.promise;
489
+ },
490
+ waitForIdle: async () => {},
491
+ abort: async () => stopped.resolve(),
492
+ dispose: () => {},
493
+ tokensUsed: 120,
494
+ }) as never,
495
+ );
496
+ const notices: string[] = [];
497
+ try {
498
+ const root = SubagentPool.extensionFor({
499
+ workspace,
500
+ cwd: "/tmp/akan",
501
+ parent: budgeted(1_000),
502
+ depth: 0,
503
+ onNotice: (message) => notices.push(message),
504
+ });
505
+ const parent = new AbortController();
506
+ const task = runTask(root, "c1", parent.signal);
507
+ await prompted.promise;
508
+ parent.abort();
509
+ await task;
510
+ expect(notices.at(-1)).toBe("explore sub-agent stopped, 120 tokens (120 of 1000 spent)");
511
+ } finally {
512
+ stopped.resolve();
513
+ spy.mockRestore();
514
+ }
515
+ });
516
+
517
+ test("a child keeps no session and cannot ask a human", () => {
518
+ const child = SubagentPool.childOf(codeAgentPresets.local("/tmp/akan"), "explore");
519
+ expect(child.session).toEqual({ store: "memory", crossSession: false });
520
+ expect(child.ui.canPrompt).toBe(false);
521
+ expect(child.context.projectFiles).toBe(false);
522
+ });
523
+ });
524
+
525
+ describe("McpToolPack", () => {
526
+ const serverSource = `
527
+ const reply = (id, result) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", id, result }) + "\\n");
528
+ for await (const line of console) {
529
+ if (!line.trim()) continue;
530
+ const message = JSON.parse(line);
531
+ if (message.method === "initialize") reply(message.id, { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "echo", version: "1" } });
532
+ if (message.method === "tools/list")
533
+ reply(message.id, { tools: [{ name: "echo", description: "Echo the text back.", inputSchema: { type: "object", properties: { text: { type: "string" } }, required: ["text"] } }] });
534
+ if (message.method === "tools/call")
535
+ reply(message.id, { content: [{ type: "text", text: "echo: " + message.params.arguments.text }] });
536
+ }
537
+ `;
538
+
539
+ const withServer = async () => {
540
+ const root = await tempRoot();
541
+ const script = path.join(root, "echo-mcp.ts");
542
+ await writeText(script, serverSource);
543
+ await writeText(
544
+ path.join(root, ".akan", "code", "mcp.json"),
545
+ JSON.stringify({ mcpServers: { echo: { command: process.execPath, args: [script] } } }),
546
+ );
547
+ return root;
548
+ };
549
+
550
+ test("connects a declared stdio server, lists its tools and calls one", async () => {
551
+ const root = await withServer();
552
+ const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.local(root) });
553
+ expect(pack).toBeDefined();
554
+ try {
555
+ expect(pack?.toolNames).toEqual(["mcp__echo__echo"]);
556
+ const client = await new McpClient({
557
+ name: "echo",
558
+ transport: "stdio",
559
+ command: process.execPath,
560
+ args: [path.join(root, "echo-mcp.ts")],
561
+ }).connect();
562
+ try {
563
+ expect(await client.callTool("echo", { text: "hi" })).toEqual({ text: "echo: hi", isError: false });
564
+ } finally {
565
+ client.close();
566
+ }
567
+ } finally {
568
+ pack?.close();
569
+ }
570
+ });
571
+
572
+ test("a server that cannot start costs its own tools and nothing else", async () => {
573
+ const root = await tempRoot();
574
+ await writeText(
575
+ path.join(root, ".akan", "code", "mcp.json"),
576
+ JSON.stringify({ mcpServers: { broken: { command: "/nonexistent/binary" } } }),
577
+ );
578
+ const notices: string[] = [];
579
+ const pack = await McpToolPack.connect({
580
+ workspaceRoot: root,
581
+ profile: codeAgentPresets.local(root),
582
+ onNotice: (message) => notices.push(message),
583
+ });
584
+ expect(pack?.toolNames).toEqual([]);
585
+ expect(notices.join(" ")).toContain("broken");
586
+ expect(pack?.status).toEqual([
587
+ { name: "broken", transport: "stdio", target: "/nonexistent/binary", tools: [], error: expect.any(String) },
588
+ ]);
589
+ });
590
+
591
+ test("declares nothing when the profile turns MCP off", async () => {
592
+ const root = await withServer();
593
+ const pack = await McpToolPack.connect({ workspaceRoot: root, profile: codeAgentPresets.review(root) });
594
+ expect(pack).toBeUndefined();
595
+ });
596
+ });
597
+
598
+ describe("WebToolPack", () => {
599
+ const root = "/tmp/akan-web";
600
+
601
+ test("publishes only what the profile allows and the environment can serve", () => {
602
+ const readOnly = codeAgentPresets.review(root);
603
+ expect(new WebToolPack({ profile: readOnly }).names()).toEqual([]);
604
+ expect(new WebToolPack({ profile: codeAgentPresets.local(root) }).names()).toContain("web_fetch");
605
+ });
606
+
607
+ test("registers web_search only when a provider key is present", () => {
608
+ const profile = codeAgentPresets.local(root);
609
+ const had = process.env.BRAVE_API_KEY;
610
+ try {
611
+ delete process.env.BRAVE_API_KEY;
612
+ delete process.env.TAVILY_API_KEY;
613
+ expect(new WebToolPack({ profile }).names()).not.toContain("web_search");
614
+ process.env.BRAVE_API_KEY = "test";
615
+ expect(new WebToolPack({ profile }).names()).toContain("web_search");
616
+ } finally {
617
+ if (had === undefined) delete process.env.BRAVE_API_KEY;
618
+ else process.env.BRAVE_API_KEY = had;
619
+ }
620
+ });
621
+
622
+ test("reads a page down to text and refuses a host the profile excludes", async () => {
623
+ const server = Bun.serve({
624
+ port: 0,
625
+ fetch: () =>
626
+ new Response(
627
+ "<html><head><style>b{}</style></head><body><h1>Akan</h1><p>Hello &amp; welcome</p></body></html>",
628
+ {
629
+ headers: { "content-type": "text/html" },
630
+ },
631
+ ),
632
+ });
633
+ try {
634
+ const profile = codeAgentPresets.local(root);
635
+ const fetchTool = toolsOf(new WebToolPack({ profile }).extension()).get("web_fetch");
636
+ const ok = await fetchTool?.("1", { url: `http://localhost:${server.port}/` } as never);
637
+ expect(ok?.content[0]?.text).toContain("Akan");
638
+ expect(ok?.content[0]?.text).toContain("Hello & welcome");
639
+ expect(ok?.content[0]?.text).not.toContain("<h1>");
640
+
641
+ const narrowed = new WebToolPack({ profile: { ...profile, network: { allowHosts: ["docs.akanjs.com"] } } });
642
+ const refused = await toolsOf(narrowed.extension()).get("web_fetch")?.("1", {
643
+ url: `http://localhost:${server.port}/`,
644
+ } as never);
645
+ expect(refused?.isError).toBe(true);
646
+ expect(refused?.content[0]?.text).toContain("docs.akanjs.com");
647
+ } finally {
648
+ server.stop(true);
649
+ }
650
+ });
651
+ });
652
+
653
+ describe("SessionToolPack", () => {
654
+ const options = (root: string, profile = codeAgentPresets.local(root)) => ({
655
+ workspaceRoot: root,
656
+ cwd: root,
657
+ profile,
658
+ currentSessionId: () => "current",
659
+ });
660
+
661
+ test("is absent when the profile forbids reaching other sessions", () => {
662
+ const pack = new SessionToolPack(options("/tmp/akan", codeAgentPresets.review("/tmp/akan")));
663
+ expect(pack.names()).toEqual([]);
664
+ expect(pack.extension()).toBeUndefined();
665
+ });
666
+
667
+ test("publishes both readers when cross-session is on", () => {
668
+ expect(new SessionToolPack(options("/tmp/akan")).names()).toEqual(["session_search", "session_read"]);
669
+ });
670
+
671
+ test("says so plainly when nothing earlier matches", async () => {
672
+ const root = await tempRoot();
673
+ const tools = toolsOf(new SessionToolPack(options(root)).extension());
674
+ const result = await tools.get("session_search")?.("1", { query: "tunnel wire" } as never);
675
+ expect(result?.content[0]?.text).toContain("No earlier session");
676
+ });
677
+ });