@mseep/claudian 2.0.25

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 (687) hide show
  1. package/.env.local.example +2 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/claude-code-review.yml +57 -0
  4. package/.github/workflows/claude.yml +50 -0
  5. package/.github/workflows/duplicate-issues.yml +22 -0
  6. package/.github/workflows/release.yml +73 -0
  7. package/.github/workflows/stale.yml +21 -0
  8. package/.node-version +1 -0
  9. package/AGENTS.md +3 -0
  10. package/CLAUDE.md +80 -0
  11. package/LICENSE +21 -0
  12. package/README.md +190 -0
  13. package/assets/Preview.png +0 -0
  14. package/assets/sponsors/MOMA.png +0 -0
  15. package/bun.lock +1618 -0
  16. package/esbuild.config.mjs +195 -0
  17. package/eslint.config.mjs +143 -0
  18. package/jest.config.js +41 -0
  19. package/main.js +104915 -0
  20. package/manifest.json +10 -0
  21. package/package.json +65 -0
  22. package/scripts/build-css.mjs +119 -0
  23. package/scripts/build.mjs +19 -0
  24. package/scripts/postinstall.mjs +25 -0
  25. package/scripts/rendererSafeUnref.js +205 -0
  26. package/scripts/run-jest.js +19 -0
  27. package/scripts/sync-version.js +16 -0
  28. package/src/app/settings/ClaudianSettingsStorage.ts +435 -0
  29. package/src/app/settings/defaultSettings.ts +54 -0
  30. package/src/app/storage/SharedStorageService.ts +106 -0
  31. package/src/core/CLAUDE.md +84 -0
  32. package/src/core/auxiliary/AuxQueryRunner.ts +11 -0
  33. package/src/core/auxiliary/QueryBackedInlineEditService.ts +73 -0
  34. package/src/core/auxiliary/QueryBackedInstructionRefineService.ts +78 -0
  35. package/src/core/auxiliary/QueryBackedTitleGenerationService.ts +90 -0
  36. package/src/core/bootstrap/SessionStorage.ts +179 -0
  37. package/src/core/bootstrap/StoragePaths.ts +7 -0
  38. package/src/core/bootstrap/storage.ts +20 -0
  39. package/src/core/commands/builtInCommands.ts +141 -0
  40. package/src/core/mcp/McpConfigParser.ts +102 -0
  41. package/src/core/mcp/McpServerManager.ts +119 -0
  42. package/src/core/mcp/McpTester.ts +310 -0
  43. package/src/core/prompt/inlineEdit.ts +252 -0
  44. package/src/core/prompt/instructionRefine.ts +72 -0
  45. package/src/core/prompt/mainAgent.ts +213 -0
  46. package/src/core/prompt/titleGeneration.ts +44 -0
  47. package/src/core/providers/ProviderRegistry.ts +253 -0
  48. package/src/core/providers/ProviderSettingsCoordinator.ts +434 -0
  49. package/src/core/providers/ProviderWorkspaceRegistry.ts +119 -0
  50. package/src/core/providers/commands/ProviderCommandCatalog.ts +21 -0
  51. package/src/core/providers/commands/ProviderCommandEntry.ts +33 -0
  52. package/src/core/providers/commands/hiddenCommands.ts +74 -0
  53. package/src/core/providers/modelRouting.ts +17 -0
  54. package/src/core/providers/modelSelection.ts +72 -0
  55. package/src/core/providers/providerConfig.ts +34 -0
  56. package/src/core/providers/providerEnvironment.ts +364 -0
  57. package/src/core/providers/types.ts +544 -0
  58. package/src/core/runtime/ChatRuntime.ts +66 -0
  59. package/src/core/runtime/QueuedTurn.ts +97 -0
  60. package/src/core/runtime/types.ts +118 -0
  61. package/src/core/security/ApprovalManager.ts +142 -0
  62. package/src/core/storage/HomeFileAdapter.ts +75 -0
  63. package/src/core/storage/VaultFileAdapter.ts +132 -0
  64. package/src/core/tools/todo.ts +65 -0
  65. package/src/core/tools/toolIcons.ts +80 -0
  66. package/src/core/tools/toolInput.ts +119 -0
  67. package/src/core/tools/toolNames.ts +149 -0
  68. package/src/core/tools/toolResultContent.ts +26 -0
  69. package/src/core/types/agent.ts +28 -0
  70. package/src/core/types/chat.ts +177 -0
  71. package/src/core/types/diff.ts +31 -0
  72. package/src/core/types/index.ts +78 -0
  73. package/src/core/types/mcp.ts +97 -0
  74. package/src/core/types/plugins.ts +9 -0
  75. package/src/core/types/provider.ts +1 -0
  76. package/src/core/types/settings.ts +152 -0
  77. package/src/core/types/tools.ts +80 -0
  78. package/src/features/chat/CLAUDE.md +136 -0
  79. package/src/features/chat/ClaudianView.ts +762 -0
  80. package/src/features/chat/constants.ts +114 -0
  81. package/src/features/chat/controllers/BrowserSelectionController.ts +295 -0
  82. package/src/features/chat/controllers/CanvasSelectionController.ts +142 -0
  83. package/src/features/chat/controllers/ConversationController.ts +1103 -0
  84. package/src/features/chat/controllers/InputController.ts +1707 -0
  85. package/src/features/chat/controllers/NavigationController.ts +209 -0
  86. package/src/features/chat/controllers/SelectionController.ts +430 -0
  87. package/src/features/chat/controllers/StreamController.ts +1560 -0
  88. package/src/features/chat/controllers/contextRowVisibility.ts +18 -0
  89. package/src/features/chat/rendering/DiffRenderer.ts +134 -0
  90. package/src/features/chat/rendering/InlineAskUserQuestion.ts +702 -0
  91. package/src/features/chat/rendering/InlineExitPlanMode.ts +263 -0
  92. package/src/features/chat/rendering/InlinePlanApproval.ts +183 -0
  93. package/src/features/chat/rendering/MessageRenderer.ts +907 -0
  94. package/src/features/chat/rendering/SubagentRenderer.ts +678 -0
  95. package/src/features/chat/rendering/ThinkingBlockRenderer.ts +126 -0
  96. package/src/features/chat/rendering/TodoListRenderer.ts +5 -0
  97. package/src/features/chat/rendering/ToolCallRenderer.ts +1161 -0
  98. package/src/features/chat/rendering/WriteEditRenderer.ts +232 -0
  99. package/src/features/chat/rendering/collapsible.ts +101 -0
  100. package/src/features/chat/rendering/subagentLifecycleResolution.ts +25 -0
  101. package/src/features/chat/rendering/todoUtils.ts +29 -0
  102. package/src/features/chat/rewind.ts +31 -0
  103. package/src/features/chat/services/BangBashService.ts +56 -0
  104. package/src/features/chat/services/SubagentManager.ts +1107 -0
  105. package/src/features/chat/state/ChatState.ts +436 -0
  106. package/src/features/chat/state/types.ts +138 -0
  107. package/src/features/chat/tabs/Tab.ts +1886 -0
  108. package/src/features/chat/tabs/TabBar.ts +179 -0
  109. package/src/features/chat/tabs/TabManager.ts +1021 -0
  110. package/src/features/chat/tabs/providerResolution.ts +34 -0
  111. package/src/features/chat/tabs/types.ts +287 -0
  112. package/src/features/chat/ui/BangBashModeManager.ts +121 -0
  113. package/src/features/chat/ui/FileContext.ts +385 -0
  114. package/src/features/chat/ui/ImageContext.ts +366 -0
  115. package/src/features/chat/ui/InputToolbar.ts +1244 -0
  116. package/src/features/chat/ui/InstructionModeManager.ts +158 -0
  117. package/src/features/chat/ui/NavigationSidebar.ts +126 -0
  118. package/src/features/chat/ui/StatusPanel.ts +589 -0
  119. package/src/features/chat/ui/file-context/state/FileContextState.ts +83 -0
  120. package/src/features/chat/ui/file-context/view/FileChipsView.ts +70 -0
  121. package/src/features/chat/ui/textareaResize.ts +47 -0
  122. package/src/features/chat/utils/usageInfo.ts +26 -0
  123. package/src/features/inline-edit/ui/InlineEditModal.ts +895 -0
  124. package/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts +55 -0
  125. package/src/features/settings/ClaudianSettings.ts +672 -0
  126. package/src/features/settings/keyboardNavigation.ts +60 -0
  127. package/src/features/settings/ui/EnvSnippetManager.ts +430 -0
  128. package/src/features/settings/ui/EnvironmentSettingsSection.ts +85 -0
  129. package/src/features/settings/ui/McpServerModal.ts +335 -0
  130. package/src/features/settings/ui/McpSettingsManager.ts +400 -0
  131. package/src/features/settings/ui/McpTestModal.ts +346 -0
  132. package/src/i18n/constants.ts +58 -0
  133. package/src/i18n/i18n.ts +140 -0
  134. package/src/i18n/locales/de.json +322 -0
  135. package/src/i18n/locales/en.json +322 -0
  136. package/src/i18n/locales/es.json +322 -0
  137. package/src/i18n/locales/fr.json +322 -0
  138. package/src/i18n/locales/ja.json +322 -0
  139. package/src/i18n/locales/ko.json +322 -0
  140. package/src/i18n/locales/pt.json +322 -0
  141. package/src/i18n/locales/ru.json +322 -0
  142. package/src/i18n/locales/zh-CN.json +322 -0
  143. package/src/i18n/locales/zh-TW.json +322 -0
  144. package/src/i18n/types.ts +248 -0
  145. package/src/main.ts +772 -0
  146. package/src/providers/acp/AcpClientConnection.ts +361 -0
  147. package/src/providers/acp/AcpJsonRpcTransport.ts +427 -0
  148. package/src/providers/acp/AcpSessionConfig.ts +139 -0
  149. package/src/providers/acp/AcpSessionUpdateNormalizer.ts +371 -0
  150. package/src/providers/acp/AcpSubprocess.ts +155 -0
  151. package/src/providers/acp/AcpToolStreamAdapter.ts +132 -0
  152. package/src/providers/acp/buildAcpUsageInfo.ts +41 -0
  153. package/src/providers/acp/index.ts +9 -0
  154. package/src/providers/acp/methodNames.ts +50 -0
  155. package/src/providers/acp/types.ts +566 -0
  156. package/src/providers/claude/CLAUDE.md +78 -0
  157. package/src/providers/claude/agents/AgentManager.ts +225 -0
  158. package/src/providers/claude/agents/AgentStorage.ts +101 -0
  159. package/src/providers/claude/app/ClaudeWorkspaceServices.ts +90 -0
  160. package/src/providers/claude/auxiliary/ClaudeInlineEditService.ts +122 -0
  161. package/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts +90 -0
  162. package/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts +129 -0
  163. package/src/providers/claude/auxiliary/extractAssistantText.ts +28 -0
  164. package/src/providers/claude/capabilities.ts +17 -0
  165. package/src/providers/claude/cli/findClaudeCLIPath.ts +261 -0
  166. package/src/providers/claude/commands/ClaudeCommandCatalog.ts +149 -0
  167. package/src/providers/claude/commands/probeRuntimeCommands.ts +86 -0
  168. package/src/providers/claude/env/ClaudeSettingsReconciler.ts +90 -0
  169. package/src/providers/claude/env/claudeModelEnv.ts +86 -0
  170. package/src/providers/claude/history/ClaudeConversationHistoryService.ts +446 -0
  171. package/src/providers/claude/history/ClaudeHistoryStore.ts +170 -0
  172. package/src/providers/claude/history/sdkAsyncSubagent.ts +92 -0
  173. package/src/providers/claude/history/sdkBranchFilter.ts +271 -0
  174. package/src/providers/claude/history/sdkHistoryTypes.ts +61 -0
  175. package/src/providers/claude/history/sdkMessageParsing.ts +413 -0
  176. package/src/providers/claude/history/sdkSessionPaths.ts +98 -0
  177. package/src/providers/claude/history/sdkSubagentSidecar.ts +261 -0
  178. package/src/providers/claude/hooks/SubagentHooks.ts +31 -0
  179. package/src/providers/claude/modelLabels.ts +77 -0
  180. package/src/providers/claude/modelOptions.ts +113 -0
  181. package/src/providers/claude/modelSelection.ts +17 -0
  182. package/src/providers/claude/plugins/PluginManager.ts +194 -0
  183. package/src/providers/claude/prompt/ClaudeTurnEncoder.ts +46 -0
  184. package/src/providers/claude/registration.ts +39 -0
  185. package/src/providers/claude/runtime/ClaudeApprovalHandler.ts +153 -0
  186. package/src/providers/claude/runtime/ClaudeChatRuntime.ts +1812 -0
  187. package/src/providers/claude/runtime/ClaudeCliResolver.ts +94 -0
  188. package/src/providers/claude/runtime/ClaudeDynamicUpdates.ts +164 -0
  189. package/src/providers/claude/runtime/ClaudeMessageChannel.ts +209 -0
  190. package/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts +315 -0
  191. package/src/providers/claude/runtime/ClaudeRewindService.ts +220 -0
  192. package/src/providers/claude/runtime/ClaudeSessionManager.ts +92 -0
  193. package/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts +172 -0
  194. package/src/providers/claude/runtime/ClaudeUserMessageFactory.ts +83 -0
  195. package/src/providers/claude/runtime/claudeColdStartQuery.ts +152 -0
  196. package/src/providers/claude/runtime/customSpawn.ts +87 -0
  197. package/src/providers/claude/runtime/types.ts +134 -0
  198. package/src/providers/claude/sdk/messages.ts +17 -0
  199. package/src/providers/claude/sdk/toolResultContent.ts +4 -0
  200. package/src/providers/claude/sdk/typeGuards.ts +14 -0
  201. package/src/providers/claude/sdk/types.ts +15 -0
  202. package/src/providers/claude/security/ClaudePermissionUpdates.ts +44 -0
  203. package/src/providers/claude/settings.ts +138 -0
  204. package/src/providers/claude/storage/AgentVaultStorage.ts +101 -0
  205. package/src/providers/claude/storage/CCSettingsStorage.ts +153 -0
  206. package/src/providers/claude/storage/ClaudianSettingsStorage.ts +6 -0
  207. package/src/providers/claude/storage/McpStorage.ts +139 -0
  208. package/src/providers/claude/storage/SessionStorage.ts +5 -0
  209. package/src/providers/claude/storage/SkillStorage.ts +61 -0
  210. package/src/providers/claude/storage/SlashCommandStorage.ts +96 -0
  211. package/src/providers/claude/storage/StorageService.ts +185 -0
  212. package/src/providers/claude/stream/toolInputStreamState.ts +318 -0
  213. package/src/providers/claude/stream/transformClaudeMessage.ts +586 -0
  214. package/src/providers/claude/types/agent.ts +2 -0
  215. package/src/providers/claude/types/models.ts +168 -0
  216. package/src/providers/claude/types/plugins.ts +14 -0
  217. package/src/providers/claude/types/providerState.ts +16 -0
  218. package/src/providers/claude/types/settings.ts +98 -0
  219. package/src/providers/claude/ui/AgentSettings.ts +389 -0
  220. package/src/providers/claude/ui/ClaudeChatUIConfig.ts +113 -0
  221. package/src/providers/claude/ui/ClaudeSettingsTab.ts +407 -0
  222. package/src/providers/claude/ui/PluginSettingsManager.ts +149 -0
  223. package/src/providers/claude/ui/SlashCommandSettings.ts +527 -0
  224. package/src/providers/codex/CLAUDE.md +64 -0
  225. package/src/providers/codex/agents/CodexAgentMentionProvider.ts +33 -0
  226. package/src/providers/codex/app/CodexWorkspaceServices.ts +76 -0
  227. package/src/providers/codex/auxiliary/CodexInlineEditService.ts +9 -0
  228. package/src/providers/codex/auxiliary/CodexInstructionRefineService.ts +9 -0
  229. package/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts +29 -0
  230. package/src/providers/codex/auxiliary/CodexTitleGenerationService.ts +22 -0
  231. package/src/providers/codex/capabilities.ts +16 -0
  232. package/src/providers/codex/commands/CodexSkillCatalog.ts +180 -0
  233. package/src/providers/codex/env/CodexSettingsReconciler.ts +68 -0
  234. package/src/providers/codex/history/CodexConversationHistoryService.ts +212 -0
  235. package/src/providers/codex/history/CodexHistoryStore.ts +1672 -0
  236. package/src/providers/codex/modelOptions.ts +99 -0
  237. package/src/providers/codex/modelSelection.ts +17 -0
  238. package/src/providers/codex/normalization/codexSubagentNormalization.ts +227 -0
  239. package/src/providers/codex/normalization/codexToolNormalization.ts +390 -0
  240. package/src/providers/codex/prompt/encodeCodexTurn.ts +57 -0
  241. package/src/providers/codex/registration.ts +29 -0
  242. package/src/providers/codex/runtime/CodexAppServerProcess.ts +105 -0
  243. package/src/providers/codex/runtime/CodexAuxQueryRunner.ts +180 -0
  244. package/src/providers/codex/runtime/CodexBinaryLocator.ts +49 -0
  245. package/src/providers/codex/runtime/CodexChatRuntime.ts +1296 -0
  246. package/src/providers/codex/runtime/CodexCliResolver.ts +65 -0
  247. package/src/providers/codex/runtime/CodexExecutionTargetResolver.ts +104 -0
  248. package/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts +85 -0
  249. package/src/providers/codex/runtime/CodexNotificationRouter.ts +1033 -0
  250. package/src/providers/codex/runtime/CodexPathMapper.ts +155 -0
  251. package/src/providers/codex/runtime/CodexRpcTransport.ts +171 -0
  252. package/src/providers/codex/runtime/CodexRuntimeContext.ts +109 -0
  253. package/src/providers/codex/runtime/CodexServerRequestRouter.ts +331 -0
  254. package/src/providers/codex/runtime/CodexSessionFileTail.ts +792 -0
  255. package/src/providers/codex/runtime/CodexSessionManager.ts +39 -0
  256. package/src/providers/codex/runtime/codexAppServerSupport.ts +58 -0
  257. package/src/providers/codex/runtime/codexAppServerTypes.ts +705 -0
  258. package/src/providers/codex/runtime/codexLaunchTypes.ts +30 -0
  259. package/src/providers/codex/settings.ts +236 -0
  260. package/src/providers/codex/skills/CodexSkillListingService.ts +173 -0
  261. package/src/providers/codex/storage/CodexSkillStorage.ts +250 -0
  262. package/src/providers/codex/storage/CodexSubagentStorage.ts +212 -0
  263. package/src/providers/codex/types/index.ts +16 -0
  264. package/src/providers/codex/types/models.ts +46 -0
  265. package/src/providers/codex/types/subagent.ts +23 -0
  266. package/src/providers/codex/ui/CodexChatUIConfig.ts +128 -0
  267. package/src/providers/codex/ui/CodexSettingsTab.ts +432 -0
  268. package/src/providers/codex/ui/CodexSkillSettings.ts +275 -0
  269. package/src/providers/codex/ui/CodexSubagentSettings.ts +400 -0
  270. package/src/providers/defaultProviderConfigs.ts +14 -0
  271. package/src/providers/index.ts +30 -0
  272. package/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts +42 -0
  273. package/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts +67 -0
  274. package/src/providers/opencode/app/OpencodeWorkspaceServices.ts +55 -0
  275. package/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts +13 -0
  276. package/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts +12 -0
  277. package/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts +29 -0
  278. package/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts +27 -0
  279. package/src/providers/opencode/capabilities.ts +16 -0
  280. package/src/providers/opencode/commands/OpencodeCommandCatalog.ts +92 -0
  281. package/src/providers/opencode/discoveryState.ts +135 -0
  282. package/src/providers/opencode/env/OpencodeSettingsReconciler.ts +178 -0
  283. package/src/providers/opencode/history/OpencodeConversationHistoryService.ts +84 -0
  284. package/src/providers/opencode/history/OpencodeHistoryStore.ts +472 -0
  285. package/src/providers/opencode/history/OpencodeSqliteReader.ts +284 -0
  286. package/src/providers/opencode/internal/compareCollections.ts +72 -0
  287. package/src/providers/opencode/internal/providerProjection.ts +15 -0
  288. package/src/providers/opencode/models.ts +378 -0
  289. package/src/providers/opencode/modes.ts +150 -0
  290. package/src/providers/opencode/normalization/opencodeToolNormalization.ts +406 -0
  291. package/src/providers/opencode/registration.ts +27 -0
  292. package/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts +436 -0
  293. package/src/providers/opencode/runtime/OpencodeChatRuntime.ts +1603 -0
  294. package/src/providers/opencode/runtime/OpencodeCliResolver.ts +57 -0
  295. package/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts +231 -0
  296. package/src/providers/opencode/runtime/OpencodePaths.ts +113 -0
  297. package/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts +18 -0
  298. package/src/providers/opencode/runtime/buildOpencodePrompt.ts +66 -0
  299. package/src/providers/opencode/settings.ts +427 -0
  300. package/src/providers/opencode/storage/OpencodeAgentStorage.ts +346 -0
  301. package/src/providers/opencode/types/agent.ts +37 -0
  302. package/src/providers/opencode/types/index.ts +9 -0
  303. package/src/providers/opencode/ui/OpencodeAgentSettings.ts +579 -0
  304. package/src/providers/opencode/ui/OpencodeChatUIConfig.ts +316 -0
  305. package/src/providers/opencode/ui/OpencodeSettingsTab.ts +674 -0
  306. package/src/providers/pi/app/PiRuntimeCommandLoader.ts +57 -0
  307. package/src/providers/pi/app/PiWorkspaceServices.ts +39 -0
  308. package/src/providers/pi/auxiliary/PiInlineEditService.ts +9 -0
  309. package/src/providers/pi/auxiliary/PiInstructionRefineService.ts +9 -0
  310. package/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts +29 -0
  311. package/src/providers/pi/auxiliary/PiTitleGenerationService.ts +19 -0
  312. package/src/providers/pi/capabilities.ts +16 -0
  313. package/src/providers/pi/commands/PiCommandCatalog.ts +92 -0
  314. package/src/providers/pi/env/PiSettingsReconciler.ts +180 -0
  315. package/src/providers/pi/history/PiConversationHistoryService.ts +123 -0
  316. package/src/providers/pi/history/PiHistoryStore.ts +664 -0
  317. package/src/providers/pi/internal/compareCollections.ts +4 -0
  318. package/src/providers/pi/internal/providerProjection.ts +18 -0
  319. package/src/providers/pi/models.ts +302 -0
  320. package/src/providers/pi/normalizations/piEventNormalization.ts +211 -0
  321. package/src/providers/pi/normalizations/piToolNormalization.ts +97 -0
  322. package/src/providers/pi/registration.ts +30 -0
  323. package/src/providers/pi/runtime/PiAuxQueryRunner.ts +216 -0
  324. package/src/providers/pi/runtime/PiChatRuntime.ts +1064 -0
  325. package/src/providers/pi/runtime/PiCliResolver.ts +53 -0
  326. package/src/providers/pi/runtime/PiExtensionUiBridge.ts +161 -0
  327. package/src/providers/pi/runtime/PiJsonl.ts +71 -0
  328. package/src/providers/pi/runtime/PiLaunchSpec.ts +70 -0
  329. package/src/providers/pi/runtime/PiModelDiscoveryService.ts +92 -0
  330. package/src/providers/pi/runtime/PiRpcPayloads.ts +18 -0
  331. package/src/providers/pi/runtime/PiRpcTransport.ts +243 -0
  332. package/src/providers/pi/runtime/PiSubprocess.ts +159 -0
  333. package/src/providers/pi/runtime/buildPiPrompt.ts +62 -0
  334. package/src/providers/pi/runtime/buildPiUsageInfo.ts +69 -0
  335. package/src/providers/pi/settings.ts +468 -0
  336. package/src/providers/pi/types.ts +64 -0
  337. package/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts +251 -0
  338. package/src/providers/pi/ui/PiChatUIConfig.ts +265 -0
  339. package/src/providers/pi/ui/PiExtensionUiRenderer.ts +12 -0
  340. package/src/providers/pi/ui/PiSettingsTab.ts +642 -0
  341. package/src/shared/components/ResumeSessionDropdown.ts +185 -0
  342. package/src/shared/components/SelectableDropdown.ts +140 -0
  343. package/src/shared/components/SelectionHighlight.ts +77 -0
  344. package/src/shared/components/SlashCommandDropdown.ts +421 -0
  345. package/src/shared/icons.ts +180 -0
  346. package/src/shared/mention/MentionDropdownController.ts +627 -0
  347. package/src/shared/mention/VaultMentionCache.ts +106 -0
  348. package/src/shared/mention/VaultMentionDataProvider.ts +51 -0
  349. package/src/shared/mention/types.ts +67 -0
  350. package/src/shared/modals/ConfirmModal.ts +60 -0
  351. package/src/shared/modals/ForkTargetModal.ts +47 -0
  352. package/src/shared/modals/InstructionConfirmModal.ts +281 -0
  353. package/src/style/CLAUDE.md +49 -0
  354. package/src/style/accessibility.css +40 -0
  355. package/src/style/base/animations.css +44 -0
  356. package/src/style/base/container.css +20 -0
  357. package/src/style/base/variables.css +46 -0
  358. package/src/style/base/visibility.css +15 -0
  359. package/src/style/components/code.css +97 -0
  360. package/src/style/components/context-footer.css +76 -0
  361. package/src/style/components/header.css +27 -0
  362. package/src/style/components/history.css +221 -0
  363. package/src/style/components/input.css +312 -0
  364. package/src/style/components/messages.css +262 -0
  365. package/src/style/components/nav-sidebar.css +58 -0
  366. package/src/style/components/status-panel.css +202 -0
  367. package/src/style/components/subagent.css +248 -0
  368. package/src/style/components/tabs.css +112 -0
  369. package/src/style/components/thinking.css +88 -0
  370. package/src/style/components/toolcalls.css +278 -0
  371. package/src/style/features/ask-user-question.css +315 -0
  372. package/src/style/features/diff.css +197 -0
  373. package/src/style/features/file-context.css +188 -0
  374. package/src/style/features/file-link.css +22 -0
  375. package/src/style/features/image-context.css +179 -0
  376. package/src/style/features/image-embed.css +40 -0
  377. package/src/style/features/image-modal.css +52 -0
  378. package/src/style/features/inline-edit.css +278 -0
  379. package/src/style/features/plan-mode.css +103 -0
  380. package/src/style/features/resume-session.css +119 -0
  381. package/src/style/features/slash-commands.css +91 -0
  382. package/src/style/index.css +63 -0
  383. package/src/style/modals/fork-target.css +21 -0
  384. package/src/style/modals/instruction.css +161 -0
  385. package/src/style/modals/mcp-modal.css +241 -0
  386. package/src/style/settings/agent-settings.css +2 -0
  387. package/src/style/settings/base.css +300 -0
  388. package/src/style/settings/env-snippets.css +366 -0
  389. package/src/style/settings/mcp-settings.css +211 -0
  390. package/src/style/settings/plugin-settings.css +164 -0
  391. package/src/style/settings/provider-model-picker.css +367 -0
  392. package/src/style/settings/slash-settings.css +16 -0
  393. package/src/style/toolbar/external-context.css +177 -0
  394. package/src/style/toolbar/mcp-selector.css +176 -0
  395. package/src/style/toolbar/mode-selector.css +19 -0
  396. package/src/style/toolbar/model-selector.css +99 -0
  397. package/src/style/toolbar/permission-toggle.css +56 -0
  398. package/src/style/toolbar/service-tier-toggle.css +39 -0
  399. package/src/style/toolbar/thinking-selector.css +83 -0
  400. package/src/types/smol-toml.d.ts +4 -0
  401. package/src/utils/agent.ts +50 -0
  402. package/src/utils/animationFrame.ts +46 -0
  403. package/src/utils/browser.ts +46 -0
  404. package/src/utils/canvas.ts +14 -0
  405. package/src/utils/cliBinaryLocator.ts +97 -0
  406. package/src/utils/context.ts +117 -0
  407. package/src/utils/contextMentionResolver.ts +154 -0
  408. package/src/utils/date.ts +31 -0
  409. package/src/utils/diff.ts +384 -0
  410. package/src/utils/editor.ts +104 -0
  411. package/src/utils/electronCompat.ts +53 -0
  412. package/src/utils/env.ts +465 -0
  413. package/src/utils/externalContext.ts +143 -0
  414. package/src/utils/externalContextScanner.ts +135 -0
  415. package/src/utils/fileLink.ts +263 -0
  416. package/src/utils/frontmatter.ts +194 -0
  417. package/src/utils/imageEmbed.ts +139 -0
  418. package/src/utils/inlineEdit.ts +22 -0
  419. package/src/utils/interrupt.ts +23 -0
  420. package/src/utils/markdown.ts +25 -0
  421. package/src/utils/markdownMath.ts +130 -0
  422. package/src/utils/mcp.ts +96 -0
  423. package/src/utils/obsidianCompat.ts +23 -0
  424. package/src/utils/path.ts +342 -0
  425. package/src/utils/session.ts +240 -0
  426. package/src/utils/slashCommand.ts +152 -0
  427. package/src/utils/subagentJsonl.ts +52 -0
  428. package/src/utils/windowsCmdShim.ts +98 -0
  429. package/tests/__mocks__/claude-agent-sdk.ts +317 -0
  430. package/tests/__mocks__/codex-sdk.ts +88 -0
  431. package/tests/__mocks__/obsidian.ts +434 -0
  432. package/tests/helpers/mockElement.ts +403 -0
  433. package/tests/helpers/sdkMessages.ts +291 -0
  434. package/tests/integration/core/agent/ClaudianService.test.ts +1845 -0
  435. package/tests/integration/core/mcp/mcp.test.ts +905 -0
  436. package/tests/integration/features/chat/imagePersistence.test.ts +38 -0
  437. package/tests/integration/main.test.ts +1701 -0
  438. package/tests/setupWindow.ts +26 -0
  439. package/tests/tsconfig.json +7 -0
  440. package/tests/unit/core/commands/builtInCommands.test.ts +239 -0
  441. package/tests/unit/core/mcp/McpServerManager.test.ts +405 -0
  442. package/tests/unit/core/mcp/McpTester.test.ts +282 -0
  443. package/tests/unit/core/mcp/createNodeFetch.test.ts +188 -0
  444. package/tests/unit/core/providers/ProviderRegistry.test.ts +275 -0
  445. package/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts +490 -0
  446. package/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts +84 -0
  447. package/tests/unit/core/providers/modelRouting.test.ts +91 -0
  448. package/tests/unit/core/providers/modelSelection.test.ts +155 -0
  449. package/tests/unit/core/providers/providerEnvironment.test.ts +162 -0
  450. package/tests/unit/core/providers/tabLifecycle.test.ts +217 -0
  451. package/tests/unit/core/security/ApprovalManager.test.ts +152 -0
  452. package/tests/unit/core/storage/VaultFileAdapter.test.ts +535 -0
  453. package/tests/unit/core/tools/todo.test.ts +227 -0
  454. package/tests/unit/core/tools/toolIcons.test.ts +75 -0
  455. package/tests/unit/core/tools/toolInput.test.ts +350 -0
  456. package/tests/unit/core/tools/toolNames.test.ts +464 -0
  457. package/tests/unit/core/types/mcp.test.ts +115 -0
  458. package/tests/unit/features/chat/ClaudianView.test.ts +404 -0
  459. package/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts +179 -0
  460. package/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts +216 -0
  461. package/tests/unit/features/chat/controllers/ConversationController.test.ts +2764 -0
  462. package/tests/unit/features/chat/controllers/InputController.test.ts +3188 -0
  463. package/tests/unit/features/chat/controllers/NavigationController.test.ts +640 -0
  464. package/tests/unit/features/chat/controllers/SelectionController.test.ts +695 -0
  465. package/tests/unit/features/chat/controllers/StreamController.test.ts +2534 -0
  466. package/tests/unit/features/chat/controllers/contextRowVisibility.test.ts +46 -0
  467. package/tests/unit/features/chat/controllers/index.test.ts +16 -0
  468. package/tests/unit/features/chat/rendering/DiffRenderer.test.ts +355 -0
  469. package/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts +1035 -0
  470. package/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts +191 -0
  471. package/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts +126 -0
  472. package/tests/unit/features/chat/rendering/MessageRenderer.test.ts +2004 -0
  473. package/tests/unit/features/chat/rendering/SubagentRenderer.test.ts +917 -0
  474. package/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts +124 -0
  475. package/tests/unit/features/chat/rendering/TodoListRenderer.test.ts +173 -0
  476. package/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts +909 -0
  477. package/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts +474 -0
  478. package/tests/unit/features/chat/rendering/collapsible.test.ts +158 -0
  479. package/tests/unit/features/chat/rendering/todoUtils.test.ts +105 -0
  480. package/tests/unit/features/chat/rewind.test.ts +56 -0
  481. package/tests/unit/features/chat/services/BangBashService.test.ts +142 -0
  482. package/tests/unit/features/chat/services/InstructionRefineService.test.ts +371 -0
  483. package/tests/unit/features/chat/services/SubagentManager.test.ts +1759 -0
  484. package/tests/unit/features/chat/services/TitleGenerationService.test.ts +480 -0
  485. package/tests/unit/features/chat/state/ChatState.test.ts +581 -0
  486. package/tests/unit/features/chat/tabs/Tab.test.ts +4287 -0
  487. package/tests/unit/features/chat/tabs/TabBar.test.ts +357 -0
  488. package/tests/unit/features/chat/tabs/TabManager.test.ts +2962 -0
  489. package/tests/unit/features/chat/tabs/index.test.ts +11 -0
  490. package/tests/unit/features/chat/ui/BangBashModeManager.test.ts +321 -0
  491. package/tests/unit/features/chat/ui/ExternalContextSelector.test.ts +555 -0
  492. package/tests/unit/features/chat/ui/FileContextManager.test.ts +876 -0
  493. package/tests/unit/features/chat/ui/ImageContext.test.ts +777 -0
  494. package/tests/unit/features/chat/ui/InputToolbar.test.ts +1139 -0
  495. package/tests/unit/features/chat/ui/InstructionModeManager.test.ts +243 -0
  496. package/tests/unit/features/chat/ui/NavigationSidebar.test.ts +570 -0
  497. package/tests/unit/features/chat/ui/StatusPanel.test.ts +953 -0
  498. package/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +155 -0
  499. package/tests/unit/features/chat/ui/textareaResize.test.ts +102 -0
  500. package/tests/unit/features/chat/utils/usageInfo.test.ts +56 -0
  501. package/tests/unit/features/inline-edit/InlineEditService.test.ts +1199 -0
  502. package/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts +1482 -0
  503. package/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts +495 -0
  504. package/tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts +92 -0
  505. package/tests/unit/features/settings/AgentSettings.test.ts +82 -0
  506. package/tests/unit/features/settings/keyboardNavigation.test.ts +73 -0
  507. package/tests/unit/features/settings/ui/CodexSkillSettings.test.ts +294 -0
  508. package/tests/unit/features/settings/ui/CodexSubagentSettings.test.ts +207 -0
  509. package/tests/unit/i18n/constants.test.ts +43 -0
  510. package/tests/unit/i18n/i18n.test.ts +244 -0
  511. package/tests/unit/i18n/locales.test.ts +134 -0
  512. package/tests/unit/providers/acp/AcpClientConnection.test.ts +248 -0
  513. package/tests/unit/providers/acp/AcpJsonRpcTransport.test.ts +186 -0
  514. package/tests/unit/providers/acp/AcpSessionConfig.test.ts +247 -0
  515. package/tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts +145 -0
  516. package/tests/unit/providers/acp/AcpSubprocess.test.ts +105 -0
  517. package/tests/unit/providers/acp/buildAcpUsageInfo.test.ts +51 -0
  518. package/tests/unit/providers/claude/agents/AgentManager.test.ts +590 -0
  519. package/tests/unit/providers/claude/agents/AgentStorage.test.ts +434 -0
  520. package/tests/unit/providers/claude/agents/index.test.ts +10 -0
  521. package/tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts +396 -0
  522. package/tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts +92 -0
  523. package/tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts +57 -0
  524. package/tests/unit/providers/claude/env/claudeModelEnv.test.ts +228 -0
  525. package/tests/unit/providers/claude/hooks/SubagentHooks.test.ts +83 -0
  526. package/tests/unit/providers/claude/plugins/PluginManager.test.ts +832 -0
  527. package/tests/unit/providers/claude/plugins/index.test.ts +7 -0
  528. package/tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts +145 -0
  529. package/tests/unit/providers/claude/prompt/instructionRefine.test.ts +185 -0
  530. package/tests/unit/providers/claude/prompt/systemPrompt.test.ts +163 -0
  531. package/tests/unit/providers/claude/prompt/titleGeneration.test.ts +20 -0
  532. package/tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts +28 -0
  533. package/tests/unit/providers/claude/runtime/ClaudianService.test.ts +3796 -0
  534. package/tests/unit/providers/claude/runtime/MessageChannel.test.ts +421 -0
  535. package/tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts +775 -0
  536. package/tests/unit/providers/claude/runtime/SessionManager.test.ts +182 -0
  537. package/tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts +331 -0
  538. package/tests/unit/providers/claude/runtime/customSpawn.test.ts +374 -0
  539. package/tests/unit/providers/claude/runtime/index.test.ts +13 -0
  540. package/tests/unit/providers/claude/runtime/types.test.ts +190 -0
  541. package/tests/unit/providers/claude/sdk/typeGuards.test.ts +50 -0
  542. package/tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts +198 -0
  543. package/tests/unit/providers/claude/storage/AgentVaultStorage.test.ts +413 -0
  544. package/tests/unit/providers/claude/storage/CCSettingsStorage.test.ts +408 -0
  545. package/tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts +653 -0
  546. package/tests/unit/providers/claude/storage/McpStorage.test.ts +619 -0
  547. package/tests/unit/providers/claude/storage/SessionStorage.test.ts +680 -0
  548. package/tests/unit/providers/claude/storage/SkillStorage.test.ts +275 -0
  549. package/tests/unit/providers/claude/storage/SlashCommandStorage.test.ts +612 -0
  550. package/tests/unit/providers/claude/storage/storage.test.ts +360 -0
  551. package/tests/unit/providers/claude/storage/storageService.convenience.test.ts +447 -0
  552. package/tests/unit/providers/claude/stream/transformSDKMessage.test.ts +1729 -0
  553. package/tests/unit/providers/claude/types/types.test.ts +726 -0
  554. package/tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts +173 -0
  555. package/tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts +466 -0
  556. package/tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts +89 -0
  557. package/tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts +81 -0
  558. package/tests/unit/providers/codex/capabilities.test.ts +39 -0
  559. package/tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts +413 -0
  560. package/tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts +106 -0
  561. package/tests/unit/providers/codex/fixtures/codex-session-abort.jsonl +9 -0
  562. package/tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl +12 -0
  563. package/tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl +15 -0
  564. package/tests/unit/providers/codex/fixtures/codex-session-simple.jsonl +10 -0
  565. package/tests/unit/providers/codex/fixtures/codex-session-tools.jsonl +12 -0
  566. package/tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl +3 -0
  567. package/tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl +7 -0
  568. package/tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts +690 -0
  569. package/tests/unit/providers/codex/history/CodexHistoryStore.test.ts +2202 -0
  570. package/tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts +81 -0
  571. package/tests/unit/providers/codex/normalization/codexToolNormalization.test.ts +322 -0
  572. package/tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts +168 -0
  573. package/tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts +255 -0
  574. package/tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts +130 -0
  575. package/tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts +90 -0
  576. package/tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts +2445 -0
  577. package/tests/unit/providers/codex/runtime/CodexCliResolver.test.ts +103 -0
  578. package/tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts +105 -0
  579. package/tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts +150 -0
  580. package/tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts +1248 -0
  581. package/tests/unit/providers/codex/runtime/CodexPathMapper.test.ts +51 -0
  582. package/tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts +220 -0
  583. package/tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts +107 -0
  584. package/tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts +537 -0
  585. package/tests/unit/providers/codex/runtime/CodexSessionFileTail.test.ts +1305 -0
  586. package/tests/unit/providers/codex/runtime/CodexSessionManager.test.ts +82 -0
  587. package/tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts +336 -0
  588. package/tests/unit/providers/codex/settings.test.ts +189 -0
  589. package/tests/unit/providers/codex/skills/CodexSkillListingService.test.ts +178 -0
  590. package/tests/unit/providers/codex/storage/CodexSkillStorage.test.ts +342 -0
  591. package/tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts +376 -0
  592. package/tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts +211 -0
  593. package/tests/unit/providers/codex/ui/CodexSettingsTab.test.ts +578 -0
  594. package/tests/unit/providers/defaultProviderConfigs.test.ts +18 -0
  595. package/tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts +355 -0
  596. package/tests/unit/providers/opencode/OpencodeChatRuntime.test.ts +808 -0
  597. package/tests/unit/providers/opencode/OpencodeCliResolver.test.ts +93 -0
  598. package/tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts +75 -0
  599. package/tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts +103 -0
  600. package/tests/unit/providers/opencode/OpencodeHistoryStore.test.ts +418 -0
  601. package/tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts +268 -0
  602. package/tests/unit/providers/opencode/OpencodePaths.test.ts +35 -0
  603. package/tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts +129 -0
  604. package/tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts +96 -0
  605. package/tests/unit/providers/opencode/OpencodeSettingsTab.test.ts +649 -0
  606. package/tests/unit/providers/opencode/OpencodeSqliteReader.test.ts +185 -0
  607. package/tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts +56 -0
  608. package/tests/unit/providers/opencode/buildOpencodePrompt.test.ts +87 -0
  609. package/tests/unit/providers/opencode/capabilities.test.ts +39 -0
  610. package/tests/unit/providers/opencode/models.test.ts +273 -0
  611. package/tests/unit/providers/opencode/modes.test.ts +151 -0
  612. package/tests/unit/providers/opencode/opencodeToolNormalization.test.ts +197 -0
  613. package/tests/unit/providers/opencode/settings.test.ts +469 -0
  614. package/tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts +377 -0
  615. package/tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts +91 -0
  616. package/tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts +149 -0
  617. package/tests/unit/providers/pi/capabilities.test.ts +20 -0
  618. package/tests/unit/providers/pi/commands/PiCommandCatalog.test.ts +69 -0
  619. package/tests/unit/providers/pi/env/PiSettingsReconciler.test.ts +61 -0
  620. package/tests/unit/providers/pi/history/PiConversationHistoryService.test.ts +147 -0
  621. package/tests/unit/providers/pi/history/PiHistoryStore.test.ts +523 -0
  622. package/tests/unit/providers/pi/models.test.ts +96 -0
  623. package/tests/unit/providers/pi/registration.test.ts +54 -0
  624. package/tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts +172 -0
  625. package/tests/unit/providers/pi/runtime/PiChatRuntime.test.ts +830 -0
  626. package/tests/unit/providers/pi/runtime/PiCliResolver.test.ts +105 -0
  627. package/tests/unit/providers/pi/runtime/PiEventNormalization.test.ts +147 -0
  628. package/tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts +98 -0
  629. package/tests/unit/providers/pi/runtime/PiJsonl.test.ts +42 -0
  630. package/tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts +92 -0
  631. package/tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts +135 -0
  632. package/tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts +15 -0
  633. package/tests/unit/providers/pi/runtime/PiRpcTransport.test.ts +116 -0
  634. package/tests/unit/providers/pi/runtime/PiSubprocess.test.ts +151 -0
  635. package/tests/unit/providers/pi/runtime/buildPiPrompt.test.ts +84 -0
  636. package/tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts +69 -0
  637. package/tests/unit/providers/pi/settings.test.ts +253 -0
  638. package/tests/unit/providers/pi/ui/PiChatUIConfig.test.ts +161 -0
  639. package/tests/unit/providers/pi/ui/PiSettingsTab.test.ts +492 -0
  640. package/tests/unit/scripts/rendererSafeUnref.test.ts +101 -0
  641. package/tests/unit/shared/components/ResumeSessionDropdown.test.ts +356 -0
  642. package/tests/unit/shared/components/SelectableDropdown.test.ts +406 -0
  643. package/tests/unit/shared/components/SlashCommandDropdown.provider.test.ts +354 -0
  644. package/tests/unit/shared/components/SlashCommandDropdown.test.ts +508 -0
  645. package/tests/unit/shared/icons.test.ts +56 -0
  646. package/tests/unit/shared/index.test.ts +46 -0
  647. package/tests/unit/shared/mention/MentionDropdownController.test.ts +823 -0
  648. package/tests/unit/shared/mention/VaultFileCache.test.ts +195 -0
  649. package/tests/unit/shared/mention/VaultFolderCache.test.ts +143 -0
  650. package/tests/unit/shared/mention/VaultMentionDataProvider.test.ts +87 -0
  651. package/tests/unit/shared/modals/ConfirmModal.test.ts +111 -0
  652. package/tests/unit/shared/modals/ForkTargetModal.test.ts +101 -0
  653. package/tests/unit/shared/modals/InstructionConfirmModal.test.ts +305 -0
  654. package/tests/unit/utils/agent.test.ts +395 -0
  655. package/tests/unit/utils/animationFrame.test.ts +59 -0
  656. package/tests/unit/utils/browser.test.ts +73 -0
  657. package/tests/unit/utils/canvas.test.ts +54 -0
  658. package/tests/unit/utils/claudeCli.test.ts +294 -0
  659. package/tests/unit/utils/cliBinaryLocator.test.ts +33 -0
  660. package/tests/unit/utils/context.test.ts +288 -0
  661. package/tests/unit/utils/contextMentionResolver.test.ts +270 -0
  662. package/tests/unit/utils/date.test.ts +80 -0
  663. package/tests/unit/utils/diff.test.ts +291 -0
  664. package/tests/unit/utils/editor.test.ts +249 -0
  665. package/tests/unit/utils/electronCompat.test.ts +87 -0
  666. package/tests/unit/utils/env.test.ts +1240 -0
  667. package/tests/unit/utils/externalContext.test.ts +336 -0
  668. package/tests/unit/utils/externalContextScanner.test.ts +186 -0
  669. package/tests/unit/utils/fileLink.dom.test.ts +273 -0
  670. package/tests/unit/utils/fileLink.handler.test.ts +64 -0
  671. package/tests/unit/utils/fileLink.test.ts +233 -0
  672. package/tests/unit/utils/frontmatter.test.ts +434 -0
  673. package/tests/unit/utils/imageEmbed.test.ts +407 -0
  674. package/tests/unit/utils/inlineEdit.test.ts +63 -0
  675. package/tests/unit/utils/interrupt.test.ts +73 -0
  676. package/tests/unit/utils/markdown.test.ts +35 -0
  677. package/tests/unit/utils/markdownMath.test.ts +54 -0
  678. package/tests/unit/utils/mcp.test.ts +256 -0
  679. package/tests/unit/utils/obsidianCompat.test.ts +18 -0
  680. package/tests/unit/utils/path.test.ts +677 -0
  681. package/tests/unit/utils/sdkSession.test.ts +2359 -0
  682. package/tests/unit/utils/session.test.ts +971 -0
  683. package/tests/unit/utils/slashCommand.test.ts +778 -0
  684. package/tests/unit/utils/utils.test.ts +809 -0
  685. package/tsconfig.jest.json +8 -0
  686. package/tsconfig.json +26 -0
  687. package/versions.json +4 -0
@@ -0,0 +1,1845 @@
1
+ import '@/providers';
2
+
3
+ // eslint-disable-next-line jest/no-mocks-import
4
+ import {
5
+ getLastOptions,
6
+ getLastResponse,
7
+ getQueryCallCount,
8
+ resetMockMessages,
9
+ setMockMessages,
10
+ simulateCrash,
11
+ } from '@test/__mocks__/claude-agent-sdk';
12
+ import * as fs from 'fs';
13
+ import * as os from 'os';
14
+ import * as path from 'path';
15
+
16
+ // Mock fs module
17
+ jest.mock('fs');
18
+
19
+ // Now import after all mocks are set up
20
+ import { buildResultErrorMessage } from '@test/helpers/sdkMessages';
21
+
22
+ import { getActionDescription, getActionPattern } from '@/core/security/ApprovalManager';
23
+ import { getPathFromToolInput } from '@/core/tools/toolInput';
24
+ import { ClaudianService } from '@/providers/claude/runtime/ClaudeChatRuntime';
25
+ import { resolveClaudeCliPath } from '@/providers/claude/runtime/ClaudeCliResolver';
26
+ import { transformSDKMessage } from '@/providers/claude/stream/transformClaudeMessage';
27
+ import {
28
+ buildContextFromHistory,
29
+ formatToolCallForContext,
30
+ getLastUserMessage,
31
+ isSessionExpiredError,
32
+ truncateToolResult,
33
+ } from '@/utils/session';
34
+
35
+ // Helper to create SDK-format assistant message with tool_use
36
+ function createAssistantWithToolUse(toolName: string, toolInput: Record<string, unknown>, toolId = 'tool-123') {
37
+ return {
38
+ type: 'assistant',
39
+ message: {
40
+ content: [
41
+ { type: 'tool_use', id: toolId, name: toolName, input: toolInput },
42
+ ],
43
+ },
44
+ };
45
+ }
46
+
47
+ // Helper to create SDK-format user message with tool_result
48
+ function createUserWithToolResult(content: string, parentToolUseId = 'tool-123') {
49
+ return {
50
+ type: 'user',
51
+ parent_tool_use_id: parentToolUseId,
52
+ tool_use_result: content,
53
+ message: { content: [] },
54
+ };
55
+ }
56
+
57
+ function createTextUserMessage(content: string) {
58
+ return {
59
+ type: 'user',
60
+ message: {
61
+ role: 'user',
62
+ content,
63
+ },
64
+ parent_tool_use_id: null,
65
+ session_id: '',
66
+ };
67
+ }
68
+
69
+ // Create a mock MCP server manager
70
+ function createMockMcpManager() {
71
+ return {
72
+ loadServers: jest.fn().mockResolvedValue(undefined),
73
+ getServers: jest.fn().mockReturnValue([]),
74
+ getEnabledCount: jest.fn().mockReturnValue(0),
75
+ getActiveServers: jest.fn().mockReturnValue({}),
76
+ getDisallowedMcpTools: jest.fn().mockReturnValue([]),
77
+ getAllDisallowedMcpTools: jest.fn().mockReturnValue([]),
78
+ hasServers: jest.fn().mockReturnValue(false),
79
+ extractMentions: jest.fn().mockReturnValue(new Set<string>()),
80
+ transformMentions: jest.fn().mockImplementation((text: string) => text),
81
+ } as any;
82
+ }
83
+
84
+ // Create a mock plugin
85
+ function createMockPlugin(settings: Record<string, unknown> = {}) {
86
+ // CC permissions storage (allow/deny/ask arrays)
87
+ const ccPermissions = {
88
+ allow: [] as string[],
89
+ deny: [] as string[],
90
+ ask: [] as string[],
91
+ };
92
+
93
+ const mockPlugin = {
94
+ settings: {
95
+ permissions: [], // Legacy field (for backwards compat tests)
96
+ permissionMode: 'yolo',
97
+ loadUserClaudeSettings: false,
98
+ mediaFolder: '',
99
+ systemPrompt: '',
100
+ model: 'claude-sonnet-4-5',
101
+ thinkingBudget: 'off',
102
+ ...settings,
103
+ },
104
+ app: {
105
+ vault: {
106
+ adapter: {
107
+ basePath: '/test/vault/path',
108
+ },
109
+ },
110
+ },
111
+ storage: {
112
+ getPermissions: jest.fn().mockImplementation(async () => ccPermissions),
113
+ addAllowRule: jest.fn().mockImplementation(async (rule: string) => {
114
+ ccPermissions.allow.push(rule);
115
+ }),
116
+ addDenyRule: jest.fn().mockImplementation(async (rule: string) => {
117
+ ccPermissions.deny.push(rule);
118
+ }),
119
+ },
120
+ // Expose ccPermissions for test assertions
121
+ _ccPermissions: ccPermissions,
122
+ saveSettings: jest.fn().mockResolvedValue(undefined),
123
+ getActiveEnvironmentVariables: jest.fn().mockReturnValue(''),
124
+ getResolvedProviderCliPath: jest.fn().mockReturnValue('/mock/claude'),
125
+ // Mock getView to return null (tests don't have real view)
126
+ // This allows optional chaining to work safely
127
+ getView: jest.fn().mockReturnValue(null),
128
+ // Mock pluginManager for QueryOptionsBuilder
129
+ pluginManager: {
130
+ getPluginsKey: jest.fn().mockReturnValue(''),
131
+ hasEnabledPlugins: jest.fn().mockReturnValue(false),
132
+ },
133
+ } as any;
134
+ return mockPlugin;
135
+ }
136
+
137
+ describe('ClaudianService', () => {
138
+ let service: ClaudianService;
139
+ let mockPlugin: any;
140
+
141
+ beforeEach(() => {
142
+ jest.clearAllMocks();
143
+ resetMockMessages();
144
+ mockPlugin = createMockPlugin();
145
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
146
+ });
147
+
148
+ afterEach(() => {
149
+ // Clean up persistent query to prevent test hangs
150
+ service.cleanup();
151
+ });
152
+
153
+ describe('findClaudeCLI', () => {
154
+ beforeEach(() => {
155
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
156
+ resolveClaudeCliPath(
157
+ undefined, // Hostname path (not used in tests)
158
+ mockPlugin.settings.claudeCliPath,
159
+ mockPlugin.getActiveEnvironmentVariables()
160
+ )
161
+ );
162
+ });
163
+
164
+ afterEach(() => {
165
+ (fs.existsSync as jest.Mock).mockReset();
166
+ (fs.statSync as jest.Mock).mockReset();
167
+ });
168
+
169
+ it('should find claude CLI in ~/.claude/local/claude', async () => {
170
+ const homeDir = os.homedir();
171
+ const expectedPath = path.join(homeDir, '.claude', 'local', 'claude');
172
+
173
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) => {
174
+ return p === expectedPath;
175
+ });
176
+ (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
177
+
178
+ setMockMessages([
179
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
180
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
181
+ ]);
182
+
183
+ const chunks: any[] = [];
184
+ for await (const chunk of service.query('hello')) {
185
+ chunks.push(chunk);
186
+ }
187
+
188
+ const errorChunk = chunks.find(
189
+ (c) => c.type === 'error' && c.content.includes('Claude CLI not found')
190
+ );
191
+ expect(errorChunk).toBeUndefined();
192
+ });
193
+
194
+ it('should return error when claude CLI not found', async () => {
195
+ (fs.existsSync as jest.Mock).mockReturnValue(false);
196
+
197
+ const chunks: any[] = [];
198
+ for await (const chunk of service.query('hello')) {
199
+ chunks.push(chunk);
200
+ }
201
+
202
+ const errorChunk = chunks.find((c) => c.type === 'error');
203
+ expect(errorChunk).toBeDefined();
204
+ expect(errorChunk?.content).toContain('Claude CLI not found');
205
+ });
206
+
207
+ it('should use custom CLI path when valid file is specified', async () => {
208
+ const customPath = '/custom/path/to/claude';
209
+ mockPlugin = createMockPlugin({ claudeCliPath: customPath });
210
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
211
+ resolveClaudeCliPath(
212
+ undefined, // Hostname path (not used in tests)
213
+ mockPlugin.settings.claudeCliPath,
214
+ mockPlugin.getActiveEnvironmentVariables()
215
+ )
216
+ );
217
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
218
+
219
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === customPath);
220
+ (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
221
+
222
+ setMockMessages([
223
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
224
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
225
+ ]);
226
+
227
+ const chunks: any[] = [];
228
+ for await (const chunk of service.query('hello')) {
229
+ chunks.push(chunk);
230
+ }
231
+
232
+ const errorChunk = chunks.find(
233
+ (c) => c.type === 'error' && c.content.includes('Claude CLI not found')
234
+ );
235
+ expect(errorChunk).toBeUndefined();
236
+ });
237
+
238
+ it('should fall back to auto-detection when custom path is a directory', async () => {
239
+ const customPath = '/custom/path/to/directory';
240
+ mockPlugin = createMockPlugin({ claudeCliPath: customPath });
241
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
242
+ resolveClaudeCliPath(
243
+ undefined, // Hostname path (not used in tests)
244
+ mockPlugin.settings.claudeCliPath,
245
+ mockPlugin.getActiveEnvironmentVariables()
246
+ )
247
+ );
248
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
249
+
250
+ const homeDir = os.homedir();
251
+ const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude');
252
+
253
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) =>
254
+ p === customPath || p === autoDetectedPath
255
+ );
256
+ (fs.statSync as jest.Mock).mockImplementation((p: string) => ({
257
+ isFile: () => p !== customPath, // Custom path is a directory
258
+ }));
259
+
260
+ setMockMessages([
261
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
262
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
263
+ ]);
264
+
265
+ const chunks: any[] = [];
266
+ for await (const chunk of service.query('hello')) {
267
+ chunks.push(chunk);
268
+ }
269
+
270
+ // CLI path validation is silent - just verifies fallback works
271
+ expect(chunks.length).toBeGreaterThan(0);
272
+ });
273
+
274
+ it('should fall back to auto-detection when custom path does not exist', async () => {
275
+ const customPath = '/nonexistent/path/claude';
276
+ mockPlugin = createMockPlugin({ claudeCliPath: customPath });
277
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
278
+ resolveClaudeCliPath(
279
+ undefined, // Hostname path (not used in tests)
280
+ mockPlugin.settings.claudeCliPath,
281
+ mockPlugin.getActiveEnvironmentVariables()
282
+ )
283
+ );
284
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
285
+
286
+ const homeDir = os.homedir();
287
+ const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude');
288
+
289
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) =>
290
+ p === autoDetectedPath // Custom path does not exist
291
+ );
292
+ (fs.statSync as jest.Mock).mockImplementation((p: string) => ({
293
+ isFile: () => p === autoDetectedPath,
294
+ }));
295
+
296
+ setMockMessages([
297
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
298
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
299
+ ]);
300
+
301
+ const chunks: any[] = [];
302
+ for await (const chunk of service.query('hello')) {
303
+ chunks.push(chunk);
304
+ }
305
+
306
+ // CLI path validation is silent - just verifies fallback works
307
+ expect(chunks.length).toBeGreaterThan(0);
308
+ });
309
+
310
+ it('should fall back to auto-detection when custom path stat fails', async () => {
311
+ const customPath = '/custom/path/to/claude';
312
+ mockPlugin = createMockPlugin({ claudeCliPath: customPath });
313
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
314
+ resolveClaudeCliPath(
315
+ undefined, // Hostname path (not used in tests)
316
+ mockPlugin.settings.claudeCliPath,
317
+ mockPlugin.getActiveEnvironmentVariables()
318
+ )
319
+ );
320
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
321
+
322
+ const homeDir = os.homedir();
323
+ const autoDetectedPath = path.join(homeDir, '.claude', 'local', 'claude');
324
+
325
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) =>
326
+ p === customPath || p === autoDetectedPath
327
+ );
328
+ // Custom path stat throws, auto-detected path works
329
+ (fs.statSync as jest.Mock).mockImplementation((p: string) => {
330
+ if (p === customPath) {
331
+ throw new Error('EACCES');
332
+ }
333
+ return { isFile: () => p === autoDetectedPath };
334
+ });
335
+
336
+ setMockMessages([
337
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
338
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
339
+ ]);
340
+
341
+ const chunks: any[] = [];
342
+ for await (const chunk of service.query('hello')) {
343
+ chunks.push(chunk);
344
+ }
345
+
346
+ const errorChunk = chunks.find(
347
+ (c) => c.type === 'error' && c.content.includes('Claude CLI not found')
348
+ );
349
+ expect(errorChunk).toBeUndefined();
350
+
351
+ const options = getLastOptions();
352
+ expect(options?.pathToClaudeCodeExecutable).toBe(autoDetectedPath);
353
+ });
354
+
355
+ it('should reload CLI path after cleanup', async () => {
356
+ const firstPath = '/custom/path/to/claude-1';
357
+ const secondPath = '/custom/path/to/claude-2';
358
+ mockPlugin = createMockPlugin({ claudeCliPath: firstPath });
359
+ mockPlugin.getResolvedProviderCliPath.mockImplementation(() =>
360
+ resolveClaudeCliPath(
361
+ undefined, // Hostname path (not used in tests)
362
+ mockPlugin.settings.claudeCliPath,
363
+ mockPlugin.getActiveEnvironmentVariables()
364
+ )
365
+ );
366
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
367
+
368
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === firstPath);
369
+ (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
370
+
371
+ setMockMessages([
372
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
373
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
374
+ ]);
375
+
376
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
377
+ for await (const _chunk of service.query('hello')) {
378
+ // drain
379
+ }
380
+
381
+ const firstOptions = getLastOptions();
382
+ expect(firstOptions?.pathToClaudeCodeExecutable).toBe(firstPath);
383
+
384
+ mockPlugin.settings.claudeCliPath = secondPath;
385
+ service.cleanup();
386
+
387
+ (fs.existsSync as jest.Mock).mockImplementation((p: string) => p === secondPath);
388
+ (fs.statSync as jest.Mock).mockReturnValue({ isFile: () => true });
389
+
390
+ setMockMessages([
391
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
392
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello again' }] } },
393
+ ]);
394
+
395
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
396
+ for await (const _chunk of service.query('hello again')) {
397
+ // drain
398
+ }
399
+
400
+ const secondOptions = getLastOptions();
401
+ expect(secondOptions?.pathToClaudeCodeExecutable).toBe(secondPath);
402
+ });
403
+ });
404
+
405
+ describe('transformSDKMessage', () => {
406
+ beforeEach(() => {
407
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
408
+ });
409
+
410
+ it('should transform assistant text messages', async () => {
411
+ setMockMessages([
412
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
413
+ {
414
+ type: 'assistant',
415
+ message: { content: [{ type: 'text', text: 'This is a test response' }] },
416
+ },
417
+ ]);
418
+
419
+ const chunks: any[] = [];
420
+ for await (const chunk of service.query('hello')) {
421
+ chunks.push(chunk);
422
+ }
423
+
424
+ const textChunk = chunks.find((c) => c.type === 'text');
425
+ expect(textChunk).toBeDefined();
426
+ expect(textChunk?.content).toBe('This is a test response');
427
+ });
428
+
429
+ it('should transform tool_use from assistant message content', async () => {
430
+ setMockMessages([
431
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
432
+ createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'),
433
+ ]);
434
+
435
+ const chunks: any[] = [];
436
+ for await (const chunk of service.query('read file')) {
437
+ chunks.push(chunk);
438
+ }
439
+
440
+ const toolUseChunk = chunks.find((c) => c.type === 'tool_use');
441
+ expect(toolUseChunk).toBeDefined();
442
+ expect(toolUseChunk?.name).toBe('Read');
443
+ expect(toolUseChunk?.input).toEqual({ file_path: '/test/file.txt' });
444
+ expect(toolUseChunk?.id).toBe('read-tool-1');
445
+ });
446
+
447
+ it('should transform tool_result from user message', async () => {
448
+ setMockMessages([
449
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
450
+ createAssistantWithToolUse('Read', { file_path: '/test/file.txt' }, 'read-tool-1'),
451
+ createUserWithToolResult('File contents here', 'read-tool-1'),
452
+ ]);
453
+
454
+ const chunks: any[] = [];
455
+ for await (const chunk of service.query('read file')) {
456
+ chunks.push(chunk);
457
+ }
458
+
459
+ const toolResultChunk = chunks.find((c) => c.type === 'subagent_tool_result');
460
+ expect(toolResultChunk).toBeDefined();
461
+ expect(toolResultChunk?.content).toBe('File contents here');
462
+ expect(toolResultChunk?.id).toBe('read-tool-1');
463
+ });
464
+
465
+ it('should transform assistant error messages', async () => {
466
+ setMockMessages([
467
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
468
+ {
469
+ type: 'assistant',
470
+ error: 'Something went wrong',
471
+ message: { content: [] },
472
+ },
473
+ ]);
474
+
475
+ const chunks: any[] = [];
476
+ for await (const chunk of service.query('do something')) {
477
+ chunks.push(chunk);
478
+ }
479
+
480
+ const errorChunk = chunks.find((c) => c.type === 'error' && c.content === 'Something went wrong');
481
+ expect(errorChunk).toBeDefined();
482
+ });
483
+
484
+ it('should capture session ID from init message', async () => {
485
+ setMockMessages([
486
+ { type: 'system', subtype: 'init', session_id: 'my-session-123' },
487
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
488
+ ]);
489
+
490
+ const chunks: any[] = [];
491
+ for await (const chunk of service.query('hello')) {
492
+ chunks.push(chunk);
493
+ }
494
+
495
+ expect(chunks.some((c) => c.type === 'text')).toBe(true);
496
+ });
497
+
498
+ it('should resume previous session on subsequent queries', async () => {
499
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
500
+
501
+ setMockMessages([
502
+ { type: 'system', subtype: 'init', session_id: 'resume-session' },
503
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'First run' }] } },
504
+ { type: 'result' },
505
+ ]);
506
+
507
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
508
+ for await (const _chunk of service.query('first')) {
509
+ // drain
510
+ }
511
+
512
+ setMockMessages([
513
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Second run' }] } },
514
+ { type: 'result' },
515
+ ]);
516
+
517
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
518
+ for await (const _chunk of service.query('second', undefined, undefined, { forceColdStart: true })) {
519
+ // drain
520
+ }
521
+
522
+ const options = getLastOptions();
523
+ expect(options?.resume).toBe('resume-session');
524
+ expect(service.getSessionId()).toBe('resume-session');
525
+ });
526
+
527
+ it('should extract multiple content blocks from assistant message', async () => {
528
+ setMockMessages([
529
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
530
+ {
531
+ type: 'assistant',
532
+ message: {
533
+ content: [
534
+ { type: 'text', text: 'Let me read that file.' },
535
+ { type: 'tool_use', id: 'tool-abc', name: 'Read', input: { file_path: '/foo.txt' } },
536
+ ],
537
+ },
538
+ },
539
+ ]);
540
+
541
+ const chunks: any[] = [];
542
+ for await (const chunk of service.query('read foo.txt')) {
543
+ chunks.push(chunk);
544
+ }
545
+
546
+ const textChunk = chunks.find((c) => c.type === 'text');
547
+ expect(textChunk?.content).toBe('Let me read that file.');
548
+
549
+ const toolUseChunk = chunks.find((c) => c.type === 'tool_use');
550
+ expect(toolUseChunk?.name).toBe('Read');
551
+ expect(toolUseChunk?.id).toBe('tool-abc');
552
+ });
553
+ });
554
+
555
+ describe('cancel', () => {
556
+ it('should abort ongoing request', async () => {
557
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
558
+
559
+ setMockMessages([
560
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
561
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
562
+ ]);
563
+
564
+ const queryGenerator = service.query('hello');
565
+ await queryGenerator.next();
566
+
567
+ expect(() => service.cancel()).not.toThrow();
568
+ });
569
+
570
+ it('should call interrupt on underlying stream when aborted', async () => {
571
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
572
+
573
+ setMockMessages([
574
+ { type: 'system', subtype: 'init', session_id: 'cancel-session' },
575
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 1' }] } },
576
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Chunk 2' }] } },
577
+ { type: 'result' },
578
+ ]);
579
+
580
+ const generator = service.query('streaming');
581
+ await generator.next();
582
+
583
+ service.cancel();
584
+
585
+ const chunks: any[] = [];
586
+ for await (const chunk of generator) {
587
+ chunks.push(chunk);
588
+ }
589
+
590
+ const response = getLastResponse();
591
+ expect(response?.interrupt).toHaveBeenCalled();
592
+ expect(chunks.some((c) => c.type === 'done')).toBe(true);
593
+ });
594
+
595
+ it('should handle cancel when no query is running', () => {
596
+ expect(() => service.cancel()).not.toThrow();
597
+ });
598
+ });
599
+
600
+ // MessageChannel tests moved to tests/unit/core/agent/MessageChannel.test.ts
601
+
602
+ describe('persistent query updates', () => {
603
+ it('updates model on the active persistent query', async () => {
604
+ const chunks: any[] = [];
605
+ for await (const chunk of service.query('hello', undefined, undefined, { model: 'claude-opus-4-5' })) {
606
+ chunks.push(chunk);
607
+ }
608
+
609
+ const response = getLastResponse();
610
+ expect(response?.setModel).toHaveBeenCalledWith('claude-opus-4-5');
611
+ });
612
+ });
613
+
614
+ describe('persistent query error handling', () => {
615
+ it('yields error from assistant message with error field', async () => {
616
+ setMockMessages([
617
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
618
+ { type: 'assistant', error: 'server_error', message: { content: [] } },
619
+ ]);
620
+
621
+ const chunks: any[] = [];
622
+ for await (const chunk of service.query('trigger error')) {
623
+ chunks.push(chunk);
624
+ }
625
+
626
+ expect(chunks.some((c) => c.type === 'error' && c.content === 'server_error')).toBe(true);
627
+ expect(chunks.some((c) => c.type === 'done')).toBe(true);
628
+ });
629
+
630
+ it('yields error from failed result messages', async () => {
631
+ setMockMessages([
632
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
633
+ buildResultErrorMessage({
634
+ subtype: 'error_max_turns',
635
+ errors: ['Max turns reached'],
636
+ }),
637
+ ]);
638
+
639
+ const chunks: any[] = [];
640
+ for await (const chunk of service.query('trigger max turns')) {
641
+ chunks.push(chunk);
642
+ }
643
+
644
+ expect(chunks.some((c) => c.type === 'error' && c.content === 'Max turns reached')).toBe(true);
645
+ expect(chunks.some((c) => c.type === 'done')).toBe(true);
646
+ });
647
+
648
+ // Note: Session expiration is handled via thrown errors in catch blocks,
649
+ // not via message types. The SDK throws on session expiration which is
650
+ // caught by isSessionExpiredError() in the query error handlers.
651
+ });
652
+
653
+ describe('closePersistentQuery with preserveHandlers', () => {
654
+ afterEach(() => {
655
+ service.cleanup();
656
+ });
657
+
658
+ it('preserves handlers when preserveHandlers is true', async () => {
659
+ // Start a query to create handlers
660
+ const queryPromise = (async () => {
661
+ const chunks: any[] = [];
662
+ for await (const chunk of service.query('hello')) {
663
+ chunks.push(chunk);
664
+ }
665
+ return chunks;
666
+ })();
667
+
668
+ // Let the query start
669
+ await new Promise((resolve) => setTimeout(resolve, 10));
670
+
671
+ // Access internal state to verify handlers exist
672
+ const handlersBefore = (service as any).responseHandlers?.length ?? 0;
673
+
674
+ // Close with preserveHandlers: true
675
+ service.closePersistentQuery('test', { preserveHandlers: true });
676
+
677
+ // Handlers should still exist
678
+ const handlersAfter = (service as any).responseHandlers?.length ?? 0;
679
+ expect(handlersAfter).toBe(handlersBefore);
680
+
681
+ // Clean up the promise (it will resolve/reject after close)
682
+ await queryPromise.catch(() => { });
683
+ });
684
+
685
+ it('clears handlers when preserveHandlers is false (default)', async () => {
686
+ // Start a query to create handlers
687
+ const queryPromise = (async () => {
688
+ const chunks: any[] = [];
689
+ for await (const chunk of service.query('hello')) {
690
+ chunks.push(chunk);
691
+ }
692
+ return chunks;
693
+ })();
694
+
695
+ // Let the query start
696
+ await new Promise((resolve) => setTimeout(resolve, 10));
697
+
698
+ // Close without preserveHandlers (default is false)
699
+ service.closePersistentQuery('test');
700
+
701
+ // Handlers should be cleared
702
+ const handlersAfter = (service as any).responseHandlers?.length ?? 0;
703
+ expect(handlersAfter).toBe(0);
704
+
705
+ // Clean up the promise
706
+ await queryPromise.catch(() => { });
707
+ });
708
+ });
709
+
710
+ describe('crash recovery with simulateCrash', () => {
711
+ afterEach(() => {
712
+ service.cleanup();
713
+ });
714
+
715
+ it('restarts persistent query on consumer error when no chunks received', async () => {
716
+ // Simulate crash before any chunks are emitted
717
+ simulateCrash(0);
718
+
719
+ const initialCallCount = getQueryCallCount();
720
+ const chunks: any[] = [];
721
+
722
+ // The query should recover and eventually succeed
723
+ for await (const chunk of service.query('hello')) {
724
+ chunks.push(chunk);
725
+ }
726
+
727
+ // Query should have been called twice (initial + restart)
728
+ expect(getQueryCallCount()).toBe(initialCallCount + 2);
729
+
730
+ // Should have received the successful response after recovery
731
+ const textChunk = chunks.find((c) => c.type === 'text');
732
+ expect(textChunk).toBeDefined();
733
+ });
734
+
735
+ it('does not replay message when chunks were already received before crash', async () => {
736
+ // Simulate crash after 1 chunk is emitted (system init message)
737
+ simulateCrash(1);
738
+
739
+ const chunks: any[] = [];
740
+
741
+ for await (const chunk of service.query('hello')) {
742
+ chunks.push(chunk);
743
+ }
744
+
745
+ // Should have received the system init chunk before error
746
+ // Note: error is propagated via onError handler which ends the generator
747
+ expect(chunks.length).toBeGreaterThan(0);
748
+ });
749
+ });
750
+
751
+ describe('persistent query recovery after close', () => {
752
+ afterEach(() => {
753
+ service.cleanup();
754
+ });
755
+
756
+ it('can start new persistent query after closePersistentQuery', async () => {
757
+ // First query establishes persistent query
758
+ const chunks1: any[] = [];
759
+ for await (const chunk of service.query('first')) {
760
+ chunks1.push(chunk);
761
+ }
762
+ expect(chunks1.length).toBeGreaterThan(0);
763
+ expect((service as any).persistentQuery).not.toBeNull();
764
+
765
+ // Close the persistent query (simulating session reset)
766
+ service.closePersistentQuery('test close');
767
+ expect((service as any).persistentQuery).toBeNull();
768
+ expect((service as any).shuttingDown).toBe(false); // Should be reset
769
+
770
+ // Next query should start a NEW persistent query (not fall back to cold-start)
771
+ const chunks2: any[] = [];
772
+ for await (const chunk of service.query('second')) {
773
+ chunks2.push(chunk);
774
+ }
775
+ expect(chunks2.length).toBeGreaterThan(0);
776
+
777
+ // Verify persistent query was recreated
778
+ expect((service as any).persistentQuery).not.toBeNull();
779
+ });
780
+
781
+ it('can recover after resetSession closes persistent query', async () => {
782
+ // First query
783
+ const chunks1: any[] = [];
784
+ for await (const chunk of service.query('first')) {
785
+ chunks1.push(chunk);
786
+ }
787
+ expect((service as any).persistentQuery).not.toBeNull();
788
+
789
+ // Reset session (which closes persistent query)
790
+ service.resetSession();
791
+ expect((service as any).persistentQuery).toBeNull();
792
+ expect((service as any).shuttingDown).toBe(false);
793
+
794
+ // Next query should work
795
+ const chunks2: any[] = [];
796
+ for await (const chunk of service.query('second')) {
797
+ chunks2.push(chunk);
798
+ }
799
+ expect(chunks2.length).toBeGreaterThan(0);
800
+ expect((service as any).persistentQuery).not.toBeNull();
801
+ });
802
+
803
+ it('can recover after session switch closes persistent query', async () => {
804
+ // First query
805
+ const chunks1: any[] = [];
806
+ for await (const chunk of service.query('first')) {
807
+ chunks1.push(chunk);
808
+ }
809
+ expect((service as any).persistentQuery).not.toBeNull();
810
+
811
+ // Switch to a different session (which closes persistent query)
812
+ service.setSessionId('new-session-id');
813
+ expect((service as any).persistentQuery).toBeNull();
814
+ expect((service as any).shuttingDown).toBe(false);
815
+
816
+ // Next query should work with new session
817
+ const chunks2: any[] = [];
818
+ for await (const chunk of service.query('second')) {
819
+ chunks2.push(chunk);
820
+ }
821
+ expect(chunks2.length).toBeGreaterThan(0);
822
+ expect((service as any).persistentQuery).not.toBeNull();
823
+ });
824
+ });
825
+
826
+ // SessionManager tests (resetSession, getSessionId, setSessionId) moved to:
827
+ // tests/unit/core/agent/SessionManager.test.ts
828
+
829
+ describe('cleanup', () => {
830
+ it('should call cancel and resetSession', () => {
831
+ const cancelSpy = jest.spyOn(service, 'cancel');
832
+ const resetSessionSpy = jest.spyOn(service, 'resetSession');
833
+
834
+ service.cleanup();
835
+
836
+ expect(cancelSpy).toHaveBeenCalled();
837
+ expect(resetSessionSpy).toHaveBeenCalled();
838
+ });
839
+ });
840
+
841
+ describe('getVaultPath', () => {
842
+ it('should return error when vault path cannot be determined', async () => {
843
+ mockPlugin = {
844
+ ...mockPlugin,
845
+ app: {
846
+ vault: {
847
+ adapter: {},
848
+ },
849
+ },
850
+ };
851
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
852
+
853
+ const chunks: any[] = [];
854
+ for await (const chunk of service.query('hello')) {
855
+ chunks.push(chunk);
856
+ }
857
+
858
+ const errorChunk = chunks.find(
859
+ (c) => c.type === 'error' && c.content.includes('vault path')
860
+ );
861
+ expect(errorChunk).toBeDefined();
862
+ });
863
+ });
864
+
865
+ describe('query with conversation history', () => {
866
+ beforeEach(() => {
867
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
868
+ });
869
+
870
+ it('should accept optional conversation history parameter', async () => {
871
+ setMockMessages([
872
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
873
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } },
874
+ { type: 'result' },
875
+ ]);
876
+
877
+ const history = [
878
+ { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() },
879
+ { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() },
880
+ ];
881
+
882
+ const chunks: any[] = [];
883
+ for await (const chunk of service.query('new message', undefined, history)) {
884
+ chunks.push(chunk);
885
+ }
886
+
887
+ expect(chunks.some((c) => c.type === 'text')).toBe(true);
888
+ });
889
+
890
+ it('should work without conversation history', async () => {
891
+ setMockMessages([
892
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
893
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } },
894
+ { type: 'result' },
895
+ ]);
896
+
897
+ const chunks: any[] = [];
898
+ for await (const chunk of service.query('hello')) {
899
+ chunks.push(chunk);
900
+ }
901
+
902
+ expect(chunks.some((c) => c.type === 'text')).toBe(true);
903
+ });
904
+
905
+ it('should rebuild history when session is missing but history exists', async () => {
906
+ const prompts: string[] = [];
907
+
908
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) {
909
+ prompts.push(prompt);
910
+ yield { type: 'text', content: 'ok' };
911
+ }) as any);
912
+
913
+ const history = [
914
+ { id: 'msg-1', role: 'user' as const, content: 'Previous message', timestamp: Date.now() },
915
+ { id: 'msg-2', role: 'assistant' as const, content: 'Previous response', timestamp: Date.now() },
916
+ ];
917
+
918
+ const chunks: any[] = [];
919
+ for await (const chunk of service.query('New message', undefined, history)) {
920
+ chunks.push(chunk);
921
+ }
922
+
923
+ expect(prompts).toHaveLength(1);
924
+ expect(prompts[0]).toContain('User: Previous message');
925
+ expect(prompts[0]).toContain('Assistant: Previous response');
926
+ expect(prompts[0]).toContain('User: New message');
927
+ expect(chunks.some((c) => c.type === 'text')).toBe(true);
928
+ });
929
+ });
930
+
931
+ describe('session restoration', () => {
932
+ it('should use restored session ID on subsequent queries', async () => {
933
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
934
+
935
+ // Simulate restoring a session ID from storage
936
+ service.setSessionId('restored-session-id');
937
+
938
+ setMockMessages([
939
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Resumed!' }] } },
940
+ { type: 'result' },
941
+ ]);
942
+
943
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
944
+ for await (const _chunk of service.query('continue')) {
945
+ // drain
946
+ }
947
+
948
+ const options = getLastOptions();
949
+ expect(options?.resume).toBe('restored-session-id');
950
+ });
951
+
952
+ it('should capture new session ID from SDK', async () => {
953
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
954
+
955
+ setMockMessages([
956
+ { type: 'system', subtype: 'init', session_id: 'new-captured-session' },
957
+ { type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
958
+ { type: 'result' },
959
+ ]);
960
+
961
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
962
+ for await (const _chunk of service.query('hello')) {
963
+ // drain
964
+ }
965
+
966
+ expect(service.getSessionId()).toBe('new-captured-session');
967
+ });
968
+ });
969
+
970
+ describe('extended thinking', () => {
971
+ beforeEach(() => {
972
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
973
+ });
974
+
975
+ it('should transform thinking blocks from assistant messages', async () => {
976
+ setMockMessages([
977
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
978
+ {
979
+ type: 'assistant',
980
+ message: {
981
+ content: [
982
+ { type: 'thinking', thinking: 'Let me analyze this problem...' },
983
+ { type: 'text', text: 'Here is my answer.' },
984
+ ],
985
+ },
986
+ },
987
+ { type: 'result' },
988
+ ]);
989
+
990
+ const chunks: any[] = [];
991
+ for await (const chunk of service.query('think about this')) {
992
+ chunks.push(chunk);
993
+ }
994
+
995
+ const thinkingChunk = chunks.find((c) => c.type === 'thinking');
996
+ expect(thinkingChunk).toBeDefined();
997
+ expect(thinkingChunk?.content).toBe('Let me analyze this problem...');
998
+
999
+ const textChunk = chunks.find((c) => c.type === 'text');
1000
+ expect(textChunk).toBeDefined();
1001
+ expect(textChunk?.content).toBe('Here is my answer.');
1002
+ });
1003
+
1004
+ it('should transform thinking deltas from stream events', async () => {
1005
+ setMockMessages([
1006
+ { type: 'system', subtype: 'init', session_id: 'test-session' },
1007
+ {
1008
+ type: 'stream_event',
1009
+ event: {
1010
+ type: 'content_block_start',
1011
+ content_block: { type: 'thinking', thinking: 'Starting thought...' },
1012
+ },
1013
+ },
1014
+ {
1015
+ type: 'stream_event',
1016
+ event: {
1017
+ type: 'content_block_delta',
1018
+ delta: { type: 'thinking_delta', thinking: ' continuing thought...' },
1019
+ },
1020
+ },
1021
+ { type: 'result' },
1022
+ ]);
1023
+
1024
+ const chunks: any[] = [];
1025
+ for await (const chunk of service.query('think')) {
1026
+ chunks.push(chunk);
1027
+ }
1028
+
1029
+ const thinkingChunks = chunks.filter((c) => c.type === 'thinking');
1030
+ expect(thinkingChunks.length).toBeGreaterThanOrEqual(1);
1031
+ expect(thinkingChunks.some((c) => c.content.includes('thought'))).toBe(true);
1032
+ });
1033
+ });
1034
+
1035
+ describe('permission utility functions', () => {
1036
+ it('should generate correct action patterns for different tools', () => {
1037
+ expect(getActionPattern('Bash', { command: 'git status' })).toBe('git status');
1038
+ expect(getActionPattern('Read', { file_path: '/test/file.md' })).toBe('/test/file.md');
1039
+ expect(getActionPattern('Write', { file_path: '/test/output.md' })).toBe('/test/output.md');
1040
+ expect(getActionPattern('Edit', { file_path: '/test/edit.md' })).toBe('/test/edit.md');
1041
+ expect(getActionPattern('Glob', { pattern: '**/*.md' })).toBe('**/*.md');
1042
+ expect(getActionPattern('Grep', { pattern: 'TODO' })).toBe('TODO');
1043
+ });
1044
+
1045
+ it('should generate correct action descriptions', () => {
1046
+ expect(getActionDescription('Bash', { command: 'git status' })).toBe('Run command: git status');
1047
+ expect(getActionDescription('Read', { file_path: '/test/file.md' })).toBe('Read file: /test/file.md');
1048
+ expect(getActionDescription('Write', { file_path: '/test/output.md' })).toBe('Write to file: /test/output.md');
1049
+ expect(getActionDescription('Edit', { file_path: '/test/edit.md' })).toBe('Edit file: /test/edit.md');
1050
+ expect(getActionDescription('Glob', { pattern: '**/*.md' })).toBe('Search files matching: **/*.md');
1051
+ expect(getActionDescription('Grep', { pattern: 'TODO' })).toBe('Search content matching: TODO');
1052
+ });
1053
+ });
1054
+
1055
+ // Note: safe mode approvals tests removed - createUnifiedToolCallback was part of plan mode
1056
+
1057
+ describe('session expiration recovery', () => {
1058
+ beforeEach(() => {
1059
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
1060
+ });
1061
+
1062
+ it('should detect session expired errors', () => {
1063
+ // Now test the standalone function directly
1064
+ expect(isSessionExpiredError(new Error('Session expired'))).toBe(true);
1065
+ expect(isSessionExpiredError(new Error('session not found'))).toBe(true);
1066
+ expect(isSessionExpiredError(new Error('invalid session'))).toBe(true);
1067
+ expect(isSessionExpiredError(new Error('Resume failed'))).toBe(true);
1068
+ });
1069
+
1070
+ it('should not detect non-session errors as session errors', () => {
1071
+ // Now test the standalone function directly
1072
+ expect(isSessionExpiredError(new Error('Network error'))).toBe(false);
1073
+ expect(isSessionExpiredError(new Error('Rate limited'))).toBe(false);
1074
+ expect(isSessionExpiredError(new Error('Invalid API key'))).toBe(false);
1075
+ });
1076
+
1077
+ it('should build context from conversation history', () => {
1078
+ const messages = [
1079
+ { id: 'msg-1', role: 'user' as const, content: 'Hello', timestamp: Date.now() },
1080
+ { id: 'msg-2', role: 'assistant' as const, content: 'Hi there!', timestamp: Date.now() },
1081
+ { id: 'msg-3', role: 'user' as const, content: 'How are you?', timestamp: Date.now() },
1082
+ ];
1083
+
1084
+ // Now test the standalone function directly
1085
+ const context = buildContextFromHistory(messages);
1086
+
1087
+ expect(context).toContain('User: Hello');
1088
+ expect(context).toContain('Assistant: Hi there!');
1089
+ expect(context).toContain('User: How are you?');
1090
+ });
1091
+
1092
+ it('should include tool call info with input (status only for success)', () => {
1093
+ const messages = [
1094
+ { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() },
1095
+ {
1096
+ id: 'msg-2',
1097
+ role: 'assistant' as const,
1098
+ content: 'Reading file...',
1099
+ timestamp: Date.now(),
1100
+ toolCalls: [
1101
+ { id: 'tool-1', name: 'Read', input: { file_path: '/test.md' }, status: 'completed' as const, result: 'File contents' },
1102
+ ],
1103
+ },
1104
+ ];
1105
+
1106
+ // Now test the standalone function directly
1107
+ const context = buildContextFromHistory(messages);
1108
+
1109
+ // Successful tools show input but no result (Claude can re-read if needed)
1110
+ expect(context).toContain('[Tool Read input: file_path=/test.md status=completed]');
1111
+ expect(context).not.toContain('File contents');
1112
+ });
1113
+
1114
+ it('should include error messages for failed tool calls with input', () => {
1115
+ const messages = [
1116
+ { id: 'msg-1', role: 'user' as const, content: 'Read a file', timestamp: Date.now() },
1117
+ {
1118
+ id: 'msg-2',
1119
+ role: 'assistant' as const,
1120
+ content: 'Reading file...',
1121
+ timestamp: Date.now(),
1122
+ toolCalls: [
1123
+ { id: 'tool-1', name: 'Read', input: { file_path: '/missing.md' }, status: 'error' as const, result: 'File not found' },
1124
+ ],
1125
+ },
1126
+ ];
1127
+
1128
+ const context = buildContextFromHistory(messages);
1129
+
1130
+ // Failed tools include input AND error message so Claude knows what went wrong
1131
+ expect(context).toContain('[Tool Read input: file_path=/missing.md status=error] error: File not found');
1132
+ });
1133
+
1134
+ it('should include current note in rebuilt history', () => {
1135
+ const messages = [
1136
+ { id: 'msg-1', role: 'user' as const, content: 'Edit this file', timestamp: Date.now(), currentNote: 'notes/file.md' },
1137
+ ];
1138
+
1139
+ // Now test the standalone function directly
1140
+ const context = buildContextFromHistory(messages);
1141
+
1142
+ expect(context).toContain('<current_note>');
1143
+ expect(context).toContain('notes/file.md');
1144
+ });
1145
+
1146
+ it('should truncate long tool results', () => {
1147
+ const longResult = 'x'.repeat(1000);
1148
+ // Now test the standalone function directly
1149
+ const truncated = truncateToolResult(longResult, 100);
1150
+
1151
+ expect(truncated.length).toBeLessThan(longResult.length);
1152
+ expect(truncated).toContain('(truncated)');
1153
+ });
1154
+
1155
+ it('should not truncate short tool results', () => {
1156
+ const shortResult = 'Short result';
1157
+ // Now test the standalone function directly
1158
+ const result = truncateToolResult(shortResult, 100);
1159
+
1160
+ expect(result).toBe(shortResult);
1161
+ });
1162
+ });
1163
+
1164
+ describe('session expiration recovery flow', () => {
1165
+ beforeEach(() => {
1166
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
1167
+ mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude');
1168
+ });
1169
+
1170
+ it('should rebuild history and retry without resume on session expiration', async () => {
1171
+ service.setSessionId('stale-session');
1172
+ const prompts: string[] = [];
1173
+
1174
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) {
1175
+ prompts.push(prompt);
1176
+ if (prompts.length === 1) {
1177
+ throw new Error('Session expired');
1178
+ }
1179
+ yield { type: 'text', content: 'Recovered' };
1180
+ }) as any);
1181
+
1182
+ const history = [
1183
+ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() },
1184
+ {
1185
+ id: 'msg-2',
1186
+ role: 'assistant' as const,
1187
+ content: 'Answer',
1188
+ timestamp: Date.now(),
1189
+ toolCalls: [
1190
+ { id: 'tool-1', name: 'Read', input: { file_path: '/test/vault/path/file.md' }, status: 'completed' as const, result: 'file content' },
1191
+ ],
1192
+ },
1193
+ { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now(), currentNote: 'note.md' },
1194
+ ];
1195
+
1196
+ const chunks: any[] = [];
1197
+ for await (const chunk of service.query('Follow up', undefined, history, { forceColdStart: true })) {
1198
+ chunks.push(chunk);
1199
+ }
1200
+
1201
+ expect(prompts[0]).toBe('Follow up');
1202
+ expect(prompts[1]).toContain('User: First question');
1203
+ expect(prompts[1]).toContain('Assistant: Answer');
1204
+ expect(prompts[1]).toContain('<current_note>');
1205
+ expect(prompts[1]).toContain('note.md');
1206
+ expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true);
1207
+ expect(service.getSessionId()).toBeNull();
1208
+ });
1209
+
1210
+ it('should rebuild history when persistent query throws session expired', async () => {
1211
+ service.setSessionId('stale-session');
1212
+ const prompts: string[] = [];
1213
+
1214
+ // eslint-disable-next-line require-yield
1215
+ jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () {
1216
+ throw new Error('Session expired');
1217
+ }) as any);
1218
+
1219
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (prompt: string) {
1220
+ prompts.push(prompt);
1221
+ yield { type: 'text', content: 'Recovered' };
1222
+ }) as any);
1223
+
1224
+ const history = [
1225
+ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() },
1226
+ { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() },
1227
+ { id: 'msg-3', role: 'user' as const, content: 'Follow up', timestamp: Date.now() },
1228
+ ];
1229
+
1230
+ const chunks: any[] = [];
1231
+ for await (const chunk of service.query('Follow up', undefined, history)) {
1232
+ chunks.push(chunk);
1233
+ }
1234
+
1235
+ expect(prompts).toHaveLength(1);
1236
+ expect(prompts[0]).toContain('User: First question');
1237
+ expect(prompts[0]).toContain('Assistant: Answer');
1238
+ expect(prompts[0]).toContain('User: Follow up');
1239
+ expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true);
1240
+ expect(service.getSessionId()).toBeNull();
1241
+ });
1242
+
1243
+ it('should preserve current message images when session expired during cold-start', async () => {
1244
+ service.setSessionId('stale-session');
1245
+ let capturedImages: any[] | undefined;
1246
+
1247
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (
1248
+ _prompt: string,
1249
+ _vaultPath: string,
1250
+ _cliPath: string,
1251
+ images: any[] | undefined
1252
+ ) {
1253
+ if (!capturedImages) {
1254
+ // First call throws session expired
1255
+ capturedImages = images;
1256
+ throw new Error('Session expired');
1257
+ }
1258
+ // Second call (retry) should have the images
1259
+ capturedImages = images;
1260
+ yield { type: 'text', content: 'Recovered' };
1261
+ }) as any);
1262
+
1263
+ const history = [
1264
+ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() },
1265
+ { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() },
1266
+ ];
1267
+
1268
+ const currentImages = [
1269
+ { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const },
1270
+ ];
1271
+
1272
+ const chunks: any[] = [];
1273
+ for await (const chunk of service.query('Follow up with image', currentImages, history, { forceColdStart: true })) {
1274
+ chunks.push(chunk);
1275
+ }
1276
+
1277
+ expect(capturedImages).toBeDefined();
1278
+ expect(capturedImages).toHaveLength(1);
1279
+ expect(capturedImages![0].id).toBe('img-1');
1280
+ expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true);
1281
+ });
1282
+
1283
+ it('should preserve current message images when session expired during persistent query', async () => {
1284
+ service.setSessionId('stale-session');
1285
+ let capturedImages: any[] | undefined;
1286
+
1287
+ // eslint-disable-next-line require-yield
1288
+ jest.spyOn(service as any, 'queryViaPersistent').mockImplementation((async function* () {
1289
+ throw new Error('Session expired');
1290
+ }) as any);
1291
+
1292
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation((async function* (
1293
+ _prompt: string,
1294
+ _vaultPath: string,
1295
+ _cliPath: string,
1296
+ images: any[] | undefined
1297
+ ) {
1298
+ capturedImages = images;
1299
+ yield { type: 'text', content: 'Recovered' };
1300
+ }) as any);
1301
+
1302
+ const history = [
1303
+ { id: 'msg-1', role: 'user' as const, content: 'First question', timestamp: Date.now() },
1304
+ { id: 'msg-2', role: 'assistant' as const, content: 'Answer', timestamp: Date.now() },
1305
+ ];
1306
+
1307
+ const currentImages = [
1308
+ { id: 'img-1', name: 'test.png', mediaType: 'image/png' as const, data: 'base64data', size: 100, source: 'file' as const },
1309
+ { id: 'img-2', name: 'test2.jpg', mediaType: 'image/jpeg' as const, data: 'base64data2', size: 200, source: 'paste' as const },
1310
+ ];
1311
+
1312
+ const chunks: any[] = [];
1313
+ for await (const chunk of service.query('Follow up with images', currentImages, history)) {
1314
+ chunks.push(chunk);
1315
+ }
1316
+
1317
+ expect(capturedImages).toBeDefined();
1318
+ expect(capturedImages).toHaveLength(2);
1319
+ expect(capturedImages![0].id).toBe('img-1');
1320
+ expect(capturedImages![1].id).toBe('img-2');
1321
+ expect(chunks.some((c) => c.type === 'text' && c.content === 'Recovered')).toBe(true);
1322
+ });
1323
+ });
1324
+
1325
+ describe('image prompt and hydration', () => {
1326
+ beforeEach(() => {
1327
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
1328
+ mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude');
1329
+ });
1330
+
1331
+ it('should return plain prompt when no valid images', () => {
1332
+ const prompt = (service as any).buildPromptWithImages('hello', []);
1333
+ expect(prompt).toBe('hello');
1334
+ });
1335
+
1336
+ it('should build async generator with image blocks', async () => {
1337
+ const images = [
1338
+ { id: 'img-1', name: 'a.png', mediaType: 'image/png', data: 'AAA', size: 3, source: 'file' },
1339
+ { id: 'img-2', name: 'b.png', mediaType: 'image/png', data: 'BBB', size: 3, source: 'file' },
1340
+ ];
1341
+
1342
+ const gen = (service as any).buildPromptWithImages('hi', images) as AsyncGenerator<any>;
1343
+ const messages: any[] = [];
1344
+ for await (const m of gen) messages.push(m);
1345
+
1346
+ expect(messages).toHaveLength(1);
1347
+ expect(messages[0].type).toBe('user');
1348
+ expect(messages[0].message.content[0].type).toBe('image');
1349
+ expect(messages[0].message.content[2].type).toBe('text');
1350
+ });
1351
+
1352
+ });
1353
+
1354
+ // QueryOptionsBuilder tests moved to tests/unit/core/agent/QueryOptionsBuilder.test.ts
1355
+
1356
+ describe('transformSDKMessage additional branches', () => {
1357
+ it('should transform tool_result blocks inside user content', () => {
1358
+ const sdkMessage: any = {
1359
+ type: 'user',
1360
+ message: {
1361
+ content: [
1362
+ { type: 'tool_result', tool_use_id: 'tool-1', content: 'out', is_error: true },
1363
+ ],
1364
+ },
1365
+ };
1366
+
1367
+ const chunks = Array.from(transformSDKMessage(sdkMessage));
1368
+ expect(chunks[0]).toEqual(expect.objectContaining({ type: 'tool_result', id: 'tool-1', isError: true }));
1369
+ });
1370
+
1371
+ it('should transform stream_event tool_use and text blocks', () => {
1372
+ const toolUseMsg: any = {
1373
+ type: 'stream_event',
1374
+ event: { type: 'content_block_start', content_block: { type: 'tool_use', id: 't1', name: 'Read', input: {} } },
1375
+ };
1376
+ const textStartMsg: any = {
1377
+ type: 'stream_event',
1378
+ event: { type: 'content_block_start', content_block: { type: 'text', text: 'hello' } },
1379
+ };
1380
+ const textDeltaMsg: any = {
1381
+ type: 'stream_event',
1382
+ event: { type: 'content_block_delta', delta: { type: 'text_delta', text: ' world' } },
1383
+ };
1384
+
1385
+ const toolChunks = Array.from(transformSDKMessage(toolUseMsg));
1386
+ const textChunks = [
1387
+ ...Array.from(transformSDKMessage(textStartMsg)),
1388
+ ...Array.from(transformSDKMessage(textDeltaMsg)),
1389
+ ];
1390
+
1391
+ expect(toolChunks[0]).toEqual(expect.objectContaining({ type: 'tool_use', id: 't1', name: 'Read' }));
1392
+ expect(textChunks.map((c: any) => c.content).join('')).toBe('hello world');
1393
+ });
1394
+
1395
+ // Note: Tests for result message usage were removed because transformSDKMessage
1396
+ // now extracts usage from assistant messages (not result messages) to avoid
1397
+ // inaccurate spikes from aggregated subagent tokens
1398
+
1399
+ it('should emit no chunks for result messages (usage now comes from assistant messages)', () => {
1400
+ const sdkMessage: any = {
1401
+ type: 'result',
1402
+ modelUsage: {
1403
+ 'model-a': {
1404
+ inputTokens: 10,
1405
+ cacheCreationInputTokens: 0,
1406
+ cacheReadInputTokens: 0,
1407
+ contextWindow: 0,
1408
+ },
1409
+ },
1410
+ };
1411
+
1412
+ const chunks = Array.from(transformSDKMessage(sdkMessage));
1413
+ expect(chunks).toHaveLength(0);
1414
+ });
1415
+ });
1416
+
1417
+ describe('remaining business branches', () => {
1418
+ beforeEach(() => {
1419
+ (fs.existsSync as jest.Mock).mockReturnValue(true);
1420
+ mockPlugin.getResolvedProviderCliPath.mockReturnValue('/mock/claude');
1421
+ });
1422
+
1423
+ it('yields error when session retry also fails', async () => {
1424
+ // eslint-disable-next-line require-yield
1425
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () {
1426
+ throw new Error('Session expired');
1427
+ });
1428
+
1429
+ const history = [
1430
+ { id: 'u1', role: 'user' as const, content: 'Hi', timestamp: 0 },
1431
+ ];
1432
+
1433
+ const chunks: any[] = [];
1434
+ for await (const c of service.query('Hi', undefined, history, { forceColdStart: true })) chunks.push(c);
1435
+
1436
+ const errorChunk = chunks.find((c) => c.type === 'error');
1437
+ expect(errorChunk).toBeDefined();
1438
+ expect(errorChunk.content).toContain('Session expired');
1439
+ });
1440
+
1441
+ it('yields error for non-session failures', async () => {
1442
+ // eslint-disable-next-line require-yield
1443
+ jest.spyOn(service as any, 'queryViaSDK').mockImplementation(async function* () {
1444
+ throw new Error('Network down');
1445
+ });
1446
+
1447
+ const chunks: any[] = [];
1448
+ for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c);
1449
+
1450
+ expect(chunks.some((c) => c.type === 'error' && c.content.includes('Network down'))).toBe(true);
1451
+ });
1452
+
1453
+ it('skips non-user messages and empty assistants in rebuilt context', () => {
1454
+ const messages: any[] = [
1455
+ { id: 'sys', role: 'system', content: 'ignore', timestamp: 0 },
1456
+ { id: 'a1', role: 'assistant', content: '', timestamp: 0 },
1457
+ { id: 'u1', role: 'user', content: 'Hello', timestamp: 0 },
1458
+ ];
1459
+
1460
+ // Now test the standalone function directly
1461
+ const context = buildContextFromHistory(messages);
1462
+ expect(context).toContain('User: Hello');
1463
+ expect(context).not.toContain('system');
1464
+ });
1465
+
1466
+ it('returns undefined when no user message exists', () => {
1467
+ // Now test the standalone function directly
1468
+ const last = getLastUserMessage([
1469
+ { id: 'a1', role: 'assistant' as const, content: 'Hi', timestamp: 0 },
1470
+ ]);
1471
+ expect(last).toBeUndefined();
1472
+ });
1473
+
1474
+ it('formats tool call without result', () => {
1475
+ // Now test the standalone function directly
1476
+ const line = formatToolCallForContext({ id: 't', name: 'Read', input: {}, status: 'completed' as const });
1477
+ expect(line).toBe('[Tool Read status=completed]');
1478
+ });
1479
+
1480
+ it('yields error when SDK query throws inside queryViaSDK', async () => {
1481
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1482
+ const sdk = require('@anthropic-ai/claude-agent-sdk');
1483
+ const spy = jest.spyOn(sdk, 'query').mockImplementation(() => { throw new Error('boom'); });
1484
+
1485
+ const chunks: any[] = [];
1486
+ for await (const c of service.query('Hi', undefined, undefined, { forceColdStart: true })) chunks.push(c);
1487
+
1488
+ expect(chunks.some((c) => c.type === 'error' && c.content.includes('boom'))).toBe(true);
1489
+ spy.mockRestore();
1490
+ });
1491
+
1492
+ // Note: 'allows pre-approved actions' test removed - createUnifiedToolCallback was part of plan mode
1493
+
1494
+ it('returns null for non-file tools in getPathFromToolInput', () => {
1495
+ expect(getPathFromToolInput('WebSearch', {})).toBeNull();
1496
+ });
1497
+
1498
+ it('does not treat Grep pattern as a path', () => {
1499
+ expect(getPathFromToolInput('Grep', { pattern: '/etc/passwd' })).toBeNull();
1500
+ expect(getPathFromToolInput('Grep', { pattern: 'TODO', path: 'notes' })).toBe('notes');
1501
+ });
1502
+
1503
+ it('covers NotebookEdit and default patterns/descriptions', () => {
1504
+ // Now test the standalone functions directly
1505
+ expect(getActionPattern('NotebookEdit', { notebook_path: 'nb.ipynb' })).toBe('nb.ipynb');
1506
+ expect(getActionPattern('Other', { foo: 'bar' })).toContain('foo');
1507
+ expect(getActionDescription('Other', { foo: 'bar' })).toContain('foo');
1508
+ });
1509
+
1510
+ });
1511
+
1512
+ describe('persistent query configuration detection', () => {
1513
+ it('detects system prompt changes requiring restart', async () => {
1514
+ // First query establishes baseline config
1515
+ const chunks1: any[] = [];
1516
+ for await (const c of service.query('first')) chunks1.push(c);
1517
+
1518
+ // Change system prompt which affects systemPromptKey
1519
+ mockPlugin.settings.systemPrompt = 'new custom prompt';
1520
+
1521
+ // Second query should detect the change
1522
+ const chunks2: any[] = [];
1523
+ for await (const c of service.query('second')) chunks2.push(c);
1524
+
1525
+ // If restart happened, the session would change
1526
+ // The service should have detected the configuration change
1527
+ expect(chunks2.some((c) => c.type === 'done')).toBe(true);
1528
+ });
1529
+
1530
+ });
1531
+
1532
+ describe('persistent query dynamic updates', () => {
1533
+ it('ignores legacy thinking budget changes', async () => {
1534
+ mockPlugin.settings.model = 'custom-model';
1535
+ mockPlugin.settings.thinkingBudget = 'off';
1536
+
1537
+ const chunks1: any[] = [];
1538
+ for await (const c of service.query('first')) chunks1.push(c);
1539
+
1540
+ const queryCountBefore = getQueryCallCount();
1541
+ mockPlugin.settings.thinkingBudget = 'high';
1542
+
1543
+ const chunks2: any[] = [];
1544
+ for await (const c of service.query('second')) chunks2.push(c);
1545
+
1546
+ const response = getLastResponse();
1547
+ expect(response?.setMaxThinkingTokens).not.toHaveBeenCalled();
1548
+ expect(getQueryCallCount()).toBe(queryCountBefore);
1549
+ });
1550
+
1551
+ it('uses effort levels instead of token budgets for built-in models', async () => {
1552
+ mockPlugin.settings.model = 'sonnet';
1553
+ mockPlugin.settings.thinkingBudget = 'off';
1554
+
1555
+ const chunks1: any[] = [];
1556
+ for await (const c of service.query('first')) chunks1.push(c);
1557
+
1558
+ // Change thinking budget — should be ignored for adaptive models
1559
+ mockPlugin.settings.thinkingBudget = 'high';
1560
+
1561
+ const chunks2: any[] = [];
1562
+ for await (const c of service.query('second')) chunks2.push(c);
1563
+
1564
+ const response = getLastResponse();
1565
+ expect(response?.setMaxThinkingTokens).not.toHaveBeenCalled();
1566
+ });
1567
+
1568
+ it('updates permission mode via setPermissionMode when going from YOLO to normal', async () => {
1569
+ // Start in YOLO mode
1570
+ mockPlugin.settings.permissionMode = 'yolo';
1571
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
1572
+
1573
+ const chunks1: any[] = [];
1574
+ for await (const c of service.query('first')) chunks1.push(c);
1575
+
1576
+ // Switch to normal mode
1577
+ mockPlugin.settings.permissionMode = 'normal';
1578
+
1579
+ const chunks2: any[] = [];
1580
+ for await (const c of service.query('second')) chunks2.push(c);
1581
+
1582
+ const response = getLastResponse();
1583
+ // Should call setPermissionMode for YOLO -> normal transition
1584
+ expect(response?.setPermissionMode).toHaveBeenCalledWith('acceptEdits');
1585
+ });
1586
+
1587
+ it('updates permission mode via setPermissionMode when going from normal to YOLO', async () => {
1588
+ // Start in normal mode
1589
+ mockPlugin.settings.permissionMode = 'normal';
1590
+ service = new ClaudianService(mockPlugin, createMockMcpManager());
1591
+
1592
+ const chunks1: any[] = [];
1593
+ for await (const c of service.query('first')) chunks1.push(c);
1594
+
1595
+ // Switch to YOLO mode
1596
+ mockPlugin.settings.permissionMode = 'yolo';
1597
+
1598
+ const chunks2: any[] = [];
1599
+ for await (const c of service.query('second')) chunks2.push(c);
1600
+
1601
+ const response = getLastResponse();
1602
+ // Should call setPermissionMode for normal -> YOLO transition (no restart needed)
1603
+ expect(response?.setPermissionMode).toHaveBeenCalledWith('bypassPermissions');
1604
+ });
1605
+
1606
+ it('updates effort level via applyFlagSettings without restarting', async () => {
1607
+ mockPlugin.settings.model = 'sonnet';
1608
+ mockPlugin.settings.effortLevel = 'high';
1609
+
1610
+ const chunks1: any[] = [];
1611
+ for await (const c of service.query('first')) chunks1.push(c);
1612
+
1613
+ const queryCountBefore = getQueryCallCount();
1614
+ mockPlugin.settings.effortLevel = 'max';
1615
+
1616
+ const chunks2: any[] = [];
1617
+ for await (const c of service.query('second')) chunks2.push(c);
1618
+
1619
+ const response = getLastResponse();
1620
+ expect(response?.applyFlagSettings).toHaveBeenCalledWith({ effortLevel: 'max' });
1621
+ expect(getQueryCallCount()).toBe(queryCountBefore);
1622
+ });
1623
+
1624
+ it('updates MCP servers on the active persistent query', async () => {
1625
+ const chunks1: any[] = [];
1626
+ for await (const c of service.query('first', undefined, undefined, {
1627
+ mcpMentions: new Set(['server1']),
1628
+ })) chunks1.push(c);
1629
+
1630
+ const response1 = getLastResponse();
1631
+ expect(response1?.setMcpServers).toHaveBeenCalled();
1632
+
1633
+ // Query with different MCP mentions
1634
+ const chunks2: any[] = [];
1635
+ for await (const c of service.query('second', undefined, undefined, {
1636
+ mcpMentions: new Set(['server2']),
1637
+ })) chunks2.push(c);
1638
+
1639
+ const response2 = getLastResponse();
1640
+ expect(response2?.setMcpServers).toHaveBeenCalled();
1641
+ });
1642
+
1643
+ it('reapplies query overrides after restart triggered by config change', async () => {
1644
+ const chunks1: any[] = [];
1645
+ for await (const c of service.query('first')) chunks1.push(c);
1646
+
1647
+ mockPlugin.settings.systemPrompt = 'restart-required';
1648
+
1649
+ const chunks2: any[] = [];
1650
+ for await (const c of service.query('second', undefined, undefined, {
1651
+ model: 'claude-opus-4-5',
1652
+ })) chunks2.push(c);
1653
+
1654
+ const response = getLastResponse();
1655
+ expect(response?.setModel).toHaveBeenCalledWith('claude-opus-4-5');
1656
+ });
1657
+
1658
+ it('falls back to cold-start when restart fails during dynamic updates', async () => {
1659
+ const chunks1: any[] = [];
1660
+ for await (const c of service.query('first')) chunks1.push(c);
1661
+ expect((service as any).persistentQuery).not.toBeNull();
1662
+
1663
+ // Force a config change that requires restart
1664
+ mockPlugin.settings.systemPrompt = 'restart-required';
1665
+
1666
+ // Allow query + applyDynamicUpdates, then fail restart due to missing CLI path
1667
+ mockPlugin.getResolvedProviderCliPath.mockReset();
1668
+ mockPlugin.getResolvedProviderCliPath
1669
+ .mockReturnValueOnce('/mock/claude')
1670
+ .mockReturnValueOnce('/mock/claude')
1671
+ .mockReturnValueOnce(null);
1672
+
1673
+ const callCountBeforeSecond = getQueryCallCount();
1674
+
1675
+ const chunks2: any[] = [];
1676
+ for await (const c of service.query('second')) chunks2.push(c);
1677
+
1678
+ expect(chunks2.some((c) => c.type === 'text')).toBe(true);
1679
+ expect(getQueryCallCount()).toBe(callCountBeforeSecond + 1);
1680
+ expect((service as any).persistentQuery).toBeNull();
1681
+ expect((service as any).shuttingDown).toBe(false);
1682
+ });
1683
+ });
1684
+
1685
+ describe('persistent query crash recovery', () => {
1686
+ it('prevents infinite crash recovery loops via crashRecoveryAttempted flag', async () => {
1687
+ // Access private state for testing
1688
+ const serviceAny = service as any;
1689
+
1690
+ // Simulate first crash recovery
1691
+ serviceAny.crashRecoveryAttempted = false;
1692
+ serviceAny.lastSentMessage = createTextUserMessage('test');
1693
+
1694
+ // After first crash, flag should be set
1695
+ serviceAny.crashRecoveryAttempted = true;
1696
+
1697
+ // Second crash should not attempt recovery
1698
+ const shouldResend = serviceAny.lastSentMessage && !serviceAny.crashRecoveryAttempted;
1699
+ expect(shouldResend).toBe(false);
1700
+ });
1701
+
1702
+ it('clears lastSentMessage on successful completion', async () => {
1703
+ const serviceAny = service as any;
1704
+
1705
+ // Before query, lastSentMessage should be null
1706
+ expect(serviceAny.lastSentMessage).toBeNull();
1707
+
1708
+ // Run a query
1709
+ const chunks: any[] = [];
1710
+ for await (const c of service.query('test')) chunks.push(c);
1711
+
1712
+ // After successful completion, lastSentMessage should be cleared
1713
+ expect(serviceAny.lastSentMessage).toBeNull();
1714
+ });
1715
+ });
1716
+
1717
+ // Note: 'persistent query deferred close', 'tool restriction with allowed tools list', and
1718
+ // 'persistent query permission mode transitions' tests removed - createUnifiedToolCallback/pendingCloseReason
1719
+ // were part of plan mode
1720
+
1721
+
1722
+
1723
+ describe('persistent query crash recovery behavior', () => {
1724
+ it('restarts persistent query after consumer error to prepare for next query', async () => {
1725
+ const serviceAny = service as any;
1726
+
1727
+ // Run a query to set up the persistent query
1728
+ const chunks: any[] = [];
1729
+ for await (const c of service.query('initial')) chunks.push(c);
1730
+
1731
+ // The persistent query should exist
1732
+ expect(serviceAny.persistentQuery).not.toBeNull();
1733
+
1734
+ // Crash recovery should restart the persistent query loop
1735
+ serviceAny.crashRecoveryAttempted = false;
1736
+ await service.ensureReady({ force: true });
1737
+
1738
+ // After restart, persistent query should still be ready
1739
+ expect(serviceAny.persistentQuery).not.toBeNull();
1740
+ });
1741
+
1742
+ it('re-enqueues pending message after crash recovery restart', async () => {
1743
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1744
+ const sdk = require('@anthropic-ai/claude-agent-sdk');
1745
+
1746
+ let callCount = 0;
1747
+ let firstPrompt: any = null;
1748
+ let secondPrompt: any = null;
1749
+ let resolveSecondPrompt: ((message: any) => void) | null = null;
1750
+
1751
+ const secondPromptPromise = new Promise<any>((resolve, reject) => {
1752
+ const timeout = setTimeout(() => {
1753
+ reject(new Error('Timed out waiting for crash recovery re-enqueue'));
1754
+ }, 2000);
1755
+ resolveSecondPrompt = (message: any) => {
1756
+ clearTimeout(timeout);
1757
+ resolve(message);
1758
+ };
1759
+ });
1760
+
1761
+ const spy = jest.spyOn(sdk, 'query').mockImplementation((params: any) => {
1762
+ const { prompt } = params;
1763
+ callCount += 1;
1764
+ const callIndex = callCount;
1765
+
1766
+ const generator = async function* () {
1767
+ if (prompt && typeof prompt[Symbol.asyncIterator] === 'function') {
1768
+ for await (const message of prompt) {
1769
+ if (callIndex === 1) {
1770
+ firstPrompt = message;
1771
+ throw new Error('boom');
1772
+ }
1773
+ secondPrompt = message;
1774
+ if (resolveSecondPrompt) resolveSecondPrompt(message);
1775
+ yield { type: 'system', subtype: 'init', session_id: 'test-session-123' };
1776
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } };
1777
+ yield { type: 'result', result: 'completed' };
1778
+ }
1779
+ return;
1780
+ }
1781
+
1782
+ if (callIndex === 1) {
1783
+ firstPrompt = prompt;
1784
+ throw new Error('boom');
1785
+ }
1786
+ secondPrompt = prompt;
1787
+ if (resolveSecondPrompt) resolveSecondPrompt(prompt);
1788
+ yield { type: 'system', subtype: 'init', session_id: 'test-session-123' };
1789
+ yield { type: 'assistant', message: { content: [{ type: 'text', text: 'Recovered' }] } };
1790
+ yield { type: 'result', result: 'completed' };
1791
+ };
1792
+
1793
+ const gen = generator() as AsyncGenerator<any> & {
1794
+ interrupt: jest.Mock;
1795
+ setModel: jest.Mock;
1796
+ setMaxThinkingTokens: jest.Mock;
1797
+ setPermissionMode: jest.Mock;
1798
+ setMcpServers: jest.Mock;
1799
+ };
1800
+ gen.interrupt = jest.fn().mockResolvedValue(undefined);
1801
+ gen.setModel = jest.fn().mockResolvedValue(undefined);
1802
+ gen.setMaxThinkingTokens = jest.fn().mockResolvedValue(undefined);
1803
+ gen.setPermissionMode = jest.fn().mockResolvedValue(undefined);
1804
+ gen.setMcpServers = jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} });
1805
+ return gen;
1806
+ });
1807
+
1808
+ try {
1809
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
1810
+ for await (const _chunk of service.query('initial')) {
1811
+ // drain
1812
+ }
1813
+
1814
+ await secondPromptPromise;
1815
+
1816
+ expect(callCount).toBeGreaterThanOrEqual(2);
1817
+ expect(firstPrompt).not.toBeNull();
1818
+ expect(secondPrompt).not.toBeNull();
1819
+ expect(secondPrompt.message?.content).toEqual(firstPrompt.message?.content);
1820
+ } finally {
1821
+ spy.mockRestore();
1822
+ }
1823
+ });
1824
+
1825
+ it('only attempts crash recovery once via crashRecoveryAttempted flag', async () => {
1826
+ const serviceAny = service as any;
1827
+
1828
+ // Run a query to set up the persistent query
1829
+ const chunks: any[] = [];
1830
+ for await (const c of service.query('initial')) chunks.push(c);
1831
+
1832
+ // First crash - should attempt recovery
1833
+ expect(serviceAny.crashRecoveryAttempted).toBe(false);
1834
+ const shouldAttemptFirst = !serviceAny.crashRecoveryAttempted;
1835
+ expect(shouldAttemptFirst).toBe(true);
1836
+
1837
+ // After first crash, flag is set
1838
+ serviceAny.crashRecoveryAttempted = true;
1839
+
1840
+ // Second crash - should not attempt recovery
1841
+ const shouldAttemptSecond = !serviceAny.crashRecoveryAttempted;
1842
+ expect(shouldAttemptSecond).toBe(false);
1843
+ });
1844
+ });
1845
+ });