@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.
- package/.env.local.example +2 -0
- package/.github/workflows/ci.yml +59 -0
- package/.github/workflows/claude-code-review.yml +57 -0
- package/.github/workflows/claude.yml +50 -0
- package/.github/workflows/duplicate-issues.yml +22 -0
- package/.github/workflows/release.yml +73 -0
- package/.github/workflows/stale.yml +21 -0
- package/.node-version +1 -0
- package/AGENTS.md +3 -0
- package/CLAUDE.md +80 -0
- package/LICENSE +21 -0
- package/README.md +190 -0
- package/assets/Preview.png +0 -0
- package/assets/sponsors/MOMA.png +0 -0
- package/bun.lock +1618 -0
- package/esbuild.config.mjs +195 -0
- package/eslint.config.mjs +143 -0
- package/jest.config.js +41 -0
- package/main.js +104915 -0
- package/manifest.json +10 -0
- package/package.json +65 -0
- package/scripts/build-css.mjs +119 -0
- package/scripts/build.mjs +19 -0
- package/scripts/postinstall.mjs +25 -0
- package/scripts/rendererSafeUnref.js +205 -0
- package/scripts/run-jest.js +19 -0
- package/scripts/sync-version.js +16 -0
- package/src/app/settings/ClaudianSettingsStorage.ts +435 -0
- package/src/app/settings/defaultSettings.ts +54 -0
- package/src/app/storage/SharedStorageService.ts +106 -0
- package/src/core/CLAUDE.md +84 -0
- package/src/core/auxiliary/AuxQueryRunner.ts +11 -0
- package/src/core/auxiliary/QueryBackedInlineEditService.ts +73 -0
- package/src/core/auxiliary/QueryBackedInstructionRefineService.ts +78 -0
- package/src/core/auxiliary/QueryBackedTitleGenerationService.ts +90 -0
- package/src/core/bootstrap/SessionStorage.ts +179 -0
- package/src/core/bootstrap/StoragePaths.ts +7 -0
- package/src/core/bootstrap/storage.ts +20 -0
- package/src/core/commands/builtInCommands.ts +141 -0
- package/src/core/mcp/McpConfigParser.ts +102 -0
- package/src/core/mcp/McpServerManager.ts +119 -0
- package/src/core/mcp/McpTester.ts +310 -0
- package/src/core/prompt/inlineEdit.ts +252 -0
- package/src/core/prompt/instructionRefine.ts +72 -0
- package/src/core/prompt/mainAgent.ts +213 -0
- package/src/core/prompt/titleGeneration.ts +44 -0
- package/src/core/providers/ProviderRegistry.ts +253 -0
- package/src/core/providers/ProviderSettingsCoordinator.ts +434 -0
- package/src/core/providers/ProviderWorkspaceRegistry.ts +119 -0
- package/src/core/providers/commands/ProviderCommandCatalog.ts +21 -0
- package/src/core/providers/commands/ProviderCommandEntry.ts +33 -0
- package/src/core/providers/commands/hiddenCommands.ts +74 -0
- package/src/core/providers/modelRouting.ts +17 -0
- package/src/core/providers/modelSelection.ts +72 -0
- package/src/core/providers/providerConfig.ts +34 -0
- package/src/core/providers/providerEnvironment.ts +364 -0
- package/src/core/providers/types.ts +544 -0
- package/src/core/runtime/ChatRuntime.ts +66 -0
- package/src/core/runtime/QueuedTurn.ts +97 -0
- package/src/core/runtime/types.ts +118 -0
- package/src/core/security/ApprovalManager.ts +142 -0
- package/src/core/storage/HomeFileAdapter.ts +75 -0
- package/src/core/storage/VaultFileAdapter.ts +132 -0
- package/src/core/tools/todo.ts +65 -0
- package/src/core/tools/toolIcons.ts +80 -0
- package/src/core/tools/toolInput.ts +119 -0
- package/src/core/tools/toolNames.ts +149 -0
- package/src/core/tools/toolResultContent.ts +26 -0
- package/src/core/types/agent.ts +28 -0
- package/src/core/types/chat.ts +177 -0
- package/src/core/types/diff.ts +31 -0
- package/src/core/types/index.ts +78 -0
- package/src/core/types/mcp.ts +97 -0
- package/src/core/types/plugins.ts +9 -0
- package/src/core/types/provider.ts +1 -0
- package/src/core/types/settings.ts +152 -0
- package/src/core/types/tools.ts +80 -0
- package/src/features/chat/CLAUDE.md +136 -0
- package/src/features/chat/ClaudianView.ts +762 -0
- package/src/features/chat/constants.ts +114 -0
- package/src/features/chat/controllers/BrowserSelectionController.ts +295 -0
- package/src/features/chat/controllers/CanvasSelectionController.ts +142 -0
- package/src/features/chat/controllers/ConversationController.ts +1103 -0
- package/src/features/chat/controllers/InputController.ts +1707 -0
- package/src/features/chat/controllers/NavigationController.ts +209 -0
- package/src/features/chat/controllers/SelectionController.ts +430 -0
- package/src/features/chat/controllers/StreamController.ts +1560 -0
- package/src/features/chat/controllers/contextRowVisibility.ts +18 -0
- package/src/features/chat/rendering/DiffRenderer.ts +134 -0
- package/src/features/chat/rendering/InlineAskUserQuestion.ts +702 -0
- package/src/features/chat/rendering/InlineExitPlanMode.ts +263 -0
- package/src/features/chat/rendering/InlinePlanApproval.ts +183 -0
- package/src/features/chat/rendering/MessageRenderer.ts +907 -0
- package/src/features/chat/rendering/SubagentRenderer.ts +678 -0
- package/src/features/chat/rendering/ThinkingBlockRenderer.ts +126 -0
- package/src/features/chat/rendering/TodoListRenderer.ts +5 -0
- package/src/features/chat/rendering/ToolCallRenderer.ts +1161 -0
- package/src/features/chat/rendering/WriteEditRenderer.ts +232 -0
- package/src/features/chat/rendering/collapsible.ts +101 -0
- package/src/features/chat/rendering/subagentLifecycleResolution.ts +25 -0
- package/src/features/chat/rendering/todoUtils.ts +29 -0
- package/src/features/chat/rewind.ts +31 -0
- package/src/features/chat/services/BangBashService.ts +56 -0
- package/src/features/chat/services/SubagentManager.ts +1107 -0
- package/src/features/chat/state/ChatState.ts +436 -0
- package/src/features/chat/state/types.ts +138 -0
- package/src/features/chat/tabs/Tab.ts +1886 -0
- package/src/features/chat/tabs/TabBar.ts +179 -0
- package/src/features/chat/tabs/TabManager.ts +1021 -0
- package/src/features/chat/tabs/providerResolution.ts +34 -0
- package/src/features/chat/tabs/types.ts +287 -0
- package/src/features/chat/ui/BangBashModeManager.ts +121 -0
- package/src/features/chat/ui/FileContext.ts +385 -0
- package/src/features/chat/ui/ImageContext.ts +366 -0
- package/src/features/chat/ui/InputToolbar.ts +1244 -0
- package/src/features/chat/ui/InstructionModeManager.ts +158 -0
- package/src/features/chat/ui/NavigationSidebar.ts +126 -0
- package/src/features/chat/ui/StatusPanel.ts +589 -0
- package/src/features/chat/ui/file-context/state/FileContextState.ts +83 -0
- package/src/features/chat/ui/file-context/view/FileChipsView.ts +70 -0
- package/src/features/chat/ui/textareaResize.ts +47 -0
- package/src/features/chat/utils/usageInfo.ts +26 -0
- package/src/features/inline-edit/ui/InlineEditModal.ts +895 -0
- package/src/features/inline-edit/ui/inlineEditMarkdownPreview.ts +55 -0
- package/src/features/settings/ClaudianSettings.ts +672 -0
- package/src/features/settings/keyboardNavigation.ts +60 -0
- package/src/features/settings/ui/EnvSnippetManager.ts +430 -0
- package/src/features/settings/ui/EnvironmentSettingsSection.ts +85 -0
- package/src/features/settings/ui/McpServerModal.ts +335 -0
- package/src/features/settings/ui/McpSettingsManager.ts +400 -0
- package/src/features/settings/ui/McpTestModal.ts +346 -0
- package/src/i18n/constants.ts +58 -0
- package/src/i18n/i18n.ts +140 -0
- package/src/i18n/locales/de.json +322 -0
- package/src/i18n/locales/en.json +322 -0
- package/src/i18n/locales/es.json +322 -0
- package/src/i18n/locales/fr.json +322 -0
- package/src/i18n/locales/ja.json +322 -0
- package/src/i18n/locales/ko.json +322 -0
- package/src/i18n/locales/pt.json +322 -0
- package/src/i18n/locales/ru.json +322 -0
- package/src/i18n/locales/zh-CN.json +322 -0
- package/src/i18n/locales/zh-TW.json +322 -0
- package/src/i18n/types.ts +248 -0
- package/src/main.ts +772 -0
- package/src/providers/acp/AcpClientConnection.ts +361 -0
- package/src/providers/acp/AcpJsonRpcTransport.ts +427 -0
- package/src/providers/acp/AcpSessionConfig.ts +139 -0
- package/src/providers/acp/AcpSessionUpdateNormalizer.ts +371 -0
- package/src/providers/acp/AcpSubprocess.ts +155 -0
- package/src/providers/acp/AcpToolStreamAdapter.ts +132 -0
- package/src/providers/acp/buildAcpUsageInfo.ts +41 -0
- package/src/providers/acp/index.ts +9 -0
- package/src/providers/acp/methodNames.ts +50 -0
- package/src/providers/acp/types.ts +566 -0
- package/src/providers/claude/CLAUDE.md +78 -0
- package/src/providers/claude/agents/AgentManager.ts +225 -0
- package/src/providers/claude/agents/AgentStorage.ts +101 -0
- package/src/providers/claude/app/ClaudeWorkspaceServices.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeInlineEditService.ts +122 -0
- package/src/providers/claude/auxiliary/ClaudeInstructionRefineService.ts +90 -0
- package/src/providers/claude/auxiliary/ClaudeTitleGenerationService.ts +129 -0
- package/src/providers/claude/auxiliary/extractAssistantText.ts +28 -0
- package/src/providers/claude/capabilities.ts +17 -0
- package/src/providers/claude/cli/findClaudeCLIPath.ts +261 -0
- package/src/providers/claude/commands/ClaudeCommandCatalog.ts +149 -0
- package/src/providers/claude/commands/probeRuntimeCommands.ts +86 -0
- package/src/providers/claude/env/ClaudeSettingsReconciler.ts +90 -0
- package/src/providers/claude/env/claudeModelEnv.ts +86 -0
- package/src/providers/claude/history/ClaudeConversationHistoryService.ts +446 -0
- package/src/providers/claude/history/ClaudeHistoryStore.ts +170 -0
- package/src/providers/claude/history/sdkAsyncSubagent.ts +92 -0
- package/src/providers/claude/history/sdkBranchFilter.ts +271 -0
- package/src/providers/claude/history/sdkHistoryTypes.ts +61 -0
- package/src/providers/claude/history/sdkMessageParsing.ts +413 -0
- package/src/providers/claude/history/sdkSessionPaths.ts +98 -0
- package/src/providers/claude/history/sdkSubagentSidecar.ts +261 -0
- package/src/providers/claude/hooks/SubagentHooks.ts +31 -0
- package/src/providers/claude/modelLabels.ts +77 -0
- package/src/providers/claude/modelOptions.ts +113 -0
- package/src/providers/claude/modelSelection.ts +17 -0
- package/src/providers/claude/plugins/PluginManager.ts +194 -0
- package/src/providers/claude/prompt/ClaudeTurnEncoder.ts +46 -0
- package/src/providers/claude/registration.ts +39 -0
- package/src/providers/claude/runtime/ClaudeApprovalHandler.ts +153 -0
- package/src/providers/claude/runtime/ClaudeChatRuntime.ts +1812 -0
- package/src/providers/claude/runtime/ClaudeCliResolver.ts +94 -0
- package/src/providers/claude/runtime/ClaudeDynamicUpdates.ts +164 -0
- package/src/providers/claude/runtime/ClaudeMessageChannel.ts +209 -0
- package/src/providers/claude/runtime/ClaudeQueryOptionsBuilder.ts +315 -0
- package/src/providers/claude/runtime/ClaudeRewindService.ts +220 -0
- package/src/providers/claude/runtime/ClaudeSessionManager.ts +92 -0
- package/src/providers/claude/runtime/ClaudeTaskResultInterpreter.ts +172 -0
- package/src/providers/claude/runtime/ClaudeUserMessageFactory.ts +83 -0
- package/src/providers/claude/runtime/claudeColdStartQuery.ts +152 -0
- package/src/providers/claude/runtime/customSpawn.ts +87 -0
- package/src/providers/claude/runtime/types.ts +134 -0
- package/src/providers/claude/sdk/messages.ts +17 -0
- package/src/providers/claude/sdk/toolResultContent.ts +4 -0
- package/src/providers/claude/sdk/typeGuards.ts +14 -0
- package/src/providers/claude/sdk/types.ts +15 -0
- package/src/providers/claude/security/ClaudePermissionUpdates.ts +44 -0
- package/src/providers/claude/settings.ts +138 -0
- package/src/providers/claude/storage/AgentVaultStorage.ts +101 -0
- package/src/providers/claude/storage/CCSettingsStorage.ts +153 -0
- package/src/providers/claude/storage/ClaudianSettingsStorage.ts +6 -0
- package/src/providers/claude/storage/McpStorage.ts +139 -0
- package/src/providers/claude/storage/SessionStorage.ts +5 -0
- package/src/providers/claude/storage/SkillStorage.ts +61 -0
- package/src/providers/claude/storage/SlashCommandStorage.ts +96 -0
- package/src/providers/claude/storage/StorageService.ts +185 -0
- package/src/providers/claude/stream/toolInputStreamState.ts +318 -0
- package/src/providers/claude/stream/transformClaudeMessage.ts +586 -0
- package/src/providers/claude/types/agent.ts +2 -0
- package/src/providers/claude/types/models.ts +168 -0
- package/src/providers/claude/types/plugins.ts +14 -0
- package/src/providers/claude/types/providerState.ts +16 -0
- package/src/providers/claude/types/settings.ts +98 -0
- package/src/providers/claude/ui/AgentSettings.ts +389 -0
- package/src/providers/claude/ui/ClaudeChatUIConfig.ts +113 -0
- package/src/providers/claude/ui/ClaudeSettingsTab.ts +407 -0
- package/src/providers/claude/ui/PluginSettingsManager.ts +149 -0
- package/src/providers/claude/ui/SlashCommandSettings.ts +527 -0
- package/src/providers/codex/CLAUDE.md +64 -0
- package/src/providers/codex/agents/CodexAgentMentionProvider.ts +33 -0
- package/src/providers/codex/app/CodexWorkspaceServices.ts +76 -0
- package/src/providers/codex/auxiliary/CodexInlineEditService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexInstructionRefineService.ts +9 -0
- package/src/providers/codex/auxiliary/CodexTaskResultInterpreter.ts +29 -0
- package/src/providers/codex/auxiliary/CodexTitleGenerationService.ts +22 -0
- package/src/providers/codex/capabilities.ts +16 -0
- package/src/providers/codex/commands/CodexSkillCatalog.ts +180 -0
- package/src/providers/codex/env/CodexSettingsReconciler.ts +68 -0
- package/src/providers/codex/history/CodexConversationHistoryService.ts +212 -0
- package/src/providers/codex/history/CodexHistoryStore.ts +1672 -0
- package/src/providers/codex/modelOptions.ts +99 -0
- package/src/providers/codex/modelSelection.ts +17 -0
- package/src/providers/codex/normalization/codexSubagentNormalization.ts +227 -0
- package/src/providers/codex/normalization/codexToolNormalization.ts +390 -0
- package/src/providers/codex/prompt/encodeCodexTurn.ts +57 -0
- package/src/providers/codex/registration.ts +29 -0
- package/src/providers/codex/runtime/CodexAppServerProcess.ts +105 -0
- package/src/providers/codex/runtime/CodexAuxQueryRunner.ts +180 -0
- package/src/providers/codex/runtime/CodexBinaryLocator.ts +49 -0
- package/src/providers/codex/runtime/CodexChatRuntime.ts +1296 -0
- package/src/providers/codex/runtime/CodexCliResolver.ts +65 -0
- package/src/providers/codex/runtime/CodexExecutionTargetResolver.ts +104 -0
- package/src/providers/codex/runtime/CodexLaunchSpecBuilder.ts +85 -0
- package/src/providers/codex/runtime/CodexNotificationRouter.ts +1033 -0
- package/src/providers/codex/runtime/CodexPathMapper.ts +155 -0
- package/src/providers/codex/runtime/CodexRpcTransport.ts +171 -0
- package/src/providers/codex/runtime/CodexRuntimeContext.ts +109 -0
- package/src/providers/codex/runtime/CodexServerRequestRouter.ts +331 -0
- package/src/providers/codex/runtime/CodexSessionFileTail.ts +792 -0
- package/src/providers/codex/runtime/CodexSessionManager.ts +39 -0
- package/src/providers/codex/runtime/codexAppServerSupport.ts +58 -0
- package/src/providers/codex/runtime/codexAppServerTypes.ts +705 -0
- package/src/providers/codex/runtime/codexLaunchTypes.ts +30 -0
- package/src/providers/codex/settings.ts +236 -0
- package/src/providers/codex/skills/CodexSkillListingService.ts +173 -0
- package/src/providers/codex/storage/CodexSkillStorage.ts +250 -0
- package/src/providers/codex/storage/CodexSubagentStorage.ts +212 -0
- package/src/providers/codex/types/index.ts +16 -0
- package/src/providers/codex/types/models.ts +46 -0
- package/src/providers/codex/types/subagent.ts +23 -0
- package/src/providers/codex/ui/CodexChatUIConfig.ts +128 -0
- package/src/providers/codex/ui/CodexSettingsTab.ts +432 -0
- package/src/providers/codex/ui/CodexSkillSettings.ts +275 -0
- package/src/providers/codex/ui/CodexSubagentSettings.ts +400 -0
- package/src/providers/defaultProviderConfigs.ts +14 -0
- package/src/providers/index.ts +30 -0
- package/src/providers/opencode/agents/OpencodeAgentMentionProvider.ts +42 -0
- package/src/providers/opencode/app/OpencodeRuntimeCommandLoader.ts +67 -0
- package/src/providers/opencode/app/OpencodeWorkspaceServices.ts +55 -0
- package/src/providers/opencode/auxiliary/OpencodeInlineEditService.ts +13 -0
- package/src/providers/opencode/auxiliary/OpencodeInstructionRefineService.ts +12 -0
- package/src/providers/opencode/auxiliary/OpencodeTaskResultInterpreter.ts +29 -0
- package/src/providers/opencode/auxiliary/OpencodeTitleGenerationService.ts +27 -0
- package/src/providers/opencode/capabilities.ts +16 -0
- package/src/providers/opencode/commands/OpencodeCommandCatalog.ts +92 -0
- package/src/providers/opencode/discoveryState.ts +135 -0
- package/src/providers/opencode/env/OpencodeSettingsReconciler.ts +178 -0
- package/src/providers/opencode/history/OpencodeConversationHistoryService.ts +84 -0
- package/src/providers/opencode/history/OpencodeHistoryStore.ts +472 -0
- package/src/providers/opencode/history/OpencodeSqliteReader.ts +284 -0
- package/src/providers/opencode/internal/compareCollections.ts +72 -0
- package/src/providers/opencode/internal/providerProjection.ts +15 -0
- package/src/providers/opencode/models.ts +378 -0
- package/src/providers/opencode/modes.ts +150 -0
- package/src/providers/opencode/normalization/opencodeToolNormalization.ts +406 -0
- package/src/providers/opencode/registration.ts +27 -0
- package/src/providers/opencode/runtime/OpencodeAuxQueryRunner.ts +436 -0
- package/src/providers/opencode/runtime/OpencodeChatRuntime.ts +1603 -0
- package/src/providers/opencode/runtime/OpencodeCliResolver.ts +57 -0
- package/src/providers/opencode/runtime/OpencodeLaunchArtifacts.ts +231 -0
- package/src/providers/opencode/runtime/OpencodePaths.ts +113 -0
- package/src/providers/opencode/runtime/OpencodeRuntimeEnvironment.ts +18 -0
- package/src/providers/opencode/runtime/buildOpencodePrompt.ts +66 -0
- package/src/providers/opencode/settings.ts +427 -0
- package/src/providers/opencode/storage/OpencodeAgentStorage.ts +346 -0
- package/src/providers/opencode/types/agent.ts +37 -0
- package/src/providers/opencode/types/index.ts +9 -0
- package/src/providers/opencode/ui/OpencodeAgentSettings.ts +579 -0
- package/src/providers/opencode/ui/OpencodeChatUIConfig.ts +316 -0
- package/src/providers/opencode/ui/OpencodeSettingsTab.ts +674 -0
- package/src/providers/pi/app/PiRuntimeCommandLoader.ts +57 -0
- package/src/providers/pi/app/PiWorkspaceServices.ts +39 -0
- package/src/providers/pi/auxiliary/PiInlineEditService.ts +9 -0
- package/src/providers/pi/auxiliary/PiInstructionRefineService.ts +9 -0
- package/src/providers/pi/auxiliary/PiTaskResultInterpreter.ts +29 -0
- package/src/providers/pi/auxiliary/PiTitleGenerationService.ts +19 -0
- package/src/providers/pi/capabilities.ts +16 -0
- package/src/providers/pi/commands/PiCommandCatalog.ts +92 -0
- package/src/providers/pi/env/PiSettingsReconciler.ts +180 -0
- package/src/providers/pi/history/PiConversationHistoryService.ts +123 -0
- package/src/providers/pi/history/PiHistoryStore.ts +664 -0
- package/src/providers/pi/internal/compareCollections.ts +4 -0
- package/src/providers/pi/internal/providerProjection.ts +18 -0
- package/src/providers/pi/models.ts +302 -0
- package/src/providers/pi/normalizations/piEventNormalization.ts +211 -0
- package/src/providers/pi/normalizations/piToolNormalization.ts +97 -0
- package/src/providers/pi/registration.ts +30 -0
- package/src/providers/pi/runtime/PiAuxQueryRunner.ts +216 -0
- package/src/providers/pi/runtime/PiChatRuntime.ts +1064 -0
- package/src/providers/pi/runtime/PiCliResolver.ts +53 -0
- package/src/providers/pi/runtime/PiExtensionUiBridge.ts +161 -0
- package/src/providers/pi/runtime/PiJsonl.ts +71 -0
- package/src/providers/pi/runtime/PiLaunchSpec.ts +70 -0
- package/src/providers/pi/runtime/PiModelDiscoveryService.ts +92 -0
- package/src/providers/pi/runtime/PiRpcPayloads.ts +18 -0
- package/src/providers/pi/runtime/PiRpcTransport.ts +243 -0
- package/src/providers/pi/runtime/PiSubprocess.ts +159 -0
- package/src/providers/pi/runtime/buildPiPrompt.ts +62 -0
- package/src/providers/pi/runtime/buildPiUsageInfo.ts +69 -0
- package/src/providers/pi/settings.ts +468 -0
- package/src/providers/pi/types.ts +64 -0
- package/src/providers/pi/ui/ObsidianPiExtensionUiRenderer.ts +251 -0
- package/src/providers/pi/ui/PiChatUIConfig.ts +265 -0
- package/src/providers/pi/ui/PiExtensionUiRenderer.ts +12 -0
- package/src/providers/pi/ui/PiSettingsTab.ts +642 -0
- package/src/shared/components/ResumeSessionDropdown.ts +185 -0
- package/src/shared/components/SelectableDropdown.ts +140 -0
- package/src/shared/components/SelectionHighlight.ts +77 -0
- package/src/shared/components/SlashCommandDropdown.ts +421 -0
- package/src/shared/icons.ts +180 -0
- package/src/shared/mention/MentionDropdownController.ts +627 -0
- package/src/shared/mention/VaultMentionCache.ts +106 -0
- package/src/shared/mention/VaultMentionDataProvider.ts +51 -0
- package/src/shared/mention/types.ts +67 -0
- package/src/shared/modals/ConfirmModal.ts +60 -0
- package/src/shared/modals/ForkTargetModal.ts +47 -0
- package/src/shared/modals/InstructionConfirmModal.ts +281 -0
- package/src/style/CLAUDE.md +49 -0
- package/src/style/accessibility.css +40 -0
- package/src/style/base/animations.css +44 -0
- package/src/style/base/container.css +20 -0
- package/src/style/base/variables.css +46 -0
- package/src/style/base/visibility.css +15 -0
- package/src/style/components/code.css +97 -0
- package/src/style/components/context-footer.css +76 -0
- package/src/style/components/header.css +27 -0
- package/src/style/components/history.css +221 -0
- package/src/style/components/input.css +312 -0
- package/src/style/components/messages.css +262 -0
- package/src/style/components/nav-sidebar.css +58 -0
- package/src/style/components/status-panel.css +202 -0
- package/src/style/components/subagent.css +248 -0
- package/src/style/components/tabs.css +112 -0
- package/src/style/components/thinking.css +88 -0
- package/src/style/components/toolcalls.css +278 -0
- package/src/style/features/ask-user-question.css +315 -0
- package/src/style/features/diff.css +197 -0
- package/src/style/features/file-context.css +188 -0
- package/src/style/features/file-link.css +22 -0
- package/src/style/features/image-context.css +179 -0
- package/src/style/features/image-embed.css +40 -0
- package/src/style/features/image-modal.css +52 -0
- package/src/style/features/inline-edit.css +278 -0
- package/src/style/features/plan-mode.css +103 -0
- package/src/style/features/resume-session.css +119 -0
- package/src/style/features/slash-commands.css +91 -0
- package/src/style/index.css +63 -0
- package/src/style/modals/fork-target.css +21 -0
- package/src/style/modals/instruction.css +161 -0
- package/src/style/modals/mcp-modal.css +241 -0
- package/src/style/settings/agent-settings.css +2 -0
- package/src/style/settings/base.css +300 -0
- package/src/style/settings/env-snippets.css +366 -0
- package/src/style/settings/mcp-settings.css +211 -0
- package/src/style/settings/plugin-settings.css +164 -0
- package/src/style/settings/provider-model-picker.css +367 -0
- package/src/style/settings/slash-settings.css +16 -0
- package/src/style/toolbar/external-context.css +177 -0
- package/src/style/toolbar/mcp-selector.css +176 -0
- package/src/style/toolbar/mode-selector.css +19 -0
- package/src/style/toolbar/model-selector.css +99 -0
- package/src/style/toolbar/permission-toggle.css +56 -0
- package/src/style/toolbar/service-tier-toggle.css +39 -0
- package/src/style/toolbar/thinking-selector.css +83 -0
- package/src/types/smol-toml.d.ts +4 -0
- package/src/utils/agent.ts +50 -0
- package/src/utils/animationFrame.ts +46 -0
- package/src/utils/browser.ts +46 -0
- package/src/utils/canvas.ts +14 -0
- package/src/utils/cliBinaryLocator.ts +97 -0
- package/src/utils/context.ts +117 -0
- package/src/utils/contextMentionResolver.ts +154 -0
- package/src/utils/date.ts +31 -0
- package/src/utils/diff.ts +384 -0
- package/src/utils/editor.ts +104 -0
- package/src/utils/electronCompat.ts +53 -0
- package/src/utils/env.ts +465 -0
- package/src/utils/externalContext.ts +143 -0
- package/src/utils/externalContextScanner.ts +135 -0
- package/src/utils/fileLink.ts +263 -0
- package/src/utils/frontmatter.ts +194 -0
- package/src/utils/imageEmbed.ts +139 -0
- package/src/utils/inlineEdit.ts +22 -0
- package/src/utils/interrupt.ts +23 -0
- package/src/utils/markdown.ts +25 -0
- package/src/utils/markdownMath.ts +130 -0
- package/src/utils/mcp.ts +96 -0
- package/src/utils/obsidianCompat.ts +23 -0
- package/src/utils/path.ts +342 -0
- package/src/utils/session.ts +240 -0
- package/src/utils/slashCommand.ts +152 -0
- package/src/utils/subagentJsonl.ts +52 -0
- package/src/utils/windowsCmdShim.ts +98 -0
- package/tests/__mocks__/claude-agent-sdk.ts +317 -0
- package/tests/__mocks__/codex-sdk.ts +88 -0
- package/tests/__mocks__/obsidian.ts +434 -0
- package/tests/helpers/mockElement.ts +403 -0
- package/tests/helpers/sdkMessages.ts +291 -0
- package/tests/integration/core/agent/ClaudianService.test.ts +1845 -0
- package/tests/integration/core/mcp/mcp.test.ts +905 -0
- package/tests/integration/features/chat/imagePersistence.test.ts +38 -0
- package/tests/integration/main.test.ts +1701 -0
- package/tests/setupWindow.ts +26 -0
- package/tests/tsconfig.json +7 -0
- package/tests/unit/core/commands/builtInCommands.test.ts +239 -0
- package/tests/unit/core/mcp/McpServerManager.test.ts +405 -0
- package/tests/unit/core/mcp/McpTester.test.ts +282 -0
- package/tests/unit/core/mcp/createNodeFetch.test.ts +188 -0
- package/tests/unit/core/providers/ProviderRegistry.test.ts +275 -0
- package/tests/unit/core/providers/ProviderSettingsCoordinator.test.ts +490 -0
- package/tests/unit/core/providers/ProviderWorkspaceRegistry.test.ts +84 -0
- package/tests/unit/core/providers/modelRouting.test.ts +91 -0
- package/tests/unit/core/providers/modelSelection.test.ts +155 -0
- package/tests/unit/core/providers/providerEnvironment.test.ts +162 -0
- package/tests/unit/core/providers/tabLifecycle.test.ts +217 -0
- package/tests/unit/core/security/ApprovalManager.test.ts +152 -0
- package/tests/unit/core/storage/VaultFileAdapter.test.ts +535 -0
- package/tests/unit/core/tools/todo.test.ts +227 -0
- package/tests/unit/core/tools/toolIcons.test.ts +75 -0
- package/tests/unit/core/tools/toolInput.test.ts +350 -0
- package/tests/unit/core/tools/toolNames.test.ts +464 -0
- package/tests/unit/core/types/mcp.test.ts +115 -0
- package/tests/unit/features/chat/ClaudianView.test.ts +404 -0
- package/tests/unit/features/chat/controllers/BrowserSelectionController.test.ts +179 -0
- package/tests/unit/features/chat/controllers/CanvasSelectionController.test.ts +216 -0
- package/tests/unit/features/chat/controllers/ConversationController.test.ts +2764 -0
- package/tests/unit/features/chat/controllers/InputController.test.ts +3188 -0
- package/tests/unit/features/chat/controllers/NavigationController.test.ts +640 -0
- package/tests/unit/features/chat/controllers/SelectionController.test.ts +695 -0
- package/tests/unit/features/chat/controllers/StreamController.test.ts +2534 -0
- package/tests/unit/features/chat/controllers/contextRowVisibility.test.ts +46 -0
- package/tests/unit/features/chat/controllers/index.test.ts +16 -0
- package/tests/unit/features/chat/rendering/DiffRenderer.test.ts +355 -0
- package/tests/unit/features/chat/rendering/InlineAskUserQuestion.test.ts +1035 -0
- package/tests/unit/features/chat/rendering/InlineExitPlanMode.test.ts +191 -0
- package/tests/unit/features/chat/rendering/InlinePlanApproval.test.ts +126 -0
- package/tests/unit/features/chat/rendering/MessageRenderer.test.ts +2004 -0
- package/tests/unit/features/chat/rendering/SubagentRenderer.test.ts +917 -0
- package/tests/unit/features/chat/rendering/ThinkingBlockRenderer.test.ts +124 -0
- package/tests/unit/features/chat/rendering/TodoListRenderer.test.ts +173 -0
- package/tests/unit/features/chat/rendering/ToolCallRenderer.test.ts +909 -0
- package/tests/unit/features/chat/rendering/WriteEditRenderer.test.ts +474 -0
- package/tests/unit/features/chat/rendering/collapsible.test.ts +158 -0
- package/tests/unit/features/chat/rendering/todoUtils.test.ts +105 -0
- package/tests/unit/features/chat/rewind.test.ts +56 -0
- package/tests/unit/features/chat/services/BangBashService.test.ts +142 -0
- package/tests/unit/features/chat/services/InstructionRefineService.test.ts +371 -0
- package/tests/unit/features/chat/services/SubagentManager.test.ts +1759 -0
- package/tests/unit/features/chat/services/TitleGenerationService.test.ts +480 -0
- package/tests/unit/features/chat/state/ChatState.test.ts +581 -0
- package/tests/unit/features/chat/tabs/Tab.test.ts +4287 -0
- package/tests/unit/features/chat/tabs/TabBar.test.ts +357 -0
- package/tests/unit/features/chat/tabs/TabManager.test.ts +2962 -0
- package/tests/unit/features/chat/tabs/index.test.ts +11 -0
- package/tests/unit/features/chat/ui/BangBashModeManager.test.ts +321 -0
- package/tests/unit/features/chat/ui/ExternalContextSelector.test.ts +555 -0
- package/tests/unit/features/chat/ui/FileContextManager.test.ts +876 -0
- package/tests/unit/features/chat/ui/ImageContext.test.ts +777 -0
- package/tests/unit/features/chat/ui/InputToolbar.test.ts +1139 -0
- package/tests/unit/features/chat/ui/InstructionModeManager.test.ts +243 -0
- package/tests/unit/features/chat/ui/NavigationSidebar.test.ts +570 -0
- package/tests/unit/features/chat/ui/StatusPanel.test.ts +953 -0
- package/tests/unit/features/chat/ui/file-context/state/FileContextState.test.ts +155 -0
- package/tests/unit/features/chat/ui/textareaResize.test.ts +102 -0
- package/tests/unit/features/chat/utils/usageInfo.test.ts +56 -0
- package/tests/unit/features/inline-edit/InlineEditService.test.ts +1199 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.openAndWait.test.ts +1482 -0
- package/tests/unit/features/inline-edit/ui/InlineEditModal.test.ts +495 -0
- package/tests/unit/features/inline-edit/ui/inlineEditMarkdownPreview.test.ts +92 -0
- package/tests/unit/features/settings/AgentSettings.test.ts +82 -0
- package/tests/unit/features/settings/keyboardNavigation.test.ts +73 -0
- package/tests/unit/features/settings/ui/CodexSkillSettings.test.ts +294 -0
- package/tests/unit/features/settings/ui/CodexSubagentSettings.test.ts +207 -0
- package/tests/unit/i18n/constants.test.ts +43 -0
- package/tests/unit/i18n/i18n.test.ts +244 -0
- package/tests/unit/i18n/locales.test.ts +134 -0
- package/tests/unit/providers/acp/AcpClientConnection.test.ts +248 -0
- package/tests/unit/providers/acp/AcpJsonRpcTransport.test.ts +186 -0
- package/tests/unit/providers/acp/AcpSessionConfig.test.ts +247 -0
- package/tests/unit/providers/acp/AcpSessionUpdateNormalizer.test.ts +145 -0
- package/tests/unit/providers/acp/AcpSubprocess.test.ts +105 -0
- package/tests/unit/providers/acp/buildAcpUsageInfo.test.ts +51 -0
- package/tests/unit/providers/claude/agents/AgentManager.test.ts +590 -0
- package/tests/unit/providers/claude/agents/AgentStorage.test.ts +434 -0
- package/tests/unit/providers/claude/agents/index.test.ts +10 -0
- package/tests/unit/providers/claude/commands/ClaudeCommandCatalog.test.ts +396 -0
- package/tests/unit/providers/claude/commands/probeRuntimeCommands.test.ts +92 -0
- package/tests/unit/providers/claude/env/ClaudeSettingsReconciler.test.ts +57 -0
- package/tests/unit/providers/claude/env/claudeModelEnv.test.ts +228 -0
- package/tests/unit/providers/claude/hooks/SubagentHooks.test.ts +83 -0
- package/tests/unit/providers/claude/plugins/PluginManager.test.ts +832 -0
- package/tests/unit/providers/claude/plugins/index.test.ts +7 -0
- package/tests/unit/providers/claude/prompt/ClaudeTurnEncoder.test.ts +145 -0
- package/tests/unit/providers/claude/prompt/instructionRefine.test.ts +185 -0
- package/tests/unit/providers/claude/prompt/systemPrompt.test.ts +163 -0
- package/tests/unit/providers/claude/prompt/titleGeneration.test.ts +20 -0
- package/tests/unit/providers/claude/runtime/ClaudeTaskResultInterpreter.test.ts +28 -0
- package/tests/unit/providers/claude/runtime/ClaudianService.test.ts +3796 -0
- package/tests/unit/providers/claude/runtime/MessageChannel.test.ts +421 -0
- package/tests/unit/providers/claude/runtime/QueryOptionsBuilder.test.ts +775 -0
- package/tests/unit/providers/claude/runtime/SessionManager.test.ts +182 -0
- package/tests/unit/providers/claude/runtime/claudeColdStartQuery.test.ts +331 -0
- package/tests/unit/providers/claude/runtime/customSpawn.test.ts +374 -0
- package/tests/unit/providers/claude/runtime/index.test.ts +13 -0
- package/tests/unit/providers/claude/runtime/types.test.ts +190 -0
- package/tests/unit/providers/claude/sdk/typeGuards.test.ts +50 -0
- package/tests/unit/providers/claude/security/ClaudePermissionUpdates.test.ts +198 -0
- package/tests/unit/providers/claude/storage/AgentVaultStorage.test.ts +413 -0
- package/tests/unit/providers/claude/storage/CCSettingsStorage.test.ts +408 -0
- package/tests/unit/providers/claude/storage/ClaudianSettingsStorage.test.ts +653 -0
- package/tests/unit/providers/claude/storage/McpStorage.test.ts +619 -0
- package/tests/unit/providers/claude/storage/SessionStorage.test.ts +680 -0
- package/tests/unit/providers/claude/storage/SkillStorage.test.ts +275 -0
- package/tests/unit/providers/claude/storage/SlashCommandStorage.test.ts +612 -0
- package/tests/unit/providers/claude/storage/storage.test.ts +360 -0
- package/tests/unit/providers/claude/storage/storageService.convenience.test.ts +447 -0
- package/tests/unit/providers/claude/stream/transformSDKMessage.test.ts +1729 -0
- package/tests/unit/providers/claude/types/types.test.ts +726 -0
- package/tests/unit/providers/claude/ui/ClaudeChatUIConfig.test.ts +173 -0
- package/tests/unit/providers/claude/ui/ClaudeSettingsTab.test.ts +466 -0
- package/tests/unit/providers/codex/agents/CodexAgentMentionProvider.test.ts +89 -0
- package/tests/unit/providers/codex/auxiliary/CodexInstructionRefineService.test.ts +81 -0
- package/tests/unit/providers/codex/capabilities.test.ts +39 -0
- package/tests/unit/providers/codex/commands/CodexSkillCatalog.test.ts +413 -0
- package/tests/unit/providers/codex/env/CodexSettingsReconciler.test.ts +106 -0
- package/tests/unit/providers/codex/fixtures/codex-session-abort.jsonl +9 -0
- package/tests/unit/providers/codex/fixtures/codex-session-agent-lifecycle.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-persisted-tools.jsonl +15 -0
- package/tests/unit/providers/codex/fixtures/codex-session-simple.jsonl +10 -0
- package/tests/unit/providers/codex/fixtures/codex-session-tools.jsonl +12 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch-persisted.jsonl +3 -0
- package/tests/unit/providers/codex/fixtures/codex-session-websearch.jsonl +7 -0
- package/tests/unit/providers/codex/history/CodexConversationHistoryService.test.ts +690 -0
- package/tests/unit/providers/codex/history/CodexHistoryStore.test.ts +2202 -0
- package/tests/unit/providers/codex/normalization/codexSubagentNormalization.test.ts +81 -0
- package/tests/unit/providers/codex/normalization/codexToolNormalization.test.ts +322 -0
- package/tests/unit/providers/codex/prompt/encodeCodexTurn.test.ts +168 -0
- package/tests/unit/providers/codex/runtime/CodexAppServerProcess.test.ts +255 -0
- package/tests/unit/providers/codex/runtime/CodexAuxQueryRunner.test.ts +130 -0
- package/tests/unit/providers/codex/runtime/CodexBinaryLocator.test.ts +90 -0
- package/tests/unit/providers/codex/runtime/CodexChatRuntime.test.ts +2445 -0
- package/tests/unit/providers/codex/runtime/CodexCliResolver.test.ts +103 -0
- package/tests/unit/providers/codex/runtime/CodexExecutionTargetResolver.test.ts +105 -0
- package/tests/unit/providers/codex/runtime/CodexLaunchSpecBuilder.test.ts +150 -0
- package/tests/unit/providers/codex/runtime/CodexNotificationRouter.test.ts +1248 -0
- package/tests/unit/providers/codex/runtime/CodexPathMapper.test.ts +51 -0
- package/tests/unit/providers/codex/runtime/CodexRpcTransport.test.ts +220 -0
- package/tests/unit/providers/codex/runtime/CodexRuntimeContext.test.ts +107 -0
- package/tests/unit/providers/codex/runtime/CodexServerRequestRouter.test.ts +537 -0
- package/tests/unit/providers/codex/runtime/CodexSessionFileTail.test.ts +1305 -0
- package/tests/unit/providers/codex/runtime/CodexSessionManager.test.ts +82 -0
- package/tests/unit/providers/codex/runtime/codexAppServerTypes.test.ts +336 -0
- package/tests/unit/providers/codex/settings.test.ts +189 -0
- package/tests/unit/providers/codex/skills/CodexSkillListingService.test.ts +178 -0
- package/tests/unit/providers/codex/storage/CodexSkillStorage.test.ts +342 -0
- package/tests/unit/providers/codex/storage/CodexSubagentStorage.test.ts +376 -0
- package/tests/unit/providers/codex/ui/CodexChatUIConfig.test.ts +211 -0
- package/tests/unit/providers/codex/ui/CodexSettingsTab.test.ts +578 -0
- package/tests/unit/providers/defaultProviderConfigs.test.ts +18 -0
- package/tests/unit/providers/opencode/OpencodeAuxQueryRunner.test.ts +355 -0
- package/tests/unit/providers/opencode/OpencodeChatRuntime.test.ts +808 -0
- package/tests/unit/providers/opencode/OpencodeCliResolver.test.ts +93 -0
- package/tests/unit/providers/opencode/OpencodeCommandCatalog.test.ts +75 -0
- package/tests/unit/providers/opencode/OpencodeConversationHistoryService.test.ts +103 -0
- package/tests/unit/providers/opencode/OpencodeHistoryStore.test.ts +418 -0
- package/tests/unit/providers/opencode/OpencodeLaunchArtifacts.test.ts +268 -0
- package/tests/unit/providers/opencode/OpencodePaths.test.ts +35 -0
- package/tests/unit/providers/opencode/OpencodeRuntimeCommandLoader.test.ts +129 -0
- package/tests/unit/providers/opencode/OpencodeSettingsReconciler.test.ts +96 -0
- package/tests/unit/providers/opencode/OpencodeSettingsTab.test.ts +649 -0
- package/tests/unit/providers/opencode/OpencodeSqliteReader.test.ts +185 -0
- package/tests/unit/providers/opencode/agents/OpencodeAgentMentionProvider.test.ts +56 -0
- package/tests/unit/providers/opencode/buildOpencodePrompt.test.ts +87 -0
- package/tests/unit/providers/opencode/capabilities.test.ts +39 -0
- package/tests/unit/providers/opencode/models.test.ts +273 -0
- package/tests/unit/providers/opencode/modes.test.ts +151 -0
- package/tests/unit/providers/opencode/opencodeToolNormalization.test.ts +197 -0
- package/tests/unit/providers/opencode/settings.test.ts +469 -0
- package/tests/unit/providers/opencode/storage/OpencodeAgentStorage.test.ts +377 -0
- package/tests/unit/providers/opencode/ui/OpencodeAgentSettings.test.ts +91 -0
- package/tests/unit/providers/pi/PiRuntimeCommandLoader.test.ts +149 -0
- package/tests/unit/providers/pi/capabilities.test.ts +20 -0
- package/tests/unit/providers/pi/commands/PiCommandCatalog.test.ts +69 -0
- package/tests/unit/providers/pi/env/PiSettingsReconciler.test.ts +61 -0
- package/tests/unit/providers/pi/history/PiConversationHistoryService.test.ts +147 -0
- package/tests/unit/providers/pi/history/PiHistoryStore.test.ts +523 -0
- package/tests/unit/providers/pi/models.test.ts +96 -0
- package/tests/unit/providers/pi/registration.test.ts +54 -0
- package/tests/unit/providers/pi/runtime/PiAuxQueryRunner.test.ts +172 -0
- package/tests/unit/providers/pi/runtime/PiChatRuntime.test.ts +830 -0
- package/tests/unit/providers/pi/runtime/PiCliResolver.test.ts +105 -0
- package/tests/unit/providers/pi/runtime/PiEventNormalization.test.ts +147 -0
- package/tests/unit/providers/pi/runtime/PiExtensionUiBridge.test.ts +98 -0
- package/tests/unit/providers/pi/runtime/PiJsonl.test.ts +42 -0
- package/tests/unit/providers/pi/runtime/PiLaunchSpec.test.ts +92 -0
- package/tests/unit/providers/pi/runtime/PiModelDiscoveryService.test.ts +135 -0
- package/tests/unit/providers/pi/runtime/PiRpcPayloads.test.ts +15 -0
- package/tests/unit/providers/pi/runtime/PiRpcTransport.test.ts +116 -0
- package/tests/unit/providers/pi/runtime/PiSubprocess.test.ts +151 -0
- package/tests/unit/providers/pi/runtime/buildPiPrompt.test.ts +84 -0
- package/tests/unit/providers/pi/runtime/buildPiUsageInfo.test.ts +69 -0
- package/tests/unit/providers/pi/settings.test.ts +253 -0
- package/tests/unit/providers/pi/ui/PiChatUIConfig.test.ts +161 -0
- package/tests/unit/providers/pi/ui/PiSettingsTab.test.ts +492 -0
- package/tests/unit/scripts/rendererSafeUnref.test.ts +101 -0
- package/tests/unit/shared/components/ResumeSessionDropdown.test.ts +356 -0
- package/tests/unit/shared/components/SelectableDropdown.test.ts +406 -0
- package/tests/unit/shared/components/SlashCommandDropdown.provider.test.ts +354 -0
- package/tests/unit/shared/components/SlashCommandDropdown.test.ts +508 -0
- package/tests/unit/shared/icons.test.ts +56 -0
- package/tests/unit/shared/index.test.ts +46 -0
- package/tests/unit/shared/mention/MentionDropdownController.test.ts +823 -0
- package/tests/unit/shared/mention/VaultFileCache.test.ts +195 -0
- package/tests/unit/shared/mention/VaultFolderCache.test.ts +143 -0
- package/tests/unit/shared/mention/VaultMentionDataProvider.test.ts +87 -0
- package/tests/unit/shared/modals/ConfirmModal.test.ts +111 -0
- package/tests/unit/shared/modals/ForkTargetModal.test.ts +101 -0
- package/tests/unit/shared/modals/InstructionConfirmModal.test.ts +305 -0
- package/tests/unit/utils/agent.test.ts +395 -0
- package/tests/unit/utils/animationFrame.test.ts +59 -0
- package/tests/unit/utils/browser.test.ts +73 -0
- package/tests/unit/utils/canvas.test.ts +54 -0
- package/tests/unit/utils/claudeCli.test.ts +294 -0
- package/tests/unit/utils/cliBinaryLocator.test.ts +33 -0
- package/tests/unit/utils/context.test.ts +288 -0
- package/tests/unit/utils/contextMentionResolver.test.ts +270 -0
- package/tests/unit/utils/date.test.ts +80 -0
- package/tests/unit/utils/diff.test.ts +291 -0
- package/tests/unit/utils/editor.test.ts +249 -0
- package/tests/unit/utils/electronCompat.test.ts +87 -0
- package/tests/unit/utils/env.test.ts +1240 -0
- package/tests/unit/utils/externalContext.test.ts +336 -0
- package/tests/unit/utils/externalContextScanner.test.ts +186 -0
- package/tests/unit/utils/fileLink.dom.test.ts +273 -0
- package/tests/unit/utils/fileLink.handler.test.ts +64 -0
- package/tests/unit/utils/fileLink.test.ts +233 -0
- package/tests/unit/utils/frontmatter.test.ts +434 -0
- package/tests/unit/utils/imageEmbed.test.ts +407 -0
- package/tests/unit/utils/inlineEdit.test.ts +63 -0
- package/tests/unit/utils/interrupt.test.ts +73 -0
- package/tests/unit/utils/markdown.test.ts +35 -0
- package/tests/unit/utils/markdownMath.test.ts +54 -0
- package/tests/unit/utils/mcp.test.ts +256 -0
- package/tests/unit/utils/obsidianCompat.test.ts +18 -0
- package/tests/unit/utils/path.test.ts +677 -0
- package/tests/unit/utils/sdkSession.test.ts +2359 -0
- package/tests/unit/utils/session.test.ts +971 -0
- package/tests/unit/utils/slashCommand.test.ts +778 -0
- package/tests/unit/utils/utils.test.ts +809 -0
- package/tsconfig.jest.json +8 -0
- package/tsconfig.json +26 -0
- 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
|
+
});
|