@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,2534 @@
1
+ import '@/providers';
2
+
3
+ import { createMockEl } from '@test/helpers/mockElement';
4
+
5
+ import { ProviderSettingsCoordinator } from '@/core/providers/ProviderSettingsCoordinator';
6
+ import {
7
+ TOOL_AGENT_OUTPUT,
8
+ TOOL_APPLY_PATCH,
9
+ TOOL_SPAWN_AGENT,
10
+ TOOL_TASK,
11
+ TOOL_TODO_WRITE,
12
+ TOOL_WAIT_AGENT,
13
+ } from '@/core/tools/toolNames';
14
+ import type { ChatMessage } from '@/core/types';
15
+ import { StreamController, type StreamControllerDeps } from '@/features/chat/controllers/StreamController';
16
+ import { ChatState } from '@/features/chat/state/ChatState';
17
+ import { DEFAULT_CODEX_PRIMARY_MODEL } from '@/providers/codex/types/models';
18
+
19
+ jest.mock('@/core/tools/todo', () => ({
20
+ parseTodoInput: jest.fn(),
21
+ }));
22
+
23
+ jest.mock('@/core/tools/toolInput', () => ({
24
+ extractResolvedAnswers: jest.fn().mockReturnValue(undefined),
25
+ extractResolvedAnswersFromResultText: jest.fn().mockReturnValue(undefined),
26
+ }));
27
+
28
+ jest.mock('@/features/chat/rendering/SubagentRenderer', () => ({
29
+ createSubagentBlock: jest.fn().mockReturnValue({
30
+ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] },
31
+ labelEl: { setText: jest.fn() },
32
+ }),
33
+ finalizeSubagentBlock: jest.fn(),
34
+ }));
35
+
36
+ jest.mock('@/features/chat/rendering/ThinkingBlockRenderer', () => ({
37
+ appendThinkingContent: jest.fn(),
38
+ createThinkingBlock: jest.fn().mockImplementation(() => ({
39
+ container: {},
40
+ contentEl: {},
41
+ content: '',
42
+ startTime: Date.now(),
43
+ })),
44
+ finalizeThinkingBlock: jest.fn().mockReturnValue(0),
45
+ }));
46
+
47
+ jest.mock('@/features/chat/rendering/ToolCallRenderer', () => ({
48
+ getToolName: jest.fn().mockReturnValue('Read'),
49
+ getToolSummary: jest.fn().mockReturnValue('file.md'),
50
+ isBlockedToolResult: jest.fn().mockReturnValue(false),
51
+ renderToolCall: jest.fn(),
52
+ updateToolCallResult: jest.fn(),
53
+ }));
54
+
55
+ jest.mock('@/features/chat/rendering/WriteEditRenderer', () => ({
56
+ createWriteEditBlock: jest.fn().mockReturnValue({}),
57
+ finalizeWriteEditBlock: jest.fn(),
58
+ updateWriteEditWithDiff: jest.fn(),
59
+ }));
60
+
61
+ jest.mock('@/utils/path', () => ({
62
+ getVaultPath: jest.fn().mockReturnValue('/test/vault'),
63
+ }));
64
+
65
+ const originalWindow = (globalThis as { window?: Window }).window;
66
+
67
+ function installTestWindow(): void {
68
+ const testWindow = {
69
+ requestAnimationFrame: (callback: FrameRequestCallback): number =>
70
+ globalThis.setTimeout(() => callback(performance.now()), 16) as unknown as number,
71
+ cancelAnimationFrame: (handle: number): void => {
72
+ globalThis.clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
73
+ },
74
+ setTimeout: (callback: () => void, timeout: number): number =>
75
+ globalThis.setTimeout(callback, timeout) as unknown as number,
76
+ clearTimeout: (handle: number): void => {
77
+ globalThis.clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
78
+ },
79
+ setInterval: (callback: () => void, timeout: number): number =>
80
+ globalThis.setInterval(callback, timeout) as unknown as number,
81
+ clearInterval: (handle: number): void => {
82
+ globalThis.clearInterval(handle as unknown as ReturnType<typeof setInterval>);
83
+ },
84
+ } as Window;
85
+
86
+ Object.defineProperty(globalThis, 'window', {
87
+ value: testWindow,
88
+ configurable: true,
89
+ });
90
+ }
91
+
92
+ function restoreTestWindow(): void {
93
+ if (originalWindow === undefined) {
94
+ delete (globalThis as { window?: Window }).window;
95
+ return;
96
+ }
97
+
98
+ Object.defineProperty(globalThis, 'window', {
99
+ value: originalWindow,
100
+ configurable: true,
101
+ });
102
+ }
103
+
104
+ function createMockDeps(): StreamControllerDeps {
105
+ const state = new ChatState();
106
+ const messagesEl = createMockEl();
107
+ const agentService = {
108
+ getSessionId: jest.fn().mockReturnValue('session-1'),
109
+ loadSubagentToolCalls: jest.fn().mockResolvedValue([]),
110
+ loadSubagentFinalResult: jest.fn().mockResolvedValue(null),
111
+ getCapabilities: jest.fn().mockReturnValue({
112
+ providerId: 'claude',
113
+ supportsPlanMode: true,
114
+ planPathPrefix: '/.claude/plans/',
115
+ }),
116
+ };
117
+ const fileContextManager = {
118
+ markFileBeingEdited: jest.fn(),
119
+ trackEditedFile: jest.fn(),
120
+ getAttachedFiles: jest.fn().mockReturnValue(new Set()),
121
+ hasFilesChanged: jest.fn().mockReturnValue(false),
122
+ };
123
+
124
+ return {
125
+ plugin: {
126
+ settings: {
127
+ permissionMode: 'yolo',
128
+ },
129
+ app: {
130
+ vault: {
131
+ adapter: {
132
+ basePath: '/test/vault',
133
+ },
134
+ },
135
+ },
136
+ } as any,
137
+ state,
138
+ renderer: {
139
+ renderContent: jest.fn(),
140
+ addTextCopyButton: jest.fn(),
141
+ } as any,
142
+ subagentManager: {
143
+ isAsyncTask: jest.fn().mockReturnValue(false),
144
+ isPendingAsyncTask: jest.fn().mockReturnValue(false),
145
+ isLinkedAgentOutputTool: jest.fn().mockReturnValue(false),
146
+ handleAgentOutputToolResult: jest.fn().mockReturnValue(undefined),
147
+ handleAgentOutputToolUse: jest.fn(),
148
+ handleAsyncSubagentResult: jest.fn().mockReturnValue(undefined),
149
+ handleTaskToolUse: jest.fn().mockReturnValue({ action: 'buffered' }),
150
+ handleTaskToolResult: jest.fn(),
151
+ refreshAsyncSubagent: jest.fn(),
152
+ hasPendingTask: jest.fn().mockReturnValue(false),
153
+ renderPendingTask: jest.fn().mockReturnValue(null),
154
+ renderPendingTaskFromTaskResult: jest.fn().mockReturnValue(null),
155
+ getSyncSubagent: jest.fn().mockReturnValue(undefined),
156
+ addSyncToolCall: jest.fn(),
157
+ updateSyncToolResult: jest.fn(),
158
+ finalizeSyncSubagent: jest.fn().mockReturnValue(null),
159
+ resetStreamingState: jest.fn(),
160
+ resetSpawnedCount: jest.fn(),
161
+ subagentsSpawnedThisStream: 0,
162
+ } as any,
163
+ getMessagesEl: () => messagesEl,
164
+ getFileContextManager: () => fileContextManager as any,
165
+ updateQueueIndicator: jest.fn(),
166
+ getAgentService: () => agentService as any,
167
+ };
168
+ }
169
+
170
+ function createTestMessage(): ChatMessage {
171
+ return {
172
+ id: 'assistant-1',
173
+ role: 'assistant',
174
+ content: '',
175
+ timestamp: Date.now(),
176
+ toolCalls: [],
177
+ contentBlocks: [],
178
+ };
179
+ }
180
+
181
+ function createMockUsage(overrides: Record<string, any> = {}) {
182
+ return {
183
+ model: 'model-a',
184
+ inputTokens: 10,
185
+ cacheCreationInputTokens: 0,
186
+ cacheReadInputTokens: 0,
187
+ contextWindow: 100,
188
+ contextTokens: 10,
189
+ percentage: 10,
190
+ ...overrides,
191
+ };
192
+ }
193
+
194
+ describe('StreamController - Text Content', () => {
195
+ let controller: StreamController;
196
+ let deps: StreamControllerDeps;
197
+
198
+ beforeEach(() => {
199
+ jest.clearAllMocks();
200
+ jest.useFakeTimers();
201
+ installTestWindow();
202
+ deps = createMockDeps();
203
+ controller = new StreamController(deps);
204
+ deps.state.currentContentEl = createMockEl();
205
+ });
206
+
207
+ afterEach(() => {
208
+ // Clean up any timers set by ChatState
209
+ deps.state.resetStreamingState();
210
+ restoreTestWindow();
211
+ jest.useRealTimers();
212
+ });
213
+
214
+ describe('Text streaming', () => {
215
+ it('should append text content to message', async () => {
216
+ const msg = createTestMessage();
217
+
218
+ deps.state.currentTextEl = createMockEl();
219
+
220
+ await controller.handleStreamChunk({ type: 'text', content: 'Hello ' }, msg);
221
+ await controller.handleStreamChunk({ type: 'text', content: 'World' }, msg);
222
+
223
+ expect(msg.content).toBe('Hello World');
224
+ });
225
+
226
+ it('should accumulate text across multiple chunks', async () => {
227
+ const msg = createTestMessage();
228
+ deps.state.currentTextEl = createMockEl();
229
+
230
+ const chunks = ['This ', 'is ', 'a ', 'test.'];
231
+ for (const chunk of chunks) {
232
+ await controller.handleStreamChunk({ type: 'text', content: chunk }, msg);
233
+ }
234
+
235
+ expect(msg.content).toBe('This is a test.');
236
+ });
237
+
238
+ it('should coalesce text renders until the next animation frame', async () => {
239
+ deps.state.currentTextEl = createMockEl();
240
+
241
+ await controller.appendText('Hello ');
242
+ await controller.appendText('World');
243
+
244
+ expect(deps.renderer.renderContent).not.toHaveBeenCalled();
245
+
246
+ jest.advanceTimersByTime(16);
247
+ await Promise.resolve();
248
+
249
+ expect(deps.renderer.renderContent).toHaveBeenCalledTimes(1);
250
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
251
+ deps.state.currentTextEl,
252
+ 'Hello World'
253
+ );
254
+ });
255
+
256
+ it('should defer math rendering during live text renders', async () => {
257
+ deps.state.currentTextEl = createMockEl();
258
+
259
+ await controller.appendText('Euler: $e^{i\\pi} + 1 = 0$');
260
+
261
+ jest.advanceTimersByTime(16);
262
+ await Promise.resolve();
263
+
264
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
265
+ deps.state.currentTextEl,
266
+ 'Euler: $e^{i\\pi} + 1 = 0$',
267
+ { deferMath: true }
268
+ );
269
+ });
270
+
271
+ it('should honor disabled deferred math rendering setting during live text renders', async () => {
272
+ (deps.plugin.settings as any).deferMathRenderingDuringStreaming = false;
273
+ deps.state.currentTextEl = createMockEl();
274
+
275
+ await controller.appendText('Euler: $e^{i\\pi} + 1 = 0$');
276
+
277
+ jest.advanceTimersByTime(16);
278
+ await Promise.resolve();
279
+
280
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
281
+ deps.state.currentTextEl,
282
+ 'Euler: $e^{i\\pi} + 1 = 0$'
283
+ );
284
+ });
285
+
286
+ it('should flush a pending text render before finalizing text', async () => {
287
+ const msg = createTestMessage();
288
+
289
+ await controller.appendText('Hello');
290
+ await controller.finalizeCurrentTextBlock(msg);
291
+
292
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
293
+ expect.anything(),
294
+ 'Hello'
295
+ );
296
+ expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith(
297
+ expect.anything(),
298
+ 'Hello'
299
+ );
300
+ expect(msg.contentBlocks).toContainEqual({
301
+ type: 'text',
302
+ content: 'Hello',
303
+ });
304
+ });
305
+
306
+ it('should render original math once when finalizing a deferred text block', async () => {
307
+ const msg = createTestMessage();
308
+
309
+ await controller.appendText('Final $x^2$');
310
+ await controller.finalizeCurrentTextBlock(msg);
311
+
312
+ expect(deps.renderer.renderContent).toHaveBeenNthCalledWith(
313
+ 1,
314
+ expect.anything(),
315
+ 'Final $x^2$',
316
+ { deferMath: true }
317
+ );
318
+ expect(deps.renderer.renderContent).toHaveBeenNthCalledWith(
319
+ 2,
320
+ expect.anything(),
321
+ 'Final $x^2$'
322
+ );
323
+ expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith(
324
+ expect.anything(),
325
+ 'Final $x^2$'
326
+ );
327
+ });
328
+ });
329
+
330
+ describe('Text block finalization', () => {
331
+ it('should add copy button when finalizing text block with content', async () => {
332
+ const msg = createTestMessage();
333
+ deps.state.currentTextEl = createMockEl();
334
+ deps.state.currentTextContent = 'Hello World';
335
+
336
+ await controller.finalizeCurrentTextBlock(msg);
337
+
338
+ expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith(
339
+ expect.anything(),
340
+ 'Hello World'
341
+ );
342
+ expect(msg.contentBlocks).toContainEqual({
343
+ type: 'text',
344
+ content: 'Hello World',
345
+ });
346
+ });
347
+
348
+ it('should not add copy button when no text element exists', async () => {
349
+ const msg = createTestMessage();
350
+ deps.state.currentTextEl = null;
351
+ deps.state.currentTextContent = 'Hello World';
352
+
353
+ await controller.finalizeCurrentTextBlock(msg);
354
+
355
+ expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled();
356
+ // Content block should still be added
357
+ expect(msg.contentBlocks).toContainEqual({
358
+ type: 'text',
359
+ content: 'Hello World',
360
+ });
361
+ });
362
+
363
+ it('should not add copy button when no text content exists', async () => {
364
+ const msg = createTestMessage();
365
+ deps.state.currentTextEl = createMockEl();
366
+ deps.state.currentTextContent = '';
367
+
368
+ await controller.finalizeCurrentTextBlock(msg);
369
+
370
+ expect(deps.renderer.addTextCopyButton).not.toHaveBeenCalled();
371
+ expect(msg.contentBlocks).toEqual([]);
372
+ });
373
+
374
+ it('should reset text state after finalization', async () => {
375
+ const msg = createTestMessage();
376
+ deps.state.currentTextEl = createMockEl();
377
+ deps.state.currentTextContent = 'Test content';
378
+
379
+ await controller.finalizeCurrentTextBlock(msg);
380
+
381
+ expect(deps.state.currentTextEl).toBeNull();
382
+ expect(deps.state.currentTextContent).toBe('');
383
+ });
384
+ });
385
+
386
+ describe('Error and notice handling', () => {
387
+ it('should append error message on error chunk', async () => {
388
+ const msg = createTestMessage();
389
+ deps.state.currentTextEl = createMockEl();
390
+
391
+ await controller.handleStreamChunk(
392
+ { type: 'error', content: 'Something went wrong' },
393
+ msg
394
+ );
395
+
396
+ expect(deps.state.currentTextContent).toContain('Error');
397
+ });
398
+
399
+ it('should append warning notice on notice chunk', async () => {
400
+ const msg = createTestMessage();
401
+ deps.state.currentTextEl = createMockEl();
402
+
403
+ await controller.handleStreamChunk(
404
+ { type: 'notice', content: 'Tool was blocked', level: 'warning' },
405
+ msg
406
+ );
407
+
408
+ expect(deps.state.currentTextContent).toContain('Blocked');
409
+ });
410
+ });
411
+
412
+ describe('context_compacted handling', () => {
413
+ it('should record a context_compacted block on the message', async () => {
414
+ const msg = createTestMessage();
415
+
416
+ await controller.handleStreamChunk({ type: 'context_compacted' }, msg);
417
+
418
+ expect(msg.contentBlocks).toContainEqual({ type: 'context_compacted' });
419
+ });
420
+ });
421
+
422
+ describe('Done chunk handling', () => {
423
+ it('should handle done chunk without error', async () => {
424
+ const msg = createTestMessage();
425
+ deps.state.currentTextEl = createMockEl();
426
+
427
+ // Should not throw
428
+ await expect(
429
+ controller.handleStreamChunk({ type: 'done' }, msg)
430
+ ).resolves.not.toThrow();
431
+ });
432
+ });
433
+
434
+ describe('Usage handling', () => {
435
+ it('should update usage for current session', async () => {
436
+ const msg = createTestMessage();
437
+ const usage = createMockUsage();
438
+
439
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg);
440
+
441
+ expect(deps.state.usage).toEqual(usage);
442
+ });
443
+
444
+ it('stamps the active provider model onto usage when the provider omits it', async () => {
445
+ const msg = createTestMessage();
446
+ const usage = createMockUsage({ model: undefined });
447
+ const providerSettingsSpy = jest.spyOn(ProviderSettingsCoordinator, 'getProviderSettingsSnapshot');
448
+ providerSettingsSpy.mockReturnValue({ model: DEFAULT_CODEX_PRIMARY_MODEL } as any);
449
+ (deps.getAgentService!() as any).providerId = 'codex';
450
+
451
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg);
452
+
453
+ expect(deps.state.usage).toEqual({ ...usage, model: DEFAULT_CODEX_PRIMARY_MODEL });
454
+
455
+ providerSettingsSpy.mockRestore();
456
+ });
457
+
458
+ it('should ignore usage from other sessions', async () => {
459
+ const msg = createTestMessage();
460
+ const usage = createMockUsage();
461
+
462
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-2' }, msg);
463
+
464
+ expect(deps.state.usage).toBeNull();
465
+ });
466
+ });
467
+
468
+ describe('Tool handling', () => {
469
+ it('should record tool_use and add to content blocks', async () => {
470
+ const msg = createTestMessage();
471
+ deps.state.currentContentEl = createMockEl();
472
+
473
+ await controller.handleStreamChunk(
474
+ { type: 'tool_use', id: 'tool-1', name: 'Read', input: { file_path: 'notes/test.md' } },
475
+ msg
476
+ );
477
+
478
+ expect(msg.toolCalls).toHaveLength(1);
479
+ expect(msg.toolCalls![0].id).toBe('tool-1');
480
+ expect(msg.toolCalls![0].status).toBe('running');
481
+ expect(msg.contentBlocks).toHaveLength(1);
482
+ expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'tool-1' });
483
+ });
484
+
485
+ it('should update tool_result status', async () => {
486
+ const msg = createTestMessage();
487
+ msg.toolCalls = [
488
+ {
489
+ id: 'tool-1',
490
+ name: 'Read',
491
+ input: { file_path: 'notes/test.md' },
492
+ status: 'running',
493
+ } as any,
494
+ ];
495
+ deps.state.currentContentEl = createMockEl();
496
+
497
+ await controller.handleStreamChunk(
498
+ { type: 'tool_result', id: 'tool-1', content: 'ok' },
499
+ msg
500
+ );
501
+
502
+ expect(msg.toolCalls![0].status).toBe('completed');
503
+ expect(msg.toolCalls![0].result).toBe('ok');
504
+ });
505
+
506
+ it('should add subagent entry to contentBlocks for Task tool', async () => {
507
+ const msg = createTestMessage();
508
+ deps.state.currentContentEl = createMockEl();
509
+
510
+ // Configure mock to return created_sync when run_in_background is known
511
+ (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({
512
+ action: 'created_sync',
513
+ subagentState: {
514
+ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] },
515
+ },
516
+ });
517
+
518
+ await controller.handleStreamChunk(
519
+ {
520
+ type: 'tool_use',
521
+ id: 'task-1',
522
+ name: TOOL_TASK,
523
+ input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false },
524
+ },
525
+ msg
526
+ );
527
+
528
+ expect(msg.contentBlocks).toHaveLength(1);
529
+ expect(msg.contentBlocks![0]).toEqual({ type: 'subagent', subagentId: 'task-1' });
530
+ expect(msg.toolCalls).toContainEqual(
531
+ expect.objectContaining({
532
+ id: 'task-1',
533
+ name: TOOL_TASK,
534
+ subagent: expect.objectContaining({ id: 'task-1' }),
535
+ })
536
+ );
537
+ });
538
+
539
+ it('should render TodoWrite inline and update panel', async () => {
540
+ const { parseTodoInput } = jest.requireMock('@/core/tools/todo');
541
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
542
+ const mockTodos = [{ content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' }];
543
+ parseTodoInput.mockReturnValue(mockTodos);
544
+
545
+ const msg = createTestMessage();
546
+ deps.state.currentContentEl = createMockEl();
547
+
548
+ await controller.handleStreamChunk(
549
+ {
550
+ type: 'tool_use',
551
+ id: 'todo-1',
552
+ name: TOOL_TODO_WRITE,
553
+ input: { todos: mockTodos },
554
+ },
555
+ msg
556
+ );
557
+
558
+ // Tool is buffered, should be in pendingTools
559
+ expect(msg.contentBlocks).toHaveLength(1);
560
+ expect(msg.contentBlocks![0]).toEqual({ type: 'tool_use', toolId: 'todo-1' });
561
+ expect(deps.state.pendingTools.size).toBe(1);
562
+
563
+ // Should update currentTodos for panel immediately (side effect)
564
+ expect(deps.state.currentTodos).toEqual(mockTodos);
565
+
566
+ // Flush pending tools by sending a different chunk type (text or done)
567
+ await controller.handleStreamChunk({ type: 'done' }, msg);
568
+
569
+ // Now renderToolCall should have been called
570
+ expect(renderToolCall).toHaveBeenCalled();
571
+ expect(deps.state.pendingTools.size).toBe(0);
572
+ });
573
+
574
+ it('should flush pending tools before rendering text content', async () => {
575
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
576
+ const msg = createTestMessage();
577
+ deps.state.currentContentEl = createMockEl();
578
+
579
+ await controller.handleStreamChunk(
580
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } },
581
+ msg
582
+ );
583
+ expect(deps.state.pendingTools.size).toBe(1);
584
+ expect(renderToolCall).not.toHaveBeenCalled();
585
+
586
+ deps.state.currentTextEl = createMockEl();
587
+ await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg);
588
+
589
+ expect(deps.state.pendingTools.size).toBe(0);
590
+ expect(renderToolCall).toHaveBeenCalledWith(
591
+ expect.anything(),
592
+ expect.objectContaining({ id: 'read-1', name: 'Read' }),
593
+ expect.any(Map),
594
+ { initiallyExpanded: false },
595
+ );
596
+ });
597
+
598
+ it('should pass expanded default to apply_patch tool blocks when enabled', async () => {
599
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
600
+ (deps.plugin.settings as any).expandFileEditsByDefault = true;
601
+
602
+ const msg = createTestMessage();
603
+ deps.state.currentContentEl = createMockEl();
604
+
605
+ await controller.handleStreamChunk(
606
+ {
607
+ type: 'tool_use',
608
+ id: 'patch-1',
609
+ name: TOOL_APPLY_PATCH,
610
+ input: { changes: [{ path: 'src/main.ts', kind: 'update' }] },
611
+ },
612
+ msg
613
+ );
614
+ await controller.handleStreamChunk({ type: 'done' }, msg);
615
+
616
+ expect(renderToolCall).toHaveBeenCalledWith(
617
+ expect.anything(),
618
+ expect.objectContaining({ id: 'patch-1', name: TOOL_APPLY_PATCH }),
619
+ expect.any(Map),
620
+ { initiallyExpanded: true },
621
+ );
622
+ });
623
+
624
+ it('should flush pending tools before rendering thinking content', async () => {
625
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
626
+ const msg = createTestMessage();
627
+ deps.state.currentContentEl = createMockEl();
628
+
629
+ await controller.handleStreamChunk(
630
+ { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } },
631
+ msg
632
+ );
633
+ expect(deps.state.pendingTools.size).toBe(1);
634
+ expect(renderToolCall).not.toHaveBeenCalled();
635
+
636
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Let me think...' }, msg);
637
+
638
+ expect(deps.state.pendingTools.size).toBe(0);
639
+ expect(renderToolCall).toHaveBeenCalled();
640
+ });
641
+
642
+ it('should render pending tool when tool_result arrives before flush', async () => {
643
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
644
+ const msg = createTestMessage();
645
+ deps.state.currentContentEl = createMockEl();
646
+
647
+ await controller.handleStreamChunk(
648
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } },
649
+ msg
650
+ );
651
+ expect(deps.state.pendingTools.size).toBe(1);
652
+ expect(renderToolCall).not.toHaveBeenCalled();
653
+
654
+ // Result arrives while tool still pending - should render tool first
655
+ await controller.handleStreamChunk(
656
+ { type: 'tool_result', id: 'read-1', content: 'file contents here' },
657
+ msg
658
+ );
659
+
660
+ expect(deps.state.pendingTools.size).toBe(0);
661
+ expect(renderToolCall).toHaveBeenCalled();
662
+ expect(msg.toolCalls![0].status).toBe('completed');
663
+ expect(msg.toolCalls![0].result).toBe('file contents here');
664
+ });
665
+
666
+ it('should render a pending tool on tool_output and append incremental output', async () => {
667
+ const { renderToolCall, updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
668
+ const msg = createTestMessage();
669
+ deps.state.currentContentEl = createMockEl();
670
+
671
+ await controller.handleStreamChunk(
672
+ { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'npm test' } },
673
+ msg
674
+ );
675
+
676
+ expect(deps.state.pendingTools.size).toBe(1);
677
+
678
+ await controller.handleStreamChunk(
679
+ { type: 'tool_output', id: 'bash-1', content: 'line 1\n' },
680
+ msg
681
+ );
682
+
683
+ expect(deps.state.pendingTools.size).toBe(0);
684
+ expect(renderToolCall).toHaveBeenCalled();
685
+ expect(updateToolCallResult).not.toHaveBeenCalled();
686
+
687
+ jest.advanceTimersByTime(16);
688
+ await Promise.resolve();
689
+
690
+ expect(updateToolCallResult).toHaveBeenCalledWith(
691
+ 'bash-1',
692
+ expect.objectContaining({
693
+ id: 'bash-1',
694
+ status: 'running',
695
+ result: 'line 1\n',
696
+ }),
697
+ expect.any(Map)
698
+ );
699
+
700
+ await controller.handleStreamChunk(
701
+ { type: 'tool_output', id: 'bash-1', content: 'line 2\n' },
702
+ msg
703
+ );
704
+
705
+ expect(msg.toolCalls![0].status).toBe('running');
706
+ expect(msg.toolCalls![0].result).toBe('line 1\nline 2\n');
707
+ expect(updateToolCallResult).toHaveBeenCalledTimes(1);
708
+
709
+ jest.advanceTimersByTime(16);
710
+ await Promise.resolve();
711
+
712
+ expect(updateToolCallResult).toHaveBeenLastCalledWith(
713
+ 'bash-1',
714
+ expect.objectContaining({
715
+ id: 'bash-1',
716
+ status: 'running',
717
+ result: 'line 1\nline 2\n',
718
+ }),
719
+ expect.any(Map)
720
+ );
721
+ });
722
+
723
+ it('should coalesce tool_output renders until the next animation frame', async () => {
724
+ const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
725
+ const msg = createTestMessage();
726
+ deps.state.currentContentEl = createMockEl();
727
+
728
+ await controller.handleStreamChunk(
729
+ { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'npm test' } },
730
+ msg
731
+ );
732
+ await controller.handleStreamChunk(
733
+ { type: 'tool_output', id: 'bash-1', content: 'line 1\n' },
734
+ msg
735
+ );
736
+ await controller.handleStreamChunk(
737
+ { type: 'tool_output', id: 'bash-1', content: 'line 2\n' },
738
+ msg
739
+ );
740
+
741
+ expect(updateToolCallResult).not.toHaveBeenCalled();
742
+
743
+ jest.advanceTimersByTime(16);
744
+ await Promise.resolve();
745
+
746
+ expect(updateToolCallResult).toHaveBeenCalledTimes(1);
747
+ expect(updateToolCallResult).toHaveBeenCalledWith(
748
+ 'bash-1',
749
+ expect.objectContaining({
750
+ result: 'line 1\nline 2\n',
751
+ status: 'running',
752
+ }),
753
+ expect.any(Map)
754
+ );
755
+ });
756
+
757
+ it('should buffer Write tool and use createWriteEditBlock on flush', async () => {
758
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
759
+ const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer');
760
+ createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() });
761
+
762
+ const msg = createTestMessage();
763
+ deps.state.currentContentEl = createMockEl();
764
+
765
+ await controller.handleStreamChunk(
766
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'test.md', content: 'hello' } },
767
+ msg
768
+ );
769
+
770
+ expect(deps.state.pendingTools.size).toBe(1);
771
+ expect(createWriteEditBlock).not.toHaveBeenCalled();
772
+ expect(renderToolCall).not.toHaveBeenCalled();
773
+
774
+ await controller.handleStreamChunk({ type: 'done' }, msg);
775
+
776
+ expect(deps.state.pendingTools.size).toBe(0);
777
+ expect(createWriteEditBlock).toHaveBeenCalledWith(
778
+ expect.anything(),
779
+ expect.objectContaining({ id: 'write-1', name: 'Write' }),
780
+ { initiallyExpanded: false },
781
+ );
782
+ // renderToolCall should NOT be called for Write/Edit tools
783
+ expect(renderToolCall).not.toHaveBeenCalled();
784
+ });
785
+
786
+ it('should pass expanded default to Write tool blocks when enabled', async () => {
787
+ const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer');
788
+ createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() });
789
+
790
+ (deps.plugin.settings as any).expandFileEditsByDefault = true;
791
+
792
+ const msg = createTestMessage();
793
+ deps.state.currentContentEl = createMockEl();
794
+
795
+ await controller.handleStreamChunk(
796
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'test.md', content: 'hello' } },
797
+ msg
798
+ );
799
+ await controller.handleStreamChunk({ type: 'done' }, msg);
800
+
801
+ expect(createWriteEditBlock).toHaveBeenCalledWith(
802
+ expect.anything(),
803
+ expect.objectContaining({ id: 'write-1', name: 'Write' }),
804
+ { initiallyExpanded: true },
805
+ );
806
+ });
807
+
808
+ it('should buffer Edit tool and use createWriteEditBlock on flush', async () => {
809
+ const { createWriteEditBlock } = jest.requireMock('@/features/chat/rendering/WriteEditRenderer');
810
+ createWriteEditBlock.mockReturnValue({ wrapperEl: createMockEl() });
811
+
812
+ const msg = createTestMessage();
813
+ deps.state.currentContentEl = createMockEl();
814
+
815
+ await controller.handleStreamChunk(
816
+ { type: 'tool_use', id: 'edit-1', name: 'Edit', input: { file_path: 'test.md', old_string: 'a', new_string: 'b' } },
817
+ msg
818
+ );
819
+
820
+ expect(deps.state.pendingTools.size).toBe(1);
821
+ expect(createWriteEditBlock).not.toHaveBeenCalled();
822
+
823
+ deps.state.currentTextEl = createMockEl();
824
+ await controller.handleStreamChunk({ type: 'text', content: 'Done editing' }, msg);
825
+
826
+ expect(deps.state.pendingTools.size).toBe(0);
827
+ expect(createWriteEditBlock).toHaveBeenCalled();
828
+ });
829
+
830
+ it('should flush pending tools before rendering blocked message', async () => {
831
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
832
+ const msg = createTestMessage();
833
+ deps.state.currentContentEl = createMockEl();
834
+
835
+ await controller.handleStreamChunk(
836
+ { type: 'tool_use', id: 'bash-1', name: 'Bash', input: { command: 'ls' } },
837
+ msg
838
+ );
839
+ expect(deps.state.pendingTools.size).toBe(1);
840
+
841
+ await controller.handleStreamChunk({ type: 'notice', content: 'Command blocked', level: 'warning' }, msg);
842
+
843
+ expect(deps.state.pendingTools.size).toBe(0);
844
+ expect(renderToolCall).toHaveBeenCalled();
845
+ });
846
+
847
+ it('should flush pending tools before rendering error message', async () => {
848
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
849
+ const msg = createTestMessage();
850
+ deps.state.currentContentEl = createMockEl();
851
+
852
+ await controller.handleStreamChunk(
853
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'missing.md' } },
854
+ msg
855
+ );
856
+ expect(deps.state.pendingTools.size).toBe(1);
857
+
858
+ await controller.handleStreamChunk({ type: 'error', content: 'Something went wrong' }, msg);
859
+
860
+ expect(deps.state.pendingTools.size).toBe(0);
861
+ expect(renderToolCall).toHaveBeenCalled();
862
+ });
863
+
864
+ it('should flush pending tools before Task tool renders', async () => {
865
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
866
+ const msg = createTestMessage();
867
+ deps.state.currentContentEl = createMockEl();
868
+
869
+ (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({
870
+ action: 'created_sync',
871
+ subagentState: {
872
+ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] },
873
+ },
874
+ });
875
+
876
+ await controller.handleStreamChunk(
877
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } },
878
+ msg
879
+ );
880
+ expect(deps.state.pendingTools.size).toBe(1);
881
+ expect(renderToolCall).not.toHaveBeenCalled();
882
+
883
+ await controller.handleStreamChunk(
884
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose', run_in_background: false } },
885
+ msg
886
+ );
887
+
888
+ expect(deps.state.pendingTools.size).toBe(0);
889
+ expect(renderToolCall).toHaveBeenCalled();
890
+ expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith(
891
+ 'task-1',
892
+ expect.objectContaining({ run_in_background: false }),
893
+ expect.anything()
894
+ );
895
+ });
896
+
897
+ it('should re-parse TodoWrite on input updates when streaming completes', async () => {
898
+ const { parseTodoInput } = jest.requireMock('@/core/tools/todo');
899
+
900
+ const mockTodos = [
901
+ { content: 'Task 1', status: 'pending', activeForm: 'Working on task 1' },
902
+ ];
903
+
904
+ // First chunk: partial input, parsing fails
905
+ parseTodoInput.mockReturnValueOnce(null);
906
+
907
+ const msg = createTestMessage();
908
+ deps.state.currentContentEl = createMockEl();
909
+
910
+ await controller.handleStreamChunk(
911
+ {
912
+ type: 'tool_use',
913
+ id: 'todo-1',
914
+ name: TOOL_TODO_WRITE,
915
+ input: { todos: '[' }, // Incomplete JSON
916
+ },
917
+ msg
918
+ );
919
+
920
+ // No todos yet
921
+ expect(deps.state.currentTodos).toBeNull();
922
+
923
+ // Second chunk: complete input, parsing succeeds
924
+ parseTodoInput.mockReturnValueOnce(mockTodos);
925
+
926
+ await controller.handleStreamChunk(
927
+ {
928
+ type: 'tool_use',
929
+ id: 'todo-1',
930
+ name: TOOL_TODO_WRITE,
931
+ input: { todos: mockTodos },
932
+ },
933
+ msg
934
+ );
935
+
936
+ // Now todos should be updated
937
+ expect(deps.state.currentTodos).toEqual(mockTodos);
938
+ });
939
+
940
+ it('should clear pendingTools on resetStreamingState', async () => {
941
+ const msg = createTestMessage();
942
+ deps.state.currentContentEl = createMockEl();
943
+
944
+ await controller.handleStreamChunk(
945
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } },
946
+ msg
947
+ );
948
+ await controller.handleStreamChunk(
949
+ { type: 'tool_use', id: 'read-2', name: 'Read', input: { file_path: 'b.md' } },
950
+ msg
951
+ );
952
+ expect(deps.state.pendingTools.size).toBe(2);
953
+
954
+ controller.resetStreamingState();
955
+
956
+ expect(deps.state.pendingTools.size).toBe(0);
957
+ });
958
+
959
+ it('should clear responseStartTime on resetStreamingState', () => {
960
+ deps.state.responseStartTime = 12345;
961
+ expect(deps.state.responseStartTime).toBe(12345);
962
+
963
+ controller.resetStreamingState();
964
+
965
+ expect(deps.state.responseStartTime).toBeNull();
966
+ });
967
+ });
968
+
969
+ describe('Timer lifecycle', () => {
970
+ it('should create timer interval when showing thinking indicator', () => {
971
+ deps.state.responseStartTime = performance.now();
972
+
973
+ controller.showThinkingIndicator();
974
+ jest.advanceTimersByTime(500); // Past the debounce delay
975
+
976
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
977
+ });
978
+
979
+ it('should clear timer interval when hiding thinking indicator', () => {
980
+ deps.state.responseStartTime = performance.now();
981
+
982
+ controller.showThinkingIndicator();
983
+ jest.advanceTimersByTime(500);
984
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
985
+
986
+ controller.hideThinkingIndicator();
987
+
988
+ expect(deps.state.flavorTimerInterval).toBeNull();
989
+ });
990
+
991
+ it('uses the content owner window for thinking timers', () => {
992
+ const ownerSetTimeout = jest.fn<ReturnType<Window['setTimeout']>, Parameters<Window['setTimeout']>>(
993
+ (callback, timeout) => globalThis.setTimeout(callback, timeout) as unknown as number,
994
+ );
995
+ const ownerClearTimeout = jest.fn<void, [number]>((handle) => {
996
+ globalThis.clearTimeout(handle as unknown as ReturnType<typeof setTimeout>);
997
+ });
998
+ const ownerSetInterval = jest.fn<ReturnType<Window['setInterval']>, Parameters<Window['setInterval']>>(
999
+ (callback, timeout) => globalThis.setInterval(callback, timeout) as unknown as number,
1000
+ );
1001
+ const ownerClearInterval = jest.fn<void, [number]>((handle) => {
1002
+ globalThis.clearInterval(handle as unknown as ReturnType<typeof setInterval>);
1003
+ });
1004
+ const ownerWindow = {
1005
+ ...deps.state.currentContentEl!.ownerDocument.defaultView,
1006
+ setTimeout: ownerSetTimeout,
1007
+ clearTimeout: ownerClearTimeout,
1008
+ setInterval: ownerSetInterval,
1009
+ clearInterval: ownerClearInterval,
1010
+ };
1011
+ Object.defineProperty(deps.state.currentContentEl!.ownerDocument, 'defaultView', {
1012
+ configurable: true,
1013
+ value: ownerWindow,
1014
+ });
1015
+
1016
+ deps.state.responseStartTime = performance.now();
1017
+
1018
+ controller.showThinkingIndicator();
1019
+ expect(ownerSetTimeout).toHaveBeenCalledWith(expect.any(Function), 400);
1020
+
1021
+ controller.hideThinkingIndicator();
1022
+ expect(ownerClearTimeout).toHaveBeenCalled();
1023
+
1024
+ controller.showThinkingIndicator();
1025
+ jest.advanceTimersByTime(500);
1026
+ expect(ownerSetInterval).toHaveBeenCalledWith(expect.any(Function), 1000);
1027
+
1028
+ controller.hideThinkingIndicator();
1029
+ expect(ownerClearInterval).toHaveBeenCalled();
1030
+ });
1031
+
1032
+ it('should clear timer interval in resetStreamingState', () => {
1033
+ deps.state.responseStartTime = performance.now();
1034
+
1035
+ controller.showThinkingIndicator();
1036
+ jest.advanceTimersByTime(500);
1037
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
1038
+
1039
+ controller.resetStreamingState();
1040
+
1041
+ expect(deps.state.flavorTimerInterval).toBeNull();
1042
+ });
1043
+
1044
+ it('should not create duplicate intervals on multiple showThinkingIndicator calls', () => {
1045
+ deps.state.responseStartTime = performance.now();
1046
+ const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
1047
+
1048
+ controller.showThinkingIndicator();
1049
+ jest.advanceTimersByTime(500);
1050
+ const firstInterval = deps.state.flavorTimerInterval;
1051
+
1052
+ // Second call while indicator exists should not create a new interval
1053
+ controller.showThinkingIndicator();
1054
+ jest.advanceTimersByTime(500);
1055
+
1056
+ // Should still have the same interval (no new one created since element exists)
1057
+ expect(deps.state.flavorTimerInterval).toBe(firstInterval);
1058
+
1059
+ clearIntervalSpy.mockRestore();
1060
+ });
1061
+ });
1062
+
1063
+ describe('Tool handling - continued', () => {
1064
+ it('should handle multiple pending tools and flush in order', async () => {
1065
+ const { renderToolCall } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
1066
+ const msg = createTestMessage();
1067
+ deps.state.currentContentEl = createMockEl();
1068
+
1069
+ await controller.handleStreamChunk(
1070
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'a.md' } },
1071
+ msg
1072
+ );
1073
+ await controller.handleStreamChunk(
1074
+ { type: 'tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' } },
1075
+ msg
1076
+ );
1077
+ await controller.handleStreamChunk(
1078
+ { type: 'tool_use', id: 'glob-1', name: 'Glob', input: { pattern: '*.md' } },
1079
+ msg
1080
+ );
1081
+
1082
+ expect(deps.state.pendingTools.size).toBe(3);
1083
+ expect(renderToolCall).not.toHaveBeenCalled();
1084
+
1085
+ await controller.handleStreamChunk({ type: 'done' }, msg);
1086
+
1087
+ expect(deps.state.pendingTools.size).toBe(0);
1088
+ expect(renderToolCall).toHaveBeenCalledTimes(3);
1089
+
1090
+ // Verify tools were rendered in order (Map preserves insertion order)
1091
+ const calls = renderToolCall.mock.calls;
1092
+ expect(calls[0][1].id).toBe('read-1');
1093
+ expect(calls[1][1].id).toBe('grep-1');
1094
+ expect(calls[2][1].id).toBe('glob-1');
1095
+ });
1096
+ });
1097
+
1098
+ describe('Usage handling - edge cases', () => {
1099
+ it('should skip usage when subagentsSpawnedThisStream > 0', async () => {
1100
+ const msg = createTestMessage();
1101
+ (deps.subagentManager as any).subagentsSpawnedThisStream = 1;
1102
+
1103
+ const usage = createMockUsage({ inputTokens: 100, contextWindow: 200, contextTokens: 100, percentage: 50 });
1104
+
1105
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg);
1106
+
1107
+ expect(deps.state.usage).toBeNull();
1108
+ });
1109
+
1110
+ it('should skip usage when chunk has sessionId but currentSessionId is null', async () => {
1111
+ const nullSessionDeps = createMockDeps();
1112
+ nullSessionDeps.getAgentService = () => ({ getSessionId: jest.fn().mockReturnValue(null) }) as any;
1113
+ nullSessionDeps.state.currentContentEl = createMockEl();
1114
+ const nullSessionController = new StreamController(nullSessionDeps);
1115
+
1116
+ const msg = createTestMessage();
1117
+ const usage = createMockUsage();
1118
+
1119
+ await nullSessionController.handleStreamChunk({ type: 'usage', usage, sessionId: 'some-session' }, msg);
1120
+
1121
+ expect(nullSessionDeps.state.usage).toBeNull();
1122
+ });
1123
+
1124
+ it('should update usage when no sessionId on chunk', async () => {
1125
+ const msg = createTestMessage();
1126
+ const usage = createMockUsage();
1127
+
1128
+ await controller.handleStreamChunk({ type: 'usage', usage } as any, msg);
1129
+
1130
+ expect(deps.state.usage).toEqual(usage);
1131
+ });
1132
+
1133
+ it('uses authoritative usage chunks directly', async () => {
1134
+ const msg = createTestMessage();
1135
+ const usage = createMockUsage({
1136
+ model: DEFAULT_CODEX_PRIMARY_MODEL,
1137
+ contextWindow: 258400,
1138
+ contextWindowIsAuthoritative: true,
1139
+ contextTokens: 129200,
1140
+ percentage: 50,
1141
+ });
1142
+
1143
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg);
1144
+
1145
+ expect(deps.state.usage).toEqual(usage);
1146
+ });
1147
+
1148
+ it('should not update usage when ignoreUsageUpdates is true', async () => {
1149
+ const msg = createTestMessage();
1150
+ deps.state.ignoreUsageUpdates = true;
1151
+
1152
+ const usage = createMockUsage();
1153
+
1154
+ await controller.handleStreamChunk({ type: 'usage', usage, sessionId: 'session-1' }, msg);
1155
+
1156
+ expect(deps.state.usage).toBeNull();
1157
+ });
1158
+ });
1159
+
1160
+ describe('Thinking indicator - edge cases', () => {
1161
+ it('should not show indicator when no currentContentEl', () => {
1162
+ deps.state.currentContentEl = null;
1163
+
1164
+ controller.showThinkingIndicator();
1165
+ jest.advanceTimersByTime(500);
1166
+
1167
+ expect(deps.state.thinkingEl).toBeNull();
1168
+ });
1169
+
1170
+ it('should not show indicator when currentThinkingState is active', () => {
1171
+ deps.state.currentThinkingState = { content: 'thinking...', container: {}, contentEl: {}, startTime: Date.now() } as any;
1172
+
1173
+ controller.showThinkingIndicator();
1174
+ jest.advanceTimersByTime(500);
1175
+
1176
+ expect(deps.state.thinkingEl).toBeNull();
1177
+ });
1178
+
1179
+ it('should re-append existing indicator to bottom when called again', () => {
1180
+ deps.state.responseStartTime = performance.now();
1181
+
1182
+ controller.showThinkingIndicator();
1183
+ jest.advanceTimersByTime(500);
1184
+
1185
+ const thinkingEl = deps.state.thinkingEl;
1186
+ expect(thinkingEl).not.toBeNull();
1187
+
1188
+ controller.showThinkingIndicator();
1189
+
1190
+ expect(deps.state.thinkingEl).toBe(thinkingEl);
1191
+ expect(deps.updateQueueIndicator).toHaveBeenCalled();
1192
+ });
1193
+ });
1194
+
1195
+ describe('scrollToBottom - settings', () => {
1196
+ it('should not scroll when enableAutoScroll setting is false', async () => {
1197
+ (deps.plugin.settings as any).enableAutoScroll = false;
1198
+ const messagesEl = deps.getMessagesEl();
1199
+ Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true });
1200
+ messagesEl.scrollTop = 0;
1201
+
1202
+ const msg = createTestMessage();
1203
+ deps.state.currentTextEl = createMockEl();
1204
+ await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg);
1205
+
1206
+ expect(messagesEl.scrollTop).toBe(0);
1207
+ });
1208
+
1209
+ it('should not scroll when autoScrollEnabled state is false', async () => {
1210
+ deps.state.autoScrollEnabled = false;
1211
+ const messagesEl = deps.getMessagesEl();
1212
+ Object.defineProperty(messagesEl, 'scrollHeight', { value: 1000, configurable: true });
1213
+ messagesEl.scrollTop = 0;
1214
+
1215
+ const msg = createTestMessage();
1216
+ deps.state.currentTextEl = createMockEl();
1217
+ await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg);
1218
+
1219
+ expect(messagesEl.scrollTop).toBe(0);
1220
+ });
1221
+ });
1222
+
1223
+ describe('Subagent chunk handling', () => {
1224
+ it('should handle subagent tool_result chunk', async () => {
1225
+ const msg = createTestMessage();
1226
+ deps.state.currentContentEl = createMockEl();
1227
+
1228
+ const toolCall = { id: 'read-1', name: 'Read', input: {}, status: 'running' };
1229
+ (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({
1230
+ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [toolCall] },
1231
+ });
1232
+
1233
+ await controller.handleStreamChunk(
1234
+ { type: 'subagent_tool_result', id: 'read-1', subagentId: 'task-1', content: 'file content' },
1235
+ msg
1236
+ );
1237
+
1238
+ expect(deps.subagentManager.updateSyncToolResult).toHaveBeenCalledWith(
1239
+ 'task-1',
1240
+ 'read-1',
1241
+ expect.objectContaining({ status: 'completed', result: 'file content' })
1242
+ );
1243
+ });
1244
+
1245
+ it('should handle subagent tool_use chunk', async () => {
1246
+ const msg = createTestMessage();
1247
+ deps.state.currentContentEl = createMockEl();
1248
+
1249
+ (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({
1250
+ info: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] },
1251
+ });
1252
+
1253
+ await controller.handleStreamChunk(
1254
+ { type: 'subagent_tool_use', id: 'grep-1', name: 'Grep', input: { pattern: 'test' }, subagentId: 'task-1' },
1255
+ msg
1256
+ );
1257
+
1258
+ expect(deps.subagentManager.addSyncToolCall).toHaveBeenCalledWith(
1259
+ 'task-1',
1260
+ expect.objectContaining({ id: 'grep-1', name: 'Grep', status: 'running' })
1261
+ );
1262
+ });
1263
+
1264
+ it('should skip subagent chunk when no sync subagent found', async () => {
1265
+ const msg = createTestMessage();
1266
+ deps.state.currentContentEl = createMockEl();
1267
+
1268
+ (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce(undefined);
1269
+
1270
+ await controller.handleStreamChunk(
1271
+ { type: 'subagent_tool_use', id: 'orphan-read', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'unknown-task' },
1272
+ msg
1273
+ );
1274
+
1275
+ // Should not throw
1276
+ expect(msg.content).toBe('');
1277
+ });
1278
+ });
1279
+
1280
+ describe('Async subagent handling', () => {
1281
+ it('should handle created_async action from Task tool use', async () => {
1282
+ const msg = createTestMessage();
1283
+ deps.state.currentContentEl = createMockEl();
1284
+
1285
+ (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({
1286
+ action: 'created_async',
1287
+ info: { id: 'task-1', description: 'background task', status: 'running', toolCalls: [], mode: 'async' },
1288
+ });
1289
+
1290
+ await controller.handleStreamChunk(
1291
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', run_in_background: true } },
1292
+ msg
1293
+ );
1294
+
1295
+ expect(msg.toolCalls).toContainEqual(
1296
+ expect.objectContaining({
1297
+ id: 'task-1',
1298
+ name: TOOL_TASK,
1299
+ subagent: expect.objectContaining({
1300
+ id: 'task-1',
1301
+ mode: 'async',
1302
+ }),
1303
+ })
1304
+ );
1305
+ expect(msg.contentBlocks).toContainEqual({ type: 'subagent', subagentId: 'task-1', mode: 'async' });
1306
+ });
1307
+
1308
+ it('should handle label_updated action from Task tool use (no-op for message)', async () => {
1309
+ const msg = createTestMessage();
1310
+ deps.state.currentContentEl = createMockEl();
1311
+
1312
+ (deps.subagentManager.handleTaskToolUse as jest.Mock).mockReturnValueOnce({
1313
+ action: 'label_updated',
1314
+ });
1315
+
1316
+ await controller.handleStreamChunk(
1317
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Updated' } },
1318
+ msg
1319
+ );
1320
+
1321
+ expect(msg.toolCalls).toContainEqual(
1322
+ expect.objectContaining({
1323
+ id: 'task-1',
1324
+ name: TOOL_TASK,
1325
+ })
1326
+ );
1327
+ expect(msg.contentBlocks).toEqual([]);
1328
+ });
1329
+ });
1330
+
1331
+ describe('onAsyncSubagentStateChange', () => {
1332
+ it('should update subagent in messages', () => {
1333
+ const subagent = { id: 'task-1', description: 'test', status: 'completed', result: 'done', toolCalls: [] } as any;
1334
+ deps.state.messages = [{
1335
+ id: 'a1',
1336
+ role: 'assistant',
1337
+ content: '',
1338
+ timestamp: Date.now(),
1339
+ toolCalls: [{
1340
+ id: 'task-1',
1341
+ name: TOOL_TASK,
1342
+ input: { description: 'test' },
1343
+ status: 'running',
1344
+ subagent: { id: 'task-1', description: 'test', status: 'running', toolCalls: [] },
1345
+ }],
1346
+ }] as any;
1347
+
1348
+ controller.onAsyncSubagentStateChange(subagent);
1349
+
1350
+ const taskTool = deps.state.messages[0].toolCalls![0];
1351
+ expect(taskTool.status).toBe('completed');
1352
+ expect(taskTool.subagent?.status).toBe('completed');
1353
+ expect(taskTool.subagent?.result).toBe('done');
1354
+ });
1355
+
1356
+ it('should not crash when subagent not found in messages', () => {
1357
+ const subagent = { id: 'unknown', description: 'test', status: 'completed', toolCalls: [] } as any;
1358
+ deps.state.messages = [{
1359
+ id: 'a1',
1360
+ role: 'assistant',
1361
+ content: '',
1362
+ timestamp: Date.now(),
1363
+ toolCalls: [{
1364
+ id: 'task-1',
1365
+ name: TOOL_TASK,
1366
+ input: { description: 'test' },
1367
+ status: 'running',
1368
+ }],
1369
+ }] as any;
1370
+
1371
+ expect(() => controller.onAsyncSubagentStateChange(subagent)).not.toThrow();
1372
+ });
1373
+ });
1374
+
1375
+ describe('Thinking block finalization', () => {
1376
+ it('should finalize thinking block and add to contentBlocks', async () => {
1377
+ const msg = createTestMessage();
1378
+ deps.state.currentContentEl = createMockEl();
1379
+
1380
+ deps.state.currentThinkingState = {
1381
+ content: 'Let me think...',
1382
+ container: createMockEl(),
1383
+ contentEl: createMockEl(),
1384
+ startTime: Date.now(),
1385
+ } as any;
1386
+
1387
+ await controller.finalizeCurrentThinkingBlock(msg);
1388
+
1389
+ expect(msg.contentBlocks).toContainEqual(
1390
+ expect.objectContaining({ type: 'thinking', content: 'Let me think...' })
1391
+ );
1392
+ expect(deps.state.currentThinkingState).toBeNull();
1393
+ });
1394
+
1395
+ it('should not add to contentBlocks when no thinking content', async () => {
1396
+ const msg = createTestMessage();
1397
+ deps.state.currentThinkingState = {
1398
+ content: '',
1399
+ container: createMockEl(),
1400
+ contentEl: createMockEl(),
1401
+ startTime: Date.now(),
1402
+ } as any;
1403
+
1404
+ await controller.finalizeCurrentThinkingBlock(msg);
1405
+
1406
+ expect(msg.contentBlocks).toEqual([]);
1407
+ });
1408
+
1409
+ it('should be a no-op when no thinking state', async () => {
1410
+ const msg = createTestMessage();
1411
+ deps.state.currentThinkingState = null;
1412
+
1413
+ await controller.finalizeCurrentThinkingBlock(msg);
1414
+
1415
+ expect(msg.contentBlocks).toEqual([]);
1416
+ });
1417
+
1418
+ it('should coalesce thinking renders until the next animation frame', async () => {
1419
+ const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer');
1420
+ const msg = createTestMessage();
1421
+ const contentEl = createMockEl();
1422
+ createThinkingBlock.mockReturnValueOnce({
1423
+ wrapperEl: createMockEl(),
1424
+ contentEl,
1425
+ labelEl: createMockEl(),
1426
+ content: '',
1427
+ startTime: Date.now(),
1428
+ });
1429
+
1430
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Let ' }, msg);
1431
+ await controller.handleStreamChunk({ type: 'thinking', content: 'me think' }, msg);
1432
+
1433
+ expect(deps.renderer.renderContent).not.toHaveBeenCalled();
1434
+
1435
+ jest.advanceTimersByTime(16);
1436
+ await Promise.resolve();
1437
+
1438
+ expect(deps.renderer.renderContent).toHaveBeenCalledTimes(1);
1439
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(contentEl, 'Let me think');
1440
+ });
1441
+
1442
+ it('should defer math rendering during live thinking renders', async () => {
1443
+ const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer');
1444
+ const msg = createTestMessage();
1445
+ const contentEl = createMockEl();
1446
+ createThinkingBlock.mockReturnValueOnce({
1447
+ wrapperEl: createMockEl(),
1448
+ contentEl,
1449
+ labelEl: createMockEl(),
1450
+ content: '',
1451
+ startTime: Date.now(),
1452
+ });
1453
+
1454
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning $x^2$' }, msg);
1455
+
1456
+ jest.advanceTimersByTime(16);
1457
+ await Promise.resolve();
1458
+
1459
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
1460
+ contentEl,
1461
+ 'Reasoning $x^2$',
1462
+ { deferMath: true }
1463
+ );
1464
+ });
1465
+
1466
+ it('should render original math once when finalizing a deferred thinking block', async () => {
1467
+ const { createThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer');
1468
+ const msg = createTestMessage();
1469
+ const contentEl = createMockEl();
1470
+ createThinkingBlock.mockReturnValueOnce({
1471
+ wrapperEl: createMockEl(),
1472
+ contentEl,
1473
+ labelEl: createMockEl(),
1474
+ content: '',
1475
+ startTime: Date.now(),
1476
+ });
1477
+
1478
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning $x^2$' }, msg);
1479
+ await controller.finalizeCurrentThinkingBlock(msg);
1480
+
1481
+ expect(deps.renderer.renderContent).toHaveBeenNthCalledWith(
1482
+ 1,
1483
+ contentEl,
1484
+ 'Reasoning $x^2$',
1485
+ { deferMath: true }
1486
+ );
1487
+ expect(deps.renderer.renderContent).toHaveBeenNthCalledWith(
1488
+ 2,
1489
+ contentEl,
1490
+ 'Reasoning $x^2$'
1491
+ );
1492
+ expect(msg.contentBlocks).toContainEqual(
1493
+ expect.objectContaining({ type: 'thinking', content: 'Reasoning $x^2$' })
1494
+ );
1495
+ });
1496
+
1497
+ it('should flush a pending thinking render before finalizing', async () => {
1498
+ const msg = createTestMessage();
1499
+
1500
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Reasoning' }, msg);
1501
+ await controller.finalizeCurrentThinkingBlock(msg);
1502
+
1503
+ expect(deps.renderer.renderContent).toHaveBeenCalledWith(
1504
+ expect.anything(),
1505
+ 'Reasoning'
1506
+ );
1507
+ expect(msg.contentBlocks).toContainEqual(
1508
+ expect.objectContaining({ type: 'thinking', content: 'Reasoning' })
1509
+ );
1510
+ });
1511
+ });
1512
+
1513
+ describe('Pending Task tool handling', () => {
1514
+ it('should render pending Task as sync when child chunk arrives', async () => {
1515
+ const msg = createTestMessage();
1516
+ deps.state.currentContentEl = createMockEl();
1517
+
1518
+ // Task without run_in_background - manager returns buffered
1519
+ await controller.handleStreamChunk(
1520
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } },
1521
+ msg
1522
+ );
1523
+
1524
+ // Manager's handleTaskToolUse should have been called
1525
+ expect(deps.subagentManager.handleTaskToolUse).toHaveBeenCalledWith(
1526
+ 'task-1',
1527
+ expect.objectContaining({ prompt: 'Do something' }),
1528
+ expect.anything()
1529
+ );
1530
+
1531
+ // Configure manager for child chunk: pending task exists, render returns sync
1532
+ (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true);
1533
+ (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce({
1534
+ mode: 'sync',
1535
+ subagentState: {
1536
+ info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] },
1537
+ },
1538
+ });
1539
+ // Also configure getSyncSubagent for the child chunk routing
1540
+ (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({
1541
+ info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [] },
1542
+ });
1543
+
1544
+ // Child chunk arrives with parentToolUseId - should trigger render
1545
+ await controller.handleStreamChunk(
1546
+ { type: 'subagent_tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'task-1' },
1547
+ msg
1548
+ );
1549
+
1550
+ // Task toolCall should carry linked subagent
1551
+ expect(msg.toolCalls).toContainEqual(
1552
+ expect.objectContaining({
1553
+ id: 'task-1',
1554
+ name: TOOL_TASK,
1555
+ subagent: expect.objectContaining({ id: 'task-1' }),
1556
+ })
1557
+ );
1558
+ expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl);
1559
+ });
1560
+
1561
+ it('should not crash stream when pending Task rendering returns null via child chunk', async () => {
1562
+ const msg = createTestMessage();
1563
+ deps.state.currentContentEl = createMockEl();
1564
+
1565
+ // Task without run_in_background - manager returns buffered
1566
+ await controller.handleStreamChunk(
1567
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } },
1568
+ msg
1569
+ );
1570
+
1571
+ // Configure manager: pending task exists but render returns null (error case)
1572
+ (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true);
1573
+ (deps.subagentManager.renderPendingTask as jest.Mock).mockReturnValueOnce(null);
1574
+
1575
+ // Child chunk arrives - renderPendingTask returns null but shouldn't crash
1576
+ await controller.handleStreamChunk(
1577
+ { type: 'subagent_tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' }, subagentId: 'task-1' },
1578
+ msg
1579
+ );
1580
+
1581
+ // Should not throw - manager handled errors internally
1582
+ expect(deps.subagentManager.renderPendingTask).toHaveBeenCalledWith('task-1', deps.state.currentContentEl);
1583
+ });
1584
+
1585
+ it('should not crash stream when pending Task rendering returns null via tool_result', async () => {
1586
+ const msg = createTestMessage();
1587
+ deps.state.currentContentEl = createMockEl();
1588
+
1589
+ // Task without run_in_background - manager returns buffered
1590
+ await controller.handleStreamChunk(
1591
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something', subagent_type: 'general-purpose' } },
1592
+ msg
1593
+ );
1594
+
1595
+ // Configure manager: pending task exists but render returns null
1596
+ (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true);
1597
+ (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null);
1598
+
1599
+ // Tool result arrives - pending resolver returns null but stream should continue
1600
+ await controller.handleStreamChunk(
1601
+ { type: 'tool_result', id: 'task-1', content: 'Task completed' },
1602
+ msg
1603
+ );
1604
+
1605
+ // Should not throw - manager handled errors internally
1606
+ expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith(
1607
+ 'task-1',
1608
+ 'Task completed',
1609
+ false,
1610
+ deps.state.currentContentEl,
1611
+ undefined
1612
+ );
1613
+ });
1614
+
1615
+ it('should resolve pending Task as async via tool_result and continue async lifecycle', async () => {
1616
+ const msg = createTestMessage();
1617
+ deps.state.currentContentEl = createMockEl();
1618
+
1619
+ await controller.handleStreamChunk(
1620
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } },
1621
+ msg
1622
+ );
1623
+
1624
+ (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true);
1625
+ (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce({
1626
+ mode: 'async',
1627
+ info: {
1628
+ id: 'task-1',
1629
+ description: 'Do something',
1630
+ prompt: 'Do something',
1631
+ mode: 'async',
1632
+ isExpanded: false,
1633
+ status: 'running',
1634
+ toolCalls: [],
1635
+ asyncStatus: 'pending',
1636
+ },
1637
+ });
1638
+ (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true);
1639
+
1640
+ await controller.handleStreamChunk(
1641
+ { type: 'tool_result', id: 'task-1', content: '{"agent_id":"agent-1"}' },
1642
+ msg
1643
+ );
1644
+
1645
+ expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith(
1646
+ 'task-1',
1647
+ '{"agent_id":"agent-1"}',
1648
+ false,
1649
+ deps.state.currentContentEl,
1650
+ undefined
1651
+ );
1652
+ expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith(
1653
+ 'task-1',
1654
+ '{"agent_id":"agent-1"}',
1655
+ undefined,
1656
+ undefined
1657
+ );
1658
+ expect(msg.contentBlocks).toContainEqual({
1659
+ type: 'subagent',
1660
+ subagentId: 'task-1',
1661
+ mode: 'async',
1662
+ });
1663
+ expect(msg.toolCalls).toContainEqual(
1664
+ expect.objectContaining({
1665
+ id: 'task-1',
1666
+ name: TOOL_TASK,
1667
+ subagent: expect.objectContaining({ mode: 'async' }),
1668
+ })
1669
+ );
1670
+ });
1671
+
1672
+ it('should pass task toolUseResult into pending Task resolver', async () => {
1673
+ const msg = createTestMessage();
1674
+ deps.state.currentContentEl = createMockEl();
1675
+
1676
+ await controller.handleStreamChunk(
1677
+ { type: 'tool_use', id: 'task-1', name: TOOL_TASK, input: { prompt: 'Do something' } },
1678
+ msg
1679
+ );
1680
+
1681
+ const toolUseResult = { isAsync: true, status: 'async_launched', agentId: 'agent-1' };
1682
+ (deps.subagentManager.hasPendingTask as jest.Mock).mockReturnValueOnce(true);
1683
+ (deps.subagentManager.renderPendingTaskFromTaskResult as jest.Mock).mockReturnValueOnce(null);
1684
+
1685
+ await controller.handleStreamChunk(
1686
+ { type: 'tool_result', id: 'task-1', content: 'Launching...', toolUseResult } as any,
1687
+ msg
1688
+ );
1689
+
1690
+ expect(deps.subagentManager.renderPendingTaskFromTaskResult).toHaveBeenCalledWith(
1691
+ 'task-1',
1692
+ 'Launching...',
1693
+ false,
1694
+ deps.state.currentContentEl,
1695
+ toolUseResult
1696
+ );
1697
+ });
1698
+ });
1699
+
1700
+ describe('Text ↔ Thinking transitions', () => {
1701
+ it('text arrives while thinking state is active → finalizeCurrentThinkingBlock is called', async () => {
1702
+ const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer');
1703
+ const msg = createTestMessage();
1704
+ deps.state.currentContentEl = createMockEl();
1705
+
1706
+ deps.state.currentThinkingState = {
1707
+ content: 'Let me think...',
1708
+ container: createMockEl(),
1709
+ contentEl: createMockEl(),
1710
+ startTime: Date.now(),
1711
+ } as any;
1712
+
1713
+ await controller.handleStreamChunk({ type: 'text', content: 'Hello' }, msg);
1714
+
1715
+ expect(finalizeThinkingBlock).toHaveBeenCalled();
1716
+ expect(deps.state.currentThinkingState).toBeNull();
1717
+ expect(msg.contentBlocks).toContainEqual(
1718
+ expect.objectContaining({ type: 'thinking', content: 'Let me think...' })
1719
+ );
1720
+ });
1721
+
1722
+ it('thinking arrives while textEl exists → finalizeCurrentTextBlock is called', async () => {
1723
+ const msg = createTestMessage();
1724
+ deps.state.currentContentEl = createMockEl();
1725
+
1726
+ deps.state.currentTextEl = createMockEl();
1727
+ deps.state.currentTextContent = 'Some text';
1728
+
1729
+ await controller.handleStreamChunk({ type: 'thinking', content: 'Hmm...' }, msg);
1730
+
1731
+ expect(deps.state.currentTextEl).toBeNull();
1732
+ expect(msg.contentBlocks).toContainEqual(
1733
+ expect.objectContaining({ type: 'text', content: 'Some text' })
1734
+ );
1735
+ expect(deps.renderer.addTextCopyButton).toHaveBeenCalledWith(
1736
+ expect.anything(),
1737
+ 'Some text'
1738
+ );
1739
+ });
1740
+
1741
+ it('tool_use arrives while thinking state → finalizeCurrentThinkingBlock is called', async () => {
1742
+ const { finalizeThinkingBlock } = jest.requireMock('@/features/chat/rendering/ThinkingBlockRenderer');
1743
+ const msg = createTestMessage();
1744
+ deps.state.currentContentEl = createMockEl();
1745
+
1746
+ deps.state.currentThinkingState = {
1747
+ content: 'Reasoning...',
1748
+ container: createMockEl(),
1749
+ contentEl: createMockEl(),
1750
+ startTime: Date.now(),
1751
+ } as any;
1752
+
1753
+ await controller.handleStreamChunk(
1754
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } },
1755
+ msg
1756
+ );
1757
+
1758
+ expect(finalizeThinkingBlock).toHaveBeenCalled();
1759
+ expect(deps.state.currentThinkingState).toBeNull();
1760
+ expect(msg.contentBlocks).toContainEqual(
1761
+ expect.objectContaining({ type: 'thinking', content: 'Reasoning...' })
1762
+ );
1763
+ });
1764
+ });
1765
+
1766
+ describe('Agent output tool use/result', () => {
1767
+ it('TOOL_AGENT_OUTPUT chunk creates tool call and delegates to subagentManager.handleAgentOutputToolUse', async () => {
1768
+ const msg = createTestMessage();
1769
+ deps.state.currentContentEl = createMockEl();
1770
+
1771
+ await controller.handleStreamChunk(
1772
+ { type: 'tool_use', id: 'agent-out-1', name: TOOL_AGENT_OUTPUT, input: { task_id: 'task-1' } },
1773
+ msg
1774
+ );
1775
+
1776
+ expect(deps.subagentManager.handleAgentOutputToolUse).toHaveBeenCalledWith(
1777
+ expect.objectContaining({
1778
+ id: 'agent-out-1',
1779
+ name: TOOL_AGENT_OUTPUT,
1780
+ status: 'running',
1781
+ })
1782
+ );
1783
+ expect(msg.toolCalls).toEqual([]);
1784
+ expect(msg.contentBlocks).toEqual([]);
1785
+ });
1786
+
1787
+ it('Agent output tool result handled via handleAgentOutputToolResult returning true', async () => {
1788
+ const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
1789
+ const msg = createTestMessage();
1790
+ deps.state.currentContentEl = createMockEl();
1791
+
1792
+ (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true);
1793
+ (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce({});
1794
+
1795
+ await controller.handleStreamChunk(
1796
+ { type: 'tool_result', id: 'agent-out-1', content: 'agent result', toolUseResult: { foo: 'bar' } as any },
1797
+ msg
1798
+ );
1799
+
1800
+ expect(deps.subagentManager.handleAgentOutputToolResult).toHaveBeenCalledWith(
1801
+ 'agent-out-1',
1802
+ 'agent result',
1803
+ false,
1804
+ { foo: 'bar' }
1805
+ );
1806
+ expect(updateToolCallResult).not.toHaveBeenCalled();
1807
+ });
1808
+
1809
+ it('async_subagent_result finalizes and hydrates the matching background subagent', async () => {
1810
+ const runtime = deps.getAgentService!() as any;
1811
+ const msg = createTestMessage();
1812
+ deps.state.currentContentEl = createMockEl();
1813
+ const completedSubagent = {
1814
+ id: 'task-1',
1815
+ description: 'Background task',
1816
+ prompt: 'Do work',
1817
+ mode: 'async',
1818
+ status: 'completed',
1819
+ toolCalls: [],
1820
+ isExpanded: false,
1821
+ asyncStatus: 'completed',
1822
+ agentId: 'agent-1',
1823
+ result: 'Notification summary',
1824
+ };
1825
+
1826
+ (deps.subagentManager.handleAsyncSubagentResult as jest.Mock).mockReturnValueOnce(completedSubagent);
1827
+ runtime.loadSubagentFinalResult.mockResolvedValueOnce('Recovered final result');
1828
+
1829
+ await controller.handleStreamChunk(
1830
+ {
1831
+ type: 'async_subagent_result',
1832
+ agentId: 'agent-1',
1833
+ status: 'completed',
1834
+ result: 'Notification summary',
1835
+ } as any,
1836
+ msg
1837
+ );
1838
+
1839
+ expect(deps.subagentManager.handleAsyncSubagentResult).toHaveBeenCalledWith(
1840
+ 'agent-1',
1841
+ 'completed',
1842
+ 'Notification summary'
1843
+ );
1844
+ expect(runtime.loadSubagentToolCalls).toHaveBeenCalledWith('agent-1');
1845
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-1');
1846
+ expect(completedSubagent.result).toBe('Recovered final result');
1847
+ expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent);
1848
+ });
1849
+
1850
+ it('hydrates async subagent tool calls from sidecar during streaming completion', async () => {
1851
+ const runtime = deps.getAgentService!() as any;
1852
+ const msg = createTestMessage();
1853
+ deps.state.currentContentEl = createMockEl();
1854
+
1855
+ const completedSubagent = {
1856
+ id: 'task-1',
1857
+ description: 'Background task',
1858
+ prompt: 'Do work',
1859
+ mode: 'async',
1860
+ status: 'completed',
1861
+ toolCalls: [],
1862
+ isExpanded: false,
1863
+ asyncStatus: 'completed',
1864
+ agentId: 'agent-1',
1865
+ result: 'Done',
1866
+ };
1867
+
1868
+ (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true);
1869
+ (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent);
1870
+ runtime.loadSubagentToolCalls.mockResolvedValueOnce([
1871
+ {
1872
+ id: 'read-1',
1873
+ name: 'Read',
1874
+ input: { file_path: 'notes.md' },
1875
+ status: 'completed',
1876
+ result: 'content',
1877
+ isExpanded: false,
1878
+ },
1879
+ ]);
1880
+
1881
+ await controller.handleStreamChunk(
1882
+ { type: 'tool_result', id: 'agent-out-1', content: 'agent result' },
1883
+ msg
1884
+ );
1885
+
1886
+ expect(runtime.loadSubagentToolCalls).toHaveBeenCalledWith('agent-1');
1887
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-1');
1888
+ expect(completedSubagent.toolCalls).toHaveLength(1);
1889
+ expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent);
1890
+ });
1891
+
1892
+ it('hydrates async subagent final result from sidecar even when tool calls already exist', async () => {
1893
+ const runtime = deps.getAgentService!() as any;
1894
+ const msg = createTestMessage();
1895
+ deps.state.currentContentEl = createMockEl();
1896
+
1897
+ const completedSubagent = {
1898
+ id: 'task-2',
1899
+ description: 'Background task',
1900
+ prompt: 'Do work',
1901
+ mode: 'async',
1902
+ status: 'completed',
1903
+ toolCalls: [
1904
+ {
1905
+ id: 'existing-tool',
1906
+ name: 'Read',
1907
+ input: { file_path: 'notes.md' },
1908
+ status: 'completed',
1909
+ result: 'existing',
1910
+ isExpanded: false,
1911
+ },
1912
+ ],
1913
+ isExpanded: false,
1914
+ asyncStatus: 'completed',
1915
+ agentId: 'agent-2',
1916
+ result: 'Short placeholder',
1917
+ };
1918
+
1919
+ (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true);
1920
+ (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent);
1921
+ runtime.loadSubagentFinalResult.mockResolvedValueOnce('Recovered final result from sidecar');
1922
+
1923
+ await controller.handleStreamChunk(
1924
+ { type: 'tool_result', id: 'agent-out-2', content: 'agent result' },
1925
+ msg
1926
+ );
1927
+
1928
+ expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled();
1929
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledWith('agent-2');
1930
+ expect(completedSubagent.result).toBe('Recovered final result from sidecar');
1931
+ expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent);
1932
+ });
1933
+
1934
+ it('does not retry async subagent final result hydration when sidecar matches current result', async () => {
1935
+ const runtime = deps.getAgentService!() as any;
1936
+ const msg = createTestMessage();
1937
+ deps.state.currentContentEl = createMockEl();
1938
+
1939
+ const completedSubagent = {
1940
+ id: 'task-2b',
1941
+ description: 'Background task',
1942
+ prompt: 'Do work',
1943
+ mode: 'async',
1944
+ status: 'completed',
1945
+ toolCalls: [
1946
+ {
1947
+ id: 'existing-tool',
1948
+ name: 'Read',
1949
+ input: { file_path: 'notes.md' },
1950
+ status: 'completed',
1951
+ result: 'existing',
1952
+ isExpanded: false,
1953
+ },
1954
+ ],
1955
+ isExpanded: false,
1956
+ asyncStatus: 'completed',
1957
+ agentId: 'agent-2b',
1958
+ result: 'Already final',
1959
+ };
1960
+
1961
+ (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true);
1962
+ (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent);
1963
+ runtime.loadSubagentFinalResult.mockResolvedValueOnce('Already final');
1964
+
1965
+ await controller.handleStreamChunk(
1966
+ { type: 'tool_result', id: 'agent-out-2b', content: 'agent result' },
1967
+ msg
1968
+ );
1969
+
1970
+ expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled();
1971
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1);
1972
+ expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled();
1973
+
1974
+ jest.advanceTimersByTime(3000);
1975
+ await Promise.resolve();
1976
+ await Promise.resolve();
1977
+
1978
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1);
1979
+ });
1980
+
1981
+ it('retries async subagent final result hydration when first sidecar read is stale', async () => {
1982
+ const runtime = deps.getAgentService!() as any;
1983
+ const msg = createTestMessage();
1984
+ deps.state.currentContentEl = createMockEl();
1985
+
1986
+ const completedSubagent = {
1987
+ id: 'task-3',
1988
+ description: 'Background task',
1989
+ prompt: 'Do work',
1990
+ mode: 'async',
1991
+ status: 'completed',
1992
+ toolCalls: [
1993
+ {
1994
+ id: 'existing-tool',
1995
+ name: 'Read',
1996
+ input: { file_path: 'notes.md' },
1997
+ status: 'completed',
1998
+ result: 'existing',
1999
+ isExpanded: false,
2000
+ },
2001
+ ],
2002
+ isExpanded: false,
2003
+ asyncStatus: 'completed',
2004
+ agentId: 'agent-3',
2005
+ result: 'Intermediate line',
2006
+ };
2007
+
2008
+ (deps.subagentManager.isLinkedAgentOutputTool as jest.Mock).mockReturnValueOnce(true);
2009
+ (deps.subagentManager.handleAgentOutputToolResult as jest.Mock).mockReturnValueOnce(completedSubagent);
2010
+ runtime.loadSubagentFinalResult
2011
+ .mockResolvedValueOnce(null)
2012
+ .mockResolvedValueOnce('Recovered final result after delayed flush');
2013
+
2014
+ await controller.handleStreamChunk(
2015
+ { type: 'tool_result', id: 'agent-out-3', content: 'agent result' },
2016
+ msg
2017
+ );
2018
+
2019
+ expect(runtime.loadSubagentToolCalls).not.toHaveBeenCalled();
2020
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(1);
2021
+ expect(deps.subagentManager.refreshAsyncSubagent).not.toHaveBeenCalled();
2022
+
2023
+ jest.advanceTimersByTime(200);
2024
+ await Promise.resolve();
2025
+ await Promise.resolve();
2026
+
2027
+ expect(runtime.loadSubagentFinalResult).toHaveBeenCalledTimes(2);
2028
+ expect(completedSubagent.result).toBe('Recovered final result after delayed flush');
2029
+ expect(deps.subagentManager.refreshAsyncSubagent).toHaveBeenCalledWith(completedSubagent);
2030
+ });
2031
+ });
2032
+
2033
+ describe('Tool header update on input re-dispatch', () => {
2034
+ it('second tool_use with same id updates existing tool input and header', async () => {
2035
+ const { getToolName, getToolSummary } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2036
+ const msg = createTestMessage();
2037
+ deps.state.currentContentEl = createMockEl();
2038
+
2039
+ // First tool_use - creates the tool call
2040
+ await controller.handleStreamChunk(
2041
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'test.md' } },
2042
+ msg
2043
+ );
2044
+
2045
+ // Flush the tool so it transitions from pending to rendered
2046
+ await controller.handleStreamChunk({ type: 'done' }, msg);
2047
+
2048
+ // Manually set up a rendered tool element with name + summary children
2049
+ // (the mock renderToolCall doesn't actually populate toolCallElements)
2050
+ const toolEl = createMockEl();
2051
+ const nameChild = toolEl.createDiv({ cls: 'claudian-tool-name' });
2052
+ nameChild.setText('Read');
2053
+ const summaryChild = toolEl.createDiv({ cls: 'claudian-tool-summary' });
2054
+ summaryChild.setText('test.md');
2055
+ deps.state.toolCallElements.set('read-1', toolEl);
2056
+
2057
+ getToolName.mockReturnValueOnce('Read');
2058
+ getToolSummary.mockReturnValueOnce('updated.md');
2059
+
2060
+ // Second tool_use with same id - should update input and header
2061
+ await controller.handleStreamChunk(
2062
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: 'updated.md' } },
2063
+ msg
2064
+ );
2065
+
2066
+ // Input should be merged
2067
+ expect(msg.toolCalls![0].input).toEqual(
2068
+ expect.objectContaining({ file_path: 'updated.md' })
2069
+ );
2070
+ // getToolName/getToolSummary should have been called with updated input
2071
+ expect(getToolName).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' }));
2072
+ expect(getToolSummary).toHaveBeenCalledWith('Read', expect.objectContaining({ file_path: 'updated.md' }));
2073
+ // Header texts should be updated
2074
+ expect(nameChild.textContent).toBe('Read');
2075
+ expect(summaryChild.textContent).toBe('updated.md');
2076
+ });
2077
+ });
2078
+
2079
+ describe('Sync subagent finalization', () => {
2080
+ it('tool_result for a sync subagent calls finalizeSyncSubagent and updates Task toolCall', async () => {
2081
+ const msg = createTestMessage();
2082
+ deps.state.currentContentEl = createMockEl();
2083
+
2084
+ msg.toolCalls = [
2085
+ {
2086
+ id: 'task-1',
2087
+ name: TOOL_TASK,
2088
+ input: { description: 'Do something' },
2089
+ status: 'running',
2090
+ subagent: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false },
2091
+ } as any,
2092
+ ];
2093
+
2094
+ // getSyncSubagent returns a subagent state (indicating this is a sync subagent)
2095
+ (deps.subagentManager.getSyncSubagent as jest.Mock).mockReturnValueOnce({
2096
+ info: { id: 'task-1', description: 'Do something', status: 'running', toolCalls: [], isExpanded: false },
2097
+ });
2098
+
2099
+ await controller.handleStreamChunk(
2100
+ { type: 'tool_result', id: 'task-1', content: 'Task completed successfully' },
2101
+ msg
2102
+ );
2103
+
2104
+ expect(deps.subagentManager.finalizeSyncSubagent).toHaveBeenCalledWith(
2105
+ 'task-1',
2106
+ 'Task completed successfully',
2107
+ false,
2108
+ undefined
2109
+ );
2110
+
2111
+ expect(msg.toolCalls![0].status).toBe('completed');
2112
+ expect(msg.toolCalls![0].result).toBe('Task completed successfully');
2113
+ expect(msg.toolCalls![0].subagent?.status).toBe('completed');
2114
+ expect(msg.toolCalls![0].subagent?.result).toBe('Task completed successfully');
2115
+ });
2116
+ });
2117
+
2118
+ describe('Codex subagent lifecycle', () => {
2119
+ it('renders prompt immediately and final result after wait_agent resolves', async () => {
2120
+ const { createSubagentBlock, finalizeSubagentBlock } = jest.requireMock('@/features/chat/rendering/SubagentRenderer');
2121
+ const msg = createTestMessage();
2122
+ deps.state.currentContentEl = createMockEl();
2123
+ deps.getAgentService = () => ({
2124
+ providerId: 'codex',
2125
+ getCapabilities: jest.fn().mockReturnValue({
2126
+ providerId: 'codex',
2127
+ supportsPlanMode: true,
2128
+ planPathPrefix: '/.codex/plans/',
2129
+ }),
2130
+ }) as any;
2131
+
2132
+ const subagentState = {
2133
+ info: { id: 'spawn-1', description: 'Codex subagent', prompt: '', status: 'running', toolCalls: [] },
2134
+ labelEl: { setText: jest.fn() },
2135
+ };
2136
+ createSubagentBlock.mockReturnValueOnce(subagentState);
2137
+
2138
+ await controller.handleStreamChunk(
2139
+ {
2140
+ type: 'tool_use',
2141
+ id: 'spawn-1',
2142
+ name: TOOL_SPAWN_AGENT,
2143
+ input: { message: 'Inspect utils.ts and return the final patch summary.', model: 'gpt-5.4-mini' },
2144
+ },
2145
+ msg,
2146
+ );
2147
+
2148
+ await controller.handleStreamChunk(
2149
+ {
2150
+ type: 'tool_result',
2151
+ id: 'spawn-1',
2152
+ content: '{"agent_id":"agent-1","nickname":"Zeno"}',
2153
+ },
2154
+ msg,
2155
+ );
2156
+
2157
+ await controller.handleStreamChunk(
2158
+ {
2159
+ type: 'tool_use',
2160
+ id: 'wait-1',
2161
+ name: TOOL_WAIT_AGENT,
2162
+ input: { targets: ['agent-1'], timeout_ms: 30000 },
2163
+ },
2164
+ msg,
2165
+ );
2166
+
2167
+ await controller.handleStreamChunk(
2168
+ {
2169
+ type: 'tool_result',
2170
+ id: 'wait-1',
2171
+ content: '{"status":{"agent-1":{"completed":"Patched utils.ts and verified imports."}},"timed_out":false}',
2172
+ },
2173
+ msg,
2174
+ );
2175
+
2176
+ expect(createSubagentBlock).toHaveBeenCalledWith(
2177
+ expect.anything(),
2178
+ 'spawn-1',
2179
+ expect.objectContaining({
2180
+ description: 'Codex subagent (gpt-5.4-mini)',
2181
+ prompt: 'Inspect utils.ts and return the final patch summary.',
2182
+ }),
2183
+ );
2184
+ expect(subagentState.info.description).toBe('Zeno (gpt-5.4-mini)');
2185
+ expect(finalizeSubagentBlock).toHaveBeenCalledWith(
2186
+ subagentState,
2187
+ 'Patched utils.ts and verified imports.',
2188
+ false,
2189
+ );
2190
+ });
2191
+ });
2192
+
2193
+ describe('Async task tool result', () => {
2194
+ it('tool_result for a pending async task returns true from handleAsyncTaskToolResult', async () => {
2195
+ const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2196
+ const msg = createTestMessage();
2197
+ deps.state.currentContentEl = createMockEl();
2198
+
2199
+ (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true);
2200
+
2201
+ await controller.handleStreamChunk(
2202
+ { type: 'tool_result', id: 'task-1', content: 'Task started in background' },
2203
+ msg
2204
+ );
2205
+
2206
+ expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith(
2207
+ 'task-1',
2208
+ 'Task started in background',
2209
+ undefined,
2210
+ undefined
2211
+ );
2212
+
2213
+ expect(updateToolCallResult).not.toHaveBeenCalled();
2214
+ expect(msg.toolCalls).toEqual([]);
2215
+ });
2216
+
2217
+ it('passes structured toolUseResult through to async Task result handler', async () => {
2218
+ const msg = createTestMessage();
2219
+ deps.state.currentContentEl = createMockEl();
2220
+ (deps.subagentManager.isPendingAsyncTask as jest.Mock).mockReturnValueOnce(true);
2221
+
2222
+ const structured = { data: { agent_id: 'agent-from-structured' } };
2223
+ await controller.handleStreamChunk(
2224
+ { type: 'tool_result', id: 'task-1', content: 'Task started', toolUseResult: structured } as any,
2225
+ msg
2226
+ );
2227
+
2228
+ expect(deps.subagentManager.handleTaskToolResult).toHaveBeenCalledWith(
2229
+ 'task-1',
2230
+ 'Task started',
2231
+ undefined,
2232
+ structured
2233
+ );
2234
+ });
2235
+
2236
+ it('normalizes structured tool_result content before storing it on tool calls', async () => {
2237
+ const { updateToolCallResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2238
+ const msg = createTestMessage();
2239
+ msg.toolCalls = [
2240
+ {
2241
+ id: 'mcp-1',
2242
+ name: 'mcp__stitch__create_project',
2243
+ input: {},
2244
+ status: 'running',
2245
+ isExpanded: false,
2246
+ } as any,
2247
+ ];
2248
+
2249
+ await controller.handleStreamChunk(
2250
+ {
2251
+ type: 'tool_result',
2252
+ id: 'mcp-1',
2253
+ content: [{ type: 'text', text: 'Created project successfully' }],
2254
+ } as any,
2255
+ msg,
2256
+ );
2257
+
2258
+ expect(msg.toolCalls[0].status).toBe('completed');
2259
+ expect(msg.toolCalls[0].result).toBe('Created project successfully');
2260
+ expect(updateToolCallResult).toHaveBeenCalled();
2261
+ });
2262
+ });
2263
+
2264
+ describe('showThinkingIndicator - timer disconnection cleanup', () => {
2265
+ it('should clear interval when timerSpan becomes disconnected from DOM', () => {
2266
+ // Use a non-zero value: with fake timers, performance.now() starts at 0,
2267
+ // and !0 is truthy which would cause updateTimer to return early.
2268
+ jest.advanceTimersByTime(1);
2269
+ deps.state.responseStartTime = performance.now();
2270
+
2271
+ controller.showThinkingIndicator();
2272
+ jest.advanceTimersByTime(500); // Past debounce delay
2273
+
2274
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
2275
+
2276
+ const thinkingEl = deps.state.thinkingEl;
2277
+ expect(thinkingEl).not.toBeNull();
2278
+
2279
+ // The timer span is the second child (first is flavor text, second is hint)
2280
+ const timerSpan = thinkingEl!.children[1];
2281
+ expect(timerSpan).toBeDefined();
2282
+
2283
+ // Mock elements don't have isConnected by default (undefined = falsy),
2284
+ // so first set it to true so the timer runs normally on its first tick.
2285
+ Object.defineProperty(timerSpan, 'isConnected', { value: true, writable: true, configurable: true });
2286
+
2287
+ // Advance time - interval should still run (isConnected is true)
2288
+ jest.advanceTimersByTime(1000);
2289
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
2290
+ // Verify the interval callback actually ran by checking the timer text was updated
2291
+ expect((timerSpan as any).textContent).toContain('esc to interrupt');
2292
+
2293
+ // Now simulate disconnection from DOM
2294
+ (timerSpan as any).isConnected = false;
2295
+
2296
+ // Advance time to trigger the interval callback
2297
+ jest.advanceTimersByTime(1000);
2298
+
2299
+ // Interval should have been cleared because isConnected is false
2300
+ expect(deps.state.flavorTimerInterval).toBeNull();
2301
+ });
2302
+ });
2303
+
2304
+ describe('showThinkingIndicator - pre-existing interval', () => {
2305
+ it('should clear pre-existing interval before creating new one', () => {
2306
+ // Advance fake clock so performance.now() returns non-zero
2307
+ jest.advanceTimersByTime(1);
2308
+ deps.state.responseStartTime = performance.now();
2309
+ const activeWindow = deps.state.currentContentEl!.ownerDocument.defaultView!;
2310
+ const clearIntervalSpy = jest.spyOn(activeWindow, 'clearInterval');
2311
+
2312
+ // Manually set a pre-existing interval
2313
+ deps.state.setFlavorTimerInterval(activeWindow.setInterval(() => {}, 9999), activeWindow);
2314
+
2315
+ controller.showThinkingIndicator();
2316
+ jest.advanceTimersByTime(500);
2317
+
2318
+ // clearInterval should have been called for the pre-existing interval
2319
+ expect(clearIntervalSpy).toHaveBeenCalled();
2320
+
2321
+ // A new interval should have been created
2322
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
2323
+
2324
+ clearIntervalSpy.mockRestore();
2325
+ });
2326
+ });
2327
+
2328
+ describe('appendThinking - no currentContentEl', () => {
2329
+ it('should not create thinking state when currentContentEl is null', async () => {
2330
+ const msg = createTestMessage();
2331
+ deps.state.currentContentEl = null;
2332
+
2333
+ await controller.handleStreamChunk({ type: 'thinking', content: 'test thinking' }, msg);
2334
+
2335
+ // No thinking state should be created
2336
+ expect(deps.state.currentThinkingState).toBeNull();
2337
+ });
2338
+ });
2339
+
2340
+ describe('showThinkingIndicator - responseStartTime null in timer', () => {
2341
+ it('should not update timer text when responseStartTime is null', () => {
2342
+ // Advance fake clock so performance.now() returns non-zero
2343
+ jest.advanceTimersByTime(1);
2344
+ deps.state.responseStartTime = performance.now();
2345
+
2346
+ controller.showThinkingIndicator();
2347
+ jest.advanceTimersByTime(500);
2348
+
2349
+ expect(deps.state.thinkingEl).not.toBeNull();
2350
+
2351
+ // Get timerSpan and set isConnected to true for proper timer operation
2352
+ const timerSpan = deps.state.thinkingEl!.children[1];
2353
+ Object.defineProperty(timerSpan, 'isConnected', { value: true, configurable: true });
2354
+
2355
+ // Clear responseStartTime to trigger early return in updateTimer
2356
+ deps.state.responseStartTime = null;
2357
+
2358
+ // Advance time to trigger timer callback - should not throw
2359
+ jest.advanceTimersByTime(1000);
2360
+
2361
+ // Timer should still be set (interval not cleared by the null check)
2362
+ expect(deps.state.flavorTimerInterval).not.toBeNull();
2363
+ });
2364
+ });
2365
+ });
2366
+
2367
+ describe('StreamController - Plan Mode', () => {
2368
+ let controller: StreamController;
2369
+ let deps: StreamControllerDeps;
2370
+
2371
+ beforeEach(() => {
2372
+ jest.clearAllMocks();
2373
+ jest.useFakeTimers();
2374
+ installTestWindow();
2375
+ deps = createMockDeps();
2376
+ controller = new StreamController(deps);
2377
+ deps.state.currentContentEl = createMockEl();
2378
+ });
2379
+
2380
+ afterEach(() => {
2381
+ deps.state.resetStreamingState();
2382
+ restoreTestWindow();
2383
+ jest.useRealTimers();
2384
+ });
2385
+
2386
+ describe('capturePlanFilePath', () => {
2387
+ it('should capture plan file path from Write tool_use', async () => {
2388
+ const msg = createTestMessage();
2389
+
2390
+ await controller.handleStreamChunk(
2391
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } },
2392
+ msg
2393
+ );
2394
+
2395
+ expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md');
2396
+ });
2397
+
2398
+ it('should capture plan file path with Windows backslashes', async () => {
2399
+ const msg = createTestMessage();
2400
+
2401
+ await controller.handleStreamChunk(
2402
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: 'C:\\.claude\\plans\\plan.md' } },
2403
+ msg
2404
+ );
2405
+
2406
+ expect(deps.state.planFilePath).toBe('C:\\.claude\\plans\\plan.md');
2407
+ });
2408
+
2409
+ it('should not capture non-plan Write paths', async () => {
2410
+ const msg = createTestMessage();
2411
+
2412
+ await controller.handleStreamChunk(
2413
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/notes/todo.md' } },
2414
+ msg
2415
+ );
2416
+
2417
+ expect(deps.state.planFilePath).toBeNull();
2418
+ });
2419
+
2420
+ it('should not capture plan path from non-Write tools', async () => {
2421
+ const msg = createTestMessage();
2422
+
2423
+ await controller.handleStreamChunk(
2424
+ { type: 'tool_use', id: 'read-1', name: 'Read', input: { file_path: '/home/user/.claude/plans/plan.md' } },
2425
+ msg
2426
+ );
2427
+
2428
+ expect(deps.state.planFilePath).toBeNull();
2429
+ });
2430
+
2431
+ it('should capture plan file path on subsequent tool_use input update', async () => {
2432
+ const msg = createTestMessage();
2433
+ msg.toolCalls = [{
2434
+ id: 'write-1',
2435
+ name: 'Write',
2436
+ input: { content: 'plan content' },
2437
+ status: 'running',
2438
+ }];
2439
+
2440
+ // Second tool_use chunk with same ID updates the input (file_path arrives later)
2441
+ await controller.handleStreamChunk(
2442
+ { type: 'tool_use', id: 'write-1', name: 'Write', input: { file_path: '/home/user/.claude/plans/plan.md' } },
2443
+ msg
2444
+ );
2445
+
2446
+ expect(deps.state.planFilePath).toBe('/home/user/.claude/plans/plan.md');
2447
+ });
2448
+ });
2449
+
2450
+ describe('blocked detection bypass', () => {
2451
+ it('should hydrate AskUserQuestion resolvedAnswers from result text fallback', async () => {
2452
+ const coreTools = jest.requireMock('@/core/tools/toolInput');
2453
+ (coreTools.extractResolvedAnswers as jest.Mock).mockReturnValueOnce(undefined);
2454
+ (coreTools.extractResolvedAnswersFromResultText as jest.Mock).mockReturnValueOnce({
2455
+ 'Color?': 'Blue',
2456
+ });
2457
+
2458
+ const msg = createTestMessage();
2459
+ msg.toolCalls = [{
2460
+ id: 'ask-1',
2461
+ name: 'AskUserQuestion',
2462
+ input: { questions: [{ question: 'Color?' }] },
2463
+ status: 'running',
2464
+ }];
2465
+
2466
+ await controller.handleStreamChunk(
2467
+ { type: 'tool_result', id: 'ask-1', content: '"Color?"="Blue"' },
2468
+ msg
2469
+ );
2470
+
2471
+ expect(msg.toolCalls![0].resolvedAnswers).toEqual({ 'Color?': 'Blue' });
2472
+ });
2473
+
2474
+ it('should not mark AskUserQuestion as blocked even when result looks blocked', async () => {
2475
+ const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2476
+ (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true);
2477
+
2478
+ const msg = createTestMessage();
2479
+ msg.toolCalls = [{
2480
+ id: 'ask-1',
2481
+ name: 'AskUserQuestion',
2482
+ input: {},
2483
+ status: 'running',
2484
+ }];
2485
+
2486
+ await controller.handleStreamChunk(
2487
+ { type: 'tool_result', id: 'ask-1', content: 'User denied this action.' },
2488
+ msg
2489
+ );
2490
+
2491
+ expect(msg.toolCalls![0].status).toBe('completed');
2492
+ });
2493
+
2494
+ it('should not mark ExitPlanMode as blocked even when result looks blocked', async () => {
2495
+ const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2496
+ (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true);
2497
+
2498
+ const msg = createTestMessage();
2499
+ msg.toolCalls = [{
2500
+ id: 'exit-1',
2501
+ name: 'ExitPlanMode',
2502
+ input: {},
2503
+ status: 'running',
2504
+ }];
2505
+
2506
+ await controller.handleStreamChunk(
2507
+ { type: 'tool_result', id: 'exit-1', content: 'User denied.' },
2508
+ msg
2509
+ );
2510
+
2511
+ expect(msg.toolCalls![0].status).toBe('completed');
2512
+ });
2513
+
2514
+ it('should mark regular tool as blocked when result is blocked', async () => {
2515
+ const { isBlockedToolResult } = jest.requireMock('@/features/chat/rendering/ToolCallRenderer');
2516
+ (isBlockedToolResult as jest.Mock).mockReturnValueOnce(true);
2517
+
2518
+ const msg = createTestMessage();
2519
+ msg.toolCalls = [{
2520
+ id: 'bash-1',
2521
+ name: 'Bash',
2522
+ input: { command: 'rm -rf /' },
2523
+ status: 'running',
2524
+ }];
2525
+
2526
+ await controller.handleStreamChunk(
2527
+ { type: 'tool_result', id: 'bash-1', content: 'Access denied by user approval' },
2528
+ msg
2529
+ );
2530
+
2531
+ expect(msg.toolCalls![0].status).toBe('blocked');
2532
+ });
2533
+ });
2534
+ });