@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,2764 @@
|
|
|
1
|
+
import { createMockEl } from '@test/helpers/mockElement';
|
|
2
|
+
import { Menu, Notice } from 'obsidian';
|
|
3
|
+
|
|
4
|
+
import { ConversationController, type ConversationControllerDeps } from '@/features/chat/controllers/ConversationController';
|
|
5
|
+
import { ChatState } from '@/features/chat/state/ChatState';
|
|
6
|
+
import { confirm } from '@/shared/modals/ConfirmModal';
|
|
7
|
+
|
|
8
|
+
jest.mock('@/shared/modals/ConfirmModal', () => ({
|
|
9
|
+
confirm: jest.fn().mockResolvedValue(true),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
const mockNotice = Notice as jest.Mock;
|
|
13
|
+
|
|
14
|
+
function createMockDeps(overrides: Partial<ConversationControllerDeps> = {}): ConversationControllerDeps {
|
|
15
|
+
const state = new ChatState();
|
|
16
|
+
const inputEl = { value: '', focus: jest.fn() } as unknown as HTMLTextAreaElement;
|
|
17
|
+
const historyDropdown = createMockEl();
|
|
18
|
+
let welcomeEl: any = createMockEl();
|
|
19
|
+
const messagesEl = createMockEl();
|
|
20
|
+
|
|
21
|
+
const fileContextManager = {
|
|
22
|
+
resetForNewConversation: jest.fn(),
|
|
23
|
+
resetForLoadedConversation: jest.fn(),
|
|
24
|
+
autoAttachActiveFile: jest.fn(),
|
|
25
|
+
setCurrentNote: jest.fn(),
|
|
26
|
+
getCurrentNotePath: jest.fn().mockReturnValue(null),
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
plugin: {
|
|
31
|
+
createConversation: jest.fn().mockResolvedValue({
|
|
32
|
+
id: 'new-conv',
|
|
33
|
+
title: 'New Conversation',
|
|
34
|
+
messages: [],
|
|
35
|
+
sessionId: null,
|
|
36
|
+
createdAt: Date.now(),
|
|
37
|
+
updatedAt: Date.now(),
|
|
38
|
+
}),
|
|
39
|
+
switchConversation: jest.fn().mockResolvedValue({
|
|
40
|
+
id: 'switched-conv',
|
|
41
|
+
title: 'Switched Conversation',
|
|
42
|
+
messages: [],
|
|
43
|
+
sessionId: null,
|
|
44
|
+
createdAt: Date.now(),
|
|
45
|
+
updatedAt: Date.now(),
|
|
46
|
+
}),
|
|
47
|
+
getConversationById: jest.fn().mockResolvedValue(null),
|
|
48
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
49
|
+
getConversationList: jest.fn().mockReturnValue([]),
|
|
50
|
+
findEmptyConversation: jest.fn().mockResolvedValue(null),
|
|
51
|
+
updateConversation: jest.fn().mockResolvedValue(undefined),
|
|
52
|
+
renameConversation: jest.fn().mockResolvedValue(undefined),
|
|
53
|
+
deleteConversation: jest.fn().mockResolvedValue(undefined),
|
|
54
|
+
agentService: {
|
|
55
|
+
getSessionId: jest.fn().mockResolvedValue(null),
|
|
56
|
+
setSessionId: jest.fn(),
|
|
57
|
+
},
|
|
58
|
+
settings: {
|
|
59
|
+
userName: '',
|
|
60
|
+
enableAutoTitleGeneration: true,
|
|
61
|
+
permissionMode: 'yolo',
|
|
62
|
+
},
|
|
63
|
+
} as any,
|
|
64
|
+
state,
|
|
65
|
+
renderer: {
|
|
66
|
+
renderMessages: jest.fn().mockReturnValue(createMockEl()),
|
|
67
|
+
} as any,
|
|
68
|
+
subagentManager: {
|
|
69
|
+
orphanAllActive: jest.fn(),
|
|
70
|
+
clear: jest.fn(),
|
|
71
|
+
} as any,
|
|
72
|
+
getHistoryDropdown: () => historyDropdown as any,
|
|
73
|
+
getWelcomeEl: () => welcomeEl,
|
|
74
|
+
setWelcomeEl: (el: any) => { welcomeEl = el; },
|
|
75
|
+
getMessagesEl: () => messagesEl as any,
|
|
76
|
+
getInputEl: () => inputEl,
|
|
77
|
+
getFileContextManager: () => fileContextManager as any,
|
|
78
|
+
getImageContextManager: () => ({
|
|
79
|
+
clearImages: jest.fn(),
|
|
80
|
+
}) as any,
|
|
81
|
+
getMcpServerSelector: () => ({
|
|
82
|
+
clearEnabled: jest.fn(),
|
|
83
|
+
getEnabledServers: jest.fn().mockResolvedValue(new Set()),
|
|
84
|
+
setEnabledServers: jest.fn(),
|
|
85
|
+
}) as any,
|
|
86
|
+
getExternalContextSelector: () => ({
|
|
87
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
88
|
+
setExternalContexts: jest.fn(),
|
|
89
|
+
clearExternalContexts: jest.fn(),
|
|
90
|
+
}) as any,
|
|
91
|
+
clearQueuedMessage: jest.fn(),
|
|
92
|
+
getTitleGenerationService: () => null,
|
|
93
|
+
getStatusPanel: () => ({
|
|
94
|
+
remount: jest.fn(),
|
|
95
|
+
}) as any,
|
|
96
|
+
...overrides,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
describe('ConversationController', () => {
|
|
101
|
+
let controller: ConversationController;
|
|
102
|
+
let deps: ConversationControllerDeps;
|
|
103
|
+
|
|
104
|
+
beforeEach(() => {
|
|
105
|
+
jest.clearAllMocks();
|
|
106
|
+
(Menu as typeof Menu & { instances: unknown[] }).instances.length = 0;
|
|
107
|
+
deps = createMockDeps();
|
|
108
|
+
controller = new ConversationController(deps);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('Queue Management', () => {
|
|
112
|
+
describe('Creating new conversation', () => {
|
|
113
|
+
it('should clear queued message on new conversation', async () => {
|
|
114
|
+
deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null };
|
|
115
|
+
deps.state.isStreaming = false;
|
|
116
|
+
|
|
117
|
+
await controller.createNew();
|
|
118
|
+
|
|
119
|
+
expect(deps.clearQueuedMessage).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should not create new conversation while streaming', async () => {
|
|
123
|
+
deps.state.isStreaming = true;
|
|
124
|
+
|
|
125
|
+
await controller.createNew();
|
|
126
|
+
|
|
127
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should save current conversation before creating new one', async () => {
|
|
131
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
132
|
+
deps.state.currentConversationId = 'old-conv';
|
|
133
|
+
|
|
134
|
+
await controller.createNew();
|
|
135
|
+
|
|
136
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('old-conv', expect.any(Object));
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should reset file context for new conversation', async () => {
|
|
140
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
141
|
+
|
|
142
|
+
await controller.createNew();
|
|
143
|
+
|
|
144
|
+
expect(fileContextManager.resetForNewConversation).toHaveBeenCalled();
|
|
145
|
+
expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should clear todos for new conversation', async () => {
|
|
149
|
+
deps.state.currentTodos = [
|
|
150
|
+
{ content: 'Existing todo', status: 'pending', activeForm: 'Doing existing todo' }
|
|
151
|
+
];
|
|
152
|
+
expect(deps.state.currentTodos).not.toBeNull();
|
|
153
|
+
|
|
154
|
+
await controller.createNew();
|
|
155
|
+
|
|
156
|
+
expect(deps.state.currentTodos).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should reset to entry point state (null conversationId) instead of creating conversation', async () => {
|
|
160
|
+
// Entry point model: createNew() resets to blank state without creating conversation
|
|
161
|
+
// Conversation is created lazily on first message send
|
|
162
|
+
await controller.createNew();
|
|
163
|
+
|
|
164
|
+
expect(deps.plugin.findEmptyConversation).not.toHaveBeenCalled();
|
|
165
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
166
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
167
|
+
expect(deps.state.currentConversationId).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should clear messages and reset state when creating new', async () => {
|
|
171
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
172
|
+
deps.state.currentConversationId = 'old-conv';
|
|
173
|
+
|
|
174
|
+
const clearMessagesSpy = jest.spyOn(deps.state, 'clearMessages');
|
|
175
|
+
|
|
176
|
+
await controller.createNew();
|
|
177
|
+
|
|
178
|
+
expect(clearMessagesSpy).toHaveBeenCalled();
|
|
179
|
+
expect(deps.state.currentConversationId).toBeNull();
|
|
180
|
+
|
|
181
|
+
clearMessagesSpy.mockRestore();
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe('Switching conversations', () => {
|
|
186
|
+
it('should clear queued message on conversation switch', async () => {
|
|
187
|
+
deps.state.currentConversationId = 'old-conv';
|
|
188
|
+
deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null };
|
|
189
|
+
|
|
190
|
+
await controller.switchTo('new-conv');
|
|
191
|
+
|
|
192
|
+
expect(deps.clearQueuedMessage).toHaveBeenCalled();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('should not switch while streaming', async () => {
|
|
196
|
+
deps.state.isStreaming = true;
|
|
197
|
+
deps.state.currentConversationId = 'old-conv';
|
|
198
|
+
|
|
199
|
+
await controller.switchTo('new-conv');
|
|
200
|
+
|
|
201
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('should not switch to current conversation', async () => {
|
|
205
|
+
deps.state.currentConversationId = 'same-conv';
|
|
206
|
+
|
|
207
|
+
await controller.switchTo('same-conv');
|
|
208
|
+
|
|
209
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should reset file context when switching conversations', async () => {
|
|
213
|
+
deps.state.currentConversationId = 'old-conv';
|
|
214
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
215
|
+
|
|
216
|
+
await controller.switchTo('new-conv');
|
|
217
|
+
|
|
218
|
+
expect(fileContextManager.resetForLoadedConversation).toHaveBeenCalled();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('should clear input value on switch', async () => {
|
|
222
|
+
deps.state.currentConversationId = 'old-conv';
|
|
223
|
+
const inputEl = deps.getInputEl();
|
|
224
|
+
inputEl.value = 'some input';
|
|
225
|
+
|
|
226
|
+
await controller.switchTo('new-conv');
|
|
227
|
+
|
|
228
|
+
expect(inputEl.value).toBe('');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('should hide history dropdown after switch', async () => {
|
|
232
|
+
deps.state.currentConversationId = 'old-conv';
|
|
233
|
+
const dropdown = deps.getHistoryDropdown()!;
|
|
234
|
+
dropdown.addClass('visible');
|
|
235
|
+
|
|
236
|
+
await controller.switchTo('new-conv');
|
|
237
|
+
|
|
238
|
+
expect(dropdown.hasClass('visible')).toBe(false);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe('Welcome visibility', () => {
|
|
243
|
+
it('should hide welcome when messages exist', () => {
|
|
244
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
245
|
+
const welcomeEl = deps.getWelcomeEl()!;
|
|
246
|
+
|
|
247
|
+
controller.updateWelcomeVisibility();
|
|
248
|
+
|
|
249
|
+
expect(welcomeEl.style.display).toBe('none');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should show welcome when no messages exist', () => {
|
|
253
|
+
deps.state.messages = [];
|
|
254
|
+
const welcomeEl = deps.getWelcomeEl()!;
|
|
255
|
+
|
|
256
|
+
controller.updateWelcomeVisibility();
|
|
257
|
+
|
|
258
|
+
// When no messages, welcome should not be 'none' (either 'block' or empty string)
|
|
259
|
+
expect(welcomeEl.style.display).not.toBe('none');
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('should update welcome visibility after switching to conversation with messages', async () => {
|
|
263
|
+
deps.state.currentConversationId = 'old-conv';
|
|
264
|
+
deps.state.messages = [];
|
|
265
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
266
|
+
id: 'new-conv',
|
|
267
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
268
|
+
sessionId: null,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await controller.switchTo('new-conv');
|
|
272
|
+
|
|
273
|
+
expect(deps.state.messages.length).toBe(1);
|
|
274
|
+
const welcomeEl = deps.getWelcomeEl()!;
|
|
275
|
+
expect(welcomeEl.style.display).toBe('none');
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('initializeWelcome', () => {
|
|
281
|
+
it('should initialize file context for new tab', () => {
|
|
282
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
283
|
+
|
|
284
|
+
controller.initializeWelcome();
|
|
285
|
+
|
|
286
|
+
expect(fileContextManager.resetForNewConversation).toHaveBeenCalled();
|
|
287
|
+
expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled();
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should not throw if welcomeEl is null', () => {
|
|
291
|
+
const depsWithNullWelcome = createMockDeps({
|
|
292
|
+
getWelcomeEl: () => null,
|
|
293
|
+
});
|
|
294
|
+
const controllerWithNullWelcome = new ConversationController(depsWithNullWelcome);
|
|
295
|
+
|
|
296
|
+
expect(() => controllerWithNullWelcome.initializeWelcome()).not.toThrow();
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('should only add greeting if not already present', () => {
|
|
300
|
+
const welcomeEl = deps.getWelcomeEl()!;
|
|
301
|
+
const createDivSpy = jest.spyOn(welcomeEl, 'createDiv');
|
|
302
|
+
|
|
303
|
+
// First call should add greeting
|
|
304
|
+
controller.initializeWelcome();
|
|
305
|
+
expect(createDivSpy).toHaveBeenCalledTimes(1);
|
|
306
|
+
|
|
307
|
+
// Mock querySelector to return an element (greeting already exists)
|
|
308
|
+
welcomeEl.querySelector = jest.fn().mockReturnValue(createMockEl());
|
|
309
|
+
|
|
310
|
+
// Second call should not add another greeting
|
|
311
|
+
controller.initializeWelcome();
|
|
312
|
+
expect(createDivSpy).toHaveBeenCalledTimes(1); // Still 1, not 2
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe('formatDate', () => {
|
|
317
|
+
it('should return time format for today', () => {
|
|
318
|
+
const now = new Date();
|
|
319
|
+
const result = controller.formatDate(now.getTime());
|
|
320
|
+
|
|
321
|
+
expect(result).toMatch(/^\d{2}:\d{2}$/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it('should return month/day format for a past date', () => {
|
|
325
|
+
const pastDate = new Date(2023, 0, 15).getTime();
|
|
326
|
+
const result = controller.formatDate(pastDate);
|
|
327
|
+
|
|
328
|
+
expect(result).toContain('15');
|
|
329
|
+
expect(result.length).toBeGreaterThan(0);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should return month/day format for yesterday', () => {
|
|
333
|
+
const yesterday = new Date();
|
|
334
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
335
|
+
const result = controller.formatDate(yesterday.getTime());
|
|
336
|
+
|
|
337
|
+
expect(result).not.toMatch(/^\d{2}:\d{2}$/);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
describe('toggleHistoryDropdown', () => {
|
|
342
|
+
it('should add visible class when dropdown is hidden', () => {
|
|
343
|
+
const dropdown = deps.getHistoryDropdown()!;
|
|
344
|
+
expect(dropdown.hasClass('visible')).toBe(false);
|
|
345
|
+
|
|
346
|
+
controller.toggleHistoryDropdown();
|
|
347
|
+
|
|
348
|
+
expect(dropdown.hasClass('visible')).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
it('should remove visible class when dropdown is visible', () => {
|
|
352
|
+
const dropdown = deps.getHistoryDropdown()!;
|
|
353
|
+
dropdown.addClass('visible');
|
|
354
|
+
|
|
355
|
+
controller.toggleHistoryDropdown();
|
|
356
|
+
|
|
357
|
+
expect(dropdown.hasClass('visible')).toBe(false);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it('should not throw when dropdown is null', () => {
|
|
361
|
+
const depsNullDropdown = createMockDeps({
|
|
362
|
+
getHistoryDropdown: () => null,
|
|
363
|
+
});
|
|
364
|
+
const ctrl = new ConversationController(depsNullDropdown);
|
|
365
|
+
|
|
366
|
+
expect(() => ctrl.toggleHistoryDropdown()).not.toThrow();
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
describe('save edge cases', () => {
|
|
371
|
+
it('should return early when no conversationId and no messages', async () => {
|
|
372
|
+
deps.state.currentConversationId = null;
|
|
373
|
+
deps.state.messages = [];
|
|
374
|
+
|
|
375
|
+
await controller.save();
|
|
376
|
+
|
|
377
|
+
expect(deps.plugin.updateConversation).not.toHaveBeenCalled();
|
|
378
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('should lazily create conversation when entry point has messages', async () => {
|
|
382
|
+
deps.state.currentConversationId = null;
|
|
383
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }];
|
|
384
|
+
|
|
385
|
+
(deps.plugin.createConversation as jest.Mock).mockResolvedValue({
|
|
386
|
+
id: 'lazy-conv',
|
|
387
|
+
title: 'New Conversation',
|
|
388
|
+
messages: [],
|
|
389
|
+
sessionId: null,
|
|
390
|
+
createdAt: Date.now(),
|
|
391
|
+
updatedAt: Date.now(),
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
await controller.save();
|
|
395
|
+
|
|
396
|
+
expect(deps.plugin.createConversation).toHaveBeenCalled();
|
|
397
|
+
expect(deps.state.currentConversationId).toBe('lazy-conv');
|
|
398
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
399
|
+
'lazy-conv',
|
|
400
|
+
expect.any(Object)
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should preserve the active runtime provider when lazily creating a conversation', async () => {
|
|
405
|
+
deps = createMockDeps({
|
|
406
|
+
getAgentService: () => ({
|
|
407
|
+
providerId: 'codex',
|
|
408
|
+
getSessionId: jest.fn().mockReturnValue('session-codex'),
|
|
409
|
+
consumeSessionInvalidation: jest.fn().mockReturnValue(false),
|
|
410
|
+
buildSessionUpdates: jest.fn().mockReturnValue({ updates: {} }),
|
|
411
|
+
syncConversationState: jest.fn(),
|
|
412
|
+
}) as any,
|
|
413
|
+
});
|
|
414
|
+
controller = new ConversationController(deps);
|
|
415
|
+
deps.state.currentConversationId = null;
|
|
416
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'hello', timestamp: Date.now() }];
|
|
417
|
+
|
|
418
|
+
(deps.plugin.createConversation as jest.Mock).mockResolvedValue({
|
|
419
|
+
id: 'lazy-codex-conv',
|
|
420
|
+
providerId: 'codex',
|
|
421
|
+
title: 'Codex Conversation',
|
|
422
|
+
messages: [],
|
|
423
|
+
sessionId: 'session-codex',
|
|
424
|
+
createdAt: Date.now(),
|
|
425
|
+
updatedAt: Date.now(),
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
await controller.save();
|
|
429
|
+
|
|
430
|
+
expect(deps.plugin.createConversation).toHaveBeenCalledWith({
|
|
431
|
+
providerId: 'codex',
|
|
432
|
+
sessionId: 'session-codex',
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('should set lastResponseAt when updateLastResponse is true', async () => {
|
|
437
|
+
deps.state.currentConversationId = 'conv-1';
|
|
438
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
439
|
+
|
|
440
|
+
const beforeCall = Date.now();
|
|
441
|
+
|
|
442
|
+
await controller.save(true);
|
|
443
|
+
|
|
444
|
+
const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0];
|
|
445
|
+
const updates = call[1];
|
|
446
|
+
expect(updates.lastResponseAt).toBeDefined();
|
|
447
|
+
expect(updates.lastResponseAt).toBeGreaterThanOrEqual(beforeCall);
|
|
448
|
+
expect(updates.lastResponseAt).toBeLessThanOrEqual(Date.now());
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
it('should NOT clear resumeAtMessageId when updateLastResponse is true (caller must pass extraUpdates)', async () => {
|
|
452
|
+
deps.state.currentConversationId = 'conv-1';
|
|
453
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
454
|
+
|
|
455
|
+
await controller.save(true);
|
|
456
|
+
|
|
457
|
+
const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0];
|
|
458
|
+
const updates = call[1];
|
|
459
|
+
expect(updates).not.toHaveProperty('resumeAtMessageId');
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
it('should clear resumeAtMessageId when passed via extraUpdates', async () => {
|
|
463
|
+
deps.state.currentConversationId = 'conv-1';
|
|
464
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
465
|
+
|
|
466
|
+
await controller.save(true, { resumeAtMessageId: undefined });
|
|
467
|
+
|
|
468
|
+
const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0];
|
|
469
|
+
const updates = call[1];
|
|
470
|
+
expect(updates.resumeAtMessageId).toBeUndefined();
|
|
471
|
+
// Verify it's explicitly set (not just missing)
|
|
472
|
+
expect('resumeAtMessageId' in updates).toBe(true);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('should not clear resumeAtMessageId when updateLastResponse is false', async () => {
|
|
476
|
+
deps.state.currentConversationId = 'conv-1';
|
|
477
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
478
|
+
|
|
479
|
+
await controller.save(false);
|
|
480
|
+
|
|
481
|
+
const call = (deps.plugin.updateConversation as jest.Mock).mock.calls[0];
|
|
482
|
+
const updates = call[1];
|
|
483
|
+
expect(updates).not.toHaveProperty('resumeAtMessageId');
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
it('should clear pending conversation save state after persisting', async () => {
|
|
487
|
+
deps.state.currentConversationId = 'conv-1';
|
|
488
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
489
|
+
deps.state.hasPendingConversationSave = true;
|
|
490
|
+
|
|
491
|
+
await controller.save();
|
|
492
|
+
|
|
493
|
+
expect(deps.state.hasPendingConversationSave).toBe(false);
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
describe('loadActive with existing conversation', () => {
|
|
498
|
+
it('should restore currentNote when conversation has one', async () => {
|
|
499
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
500
|
+
deps.state.currentConversationId = 'conv-with-note';
|
|
501
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
502
|
+
id: 'conv-with-note',
|
|
503
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
504
|
+
sessionId: null,
|
|
505
|
+
currentNote: 'notes/my-note.md',
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await controller.loadActive();
|
|
509
|
+
|
|
510
|
+
expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('notes/my-note.md');
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it('should auto-attach active file when no currentNote and no messages', async () => {
|
|
514
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
515
|
+
deps.state.currentConversationId = 'empty-conv';
|
|
516
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
517
|
+
id: 'empty-conv',
|
|
518
|
+
messages: [],
|
|
519
|
+
sessionId: null,
|
|
520
|
+
currentNote: undefined,
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
await controller.loadActive();
|
|
524
|
+
|
|
525
|
+
expect(fileContextManager.autoAttachActiveFile).toHaveBeenCalled();
|
|
526
|
+
expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('should call renderer.renderMessages with greeting callback', async () => {
|
|
530
|
+
deps.state.currentConversationId = 'conv-1';
|
|
531
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
532
|
+
id: 'conv-1',
|
|
533
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
534
|
+
sessionId: null,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
await controller.loadActive();
|
|
538
|
+
|
|
539
|
+
expect(deps.renderer.renderMessages).toHaveBeenCalledWith(
|
|
540
|
+
expect.any(Array),
|
|
541
|
+
expect.any(Function)
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1];
|
|
545
|
+
expect(greetingFn().length).toBeGreaterThan(0);
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe('switchTo with currentNote', () => {
|
|
550
|
+
it('should set currentNote when switched conversation has one', async () => {
|
|
551
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
552
|
+
deps.state.currentConversationId = 'old-conv';
|
|
553
|
+
|
|
554
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
555
|
+
id: 'new-conv',
|
|
556
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
557
|
+
sessionId: null,
|
|
558
|
+
currentNote: 'docs/readme.md',
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
await controller.switchTo('new-conv');
|
|
562
|
+
|
|
563
|
+
expect(fileContextManager.setCurrentNote).toHaveBeenCalledWith('docs/readme.md');
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('should not set currentNote when switched conversation has none', async () => {
|
|
567
|
+
const fileContextManager = deps.getFileContextManager()!;
|
|
568
|
+
deps.state.currentConversationId = 'old-conv';
|
|
569
|
+
|
|
570
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
571
|
+
id: 'new-conv',
|
|
572
|
+
messages: [],
|
|
573
|
+
sessionId: null,
|
|
574
|
+
currentNote: undefined,
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
await controller.switchTo('new-conv');
|
|
578
|
+
|
|
579
|
+
expect(fileContextManager.setCurrentNote).not.toHaveBeenCalled();
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
it('should call renderer.renderMessages with greeting callback on switch', async () => {
|
|
583
|
+
deps.state.currentConversationId = 'old-conv';
|
|
584
|
+
|
|
585
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
586
|
+
id: 'new-conv',
|
|
587
|
+
messages: [],
|
|
588
|
+
sessionId: null,
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
await controller.switchTo('new-conv');
|
|
592
|
+
|
|
593
|
+
expect(deps.renderer.renderMessages).toHaveBeenCalledWith(
|
|
594
|
+
expect.any(Array),
|
|
595
|
+
expect.any(Function)
|
|
596
|
+
);
|
|
597
|
+
|
|
598
|
+
const greetingFn = (deps.renderer.renderMessages as jest.Mock).mock.calls[0][1];
|
|
599
|
+
expect(greetingFn().length).toBeGreaterThan(0);
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
describe('History Rendering', () => {
|
|
604
|
+
let dropdown: any;
|
|
605
|
+
|
|
606
|
+
beforeEach(() => {
|
|
607
|
+
dropdown = createMockEl();
|
|
608
|
+
deps.getHistoryDropdown = () => dropdown;
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
describe('updateHistoryDropdown with conversations', () => {
|
|
612
|
+
it('should render conversation items when conversations exist', () => {
|
|
613
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
614
|
+
{ id: 'conv-1', title: 'First Conversation', createdAt: 1000, lastResponseAt: 3000 },
|
|
615
|
+
{ id: 'conv-2', title: 'Second Conversation', createdAt: 2000, lastResponseAt: 2000 },
|
|
616
|
+
]);
|
|
617
|
+
|
|
618
|
+
controller.updateHistoryDropdown();
|
|
619
|
+
|
|
620
|
+
expect(dropdown.children.length).toBe(2);
|
|
621
|
+
const list = dropdown.children[1];
|
|
622
|
+
expect(list.hasClass('claudian-history-list')).toBe(true);
|
|
623
|
+
expect(list.children.length).toBe(2);
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('should show "No conversations" when list is empty', () => {
|
|
627
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([]);
|
|
628
|
+
|
|
629
|
+
controller.updateHistoryDropdown();
|
|
630
|
+
|
|
631
|
+
const list = dropdown.children[1];
|
|
632
|
+
expect(list.children[0].hasClass('claudian-history-empty')).toBe(true);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should sort conversations by lastResponseAt descending', () => {
|
|
636
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
637
|
+
{ id: 'conv-old', title: 'Old', createdAt: 1000, lastResponseAt: 1000 },
|
|
638
|
+
{ id: 'conv-new', title: 'New', createdAt: 2000, lastResponseAt: 5000 },
|
|
639
|
+
{ id: 'conv-mid', title: 'Mid', createdAt: 3000, lastResponseAt: 3000 },
|
|
640
|
+
]);
|
|
641
|
+
|
|
642
|
+
controller.updateHistoryDropdown();
|
|
643
|
+
|
|
644
|
+
const list = dropdown.children[1];
|
|
645
|
+
const firstTitle = list.children[0].querySelector('.claudian-history-item-title');
|
|
646
|
+
expect(firstTitle?.textContent).toBe('New');
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('should mark current conversation as active', () => {
|
|
650
|
+
deps.state.currentConversationId = 'conv-1';
|
|
651
|
+
|
|
652
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
653
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 },
|
|
654
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 2000 },
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
controller.updateHistoryDropdown();
|
|
658
|
+
|
|
659
|
+
const list = dropdown.children[1];
|
|
660
|
+
const items = list.children;
|
|
661
|
+
const activeItem = items.find((item: any) => item.hasClass('active'));
|
|
662
|
+
expect(activeItem).toBeDefined();
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('should show loading indicator for pending title generation', () => {
|
|
666
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
667
|
+
{ id: 'conv-1', title: 'Generating...', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'pending' },
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
controller.updateHistoryDropdown();
|
|
671
|
+
|
|
672
|
+
const list = dropdown.children[1];
|
|
673
|
+
const item = list.children[0];
|
|
674
|
+
const loadingEl = item.querySelector('.claudian-action-loading');
|
|
675
|
+
expect(loadingEl).toBeTruthy();
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
it('should show regenerate button for failed title generation', () => {
|
|
679
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
680
|
+
{ id: 'conv-1', title: 'Fallback Title', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' },
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
controller.updateHistoryDropdown();
|
|
684
|
+
|
|
685
|
+
const list = dropdown.children[1];
|
|
686
|
+
const item = list.children[0];
|
|
687
|
+
const actions = item.querySelector('.claudian-history-item-actions');
|
|
688
|
+
expect(actions).toBeTruthy();
|
|
689
|
+
// regenerate button + rename button + delete button = 3 children
|
|
690
|
+
expect(actions!.children.length).toBe(3);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
it('should not show select click handler on current conversation', () => {
|
|
694
|
+
deps.state.currentConversationId = 'conv-1';
|
|
695
|
+
|
|
696
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
697
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 },
|
|
698
|
+
]);
|
|
699
|
+
|
|
700
|
+
controller.updateHistoryDropdown();
|
|
701
|
+
|
|
702
|
+
const list = dropdown.children[1];
|
|
703
|
+
const item = list.children[0];
|
|
704
|
+
const content = item.querySelector('.claudian-history-item-content');
|
|
705
|
+
const listeners = content?._eventListeners?.get('click');
|
|
706
|
+
expect(listeners).toBeUndefined();
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it('should attach select click handler on non-current conversations', () => {
|
|
710
|
+
deps.state.currentConversationId = 'conv-1';
|
|
711
|
+
|
|
712
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
713
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
714
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
715
|
+
]);
|
|
716
|
+
|
|
717
|
+
controller.updateHistoryDropdown();
|
|
718
|
+
|
|
719
|
+
const list = dropdown.children[1];
|
|
720
|
+
// conv-2 is the non-current one (sorted second by lastResponseAt)
|
|
721
|
+
const otherItem = list.children[1];
|
|
722
|
+
const content = otherItem.querySelector('.claudian-history-item-content');
|
|
723
|
+
const listeners = content?._eventListeners?.get('click');
|
|
724
|
+
expect(listeners).toBeDefined();
|
|
725
|
+
expect(listeners!.length).toBe(1);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it('should not delete while streaming', async () => {
|
|
729
|
+
deps.state.isStreaming = true;
|
|
730
|
+
|
|
731
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
732
|
+
{ id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 },
|
|
733
|
+
]);
|
|
734
|
+
|
|
735
|
+
controller.updateHistoryDropdown();
|
|
736
|
+
|
|
737
|
+
const list = dropdown.children[1];
|
|
738
|
+
const item = list.children[0];
|
|
739
|
+
const deleteBtn = item.querySelector('.claudian-delete-btn');
|
|
740
|
+
expect(deleteBtn).toBeTruthy();
|
|
741
|
+
|
|
742
|
+
const clickHandlers = deleteBtn!._eventListeners?.get('click');
|
|
743
|
+
expect(clickHandlers).toBeDefined();
|
|
744
|
+
await clickHandlers });
|
|
745
|
+
|
|
746
|
+
expect(deps.plugin.deleteConversation).not.toHaveBeenCalled();
|
|
747
|
+
});
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
describe('renderHistoryDropdown', () => {
|
|
751
|
+
it('should render history items to provided container', () => {
|
|
752
|
+
const container = createMockEl();
|
|
753
|
+
const onSelectConversation = jest.fn();
|
|
754
|
+
|
|
755
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
756
|
+
{ id: 'conv-1', title: 'Test', createdAt: 1000, lastResponseAt: 1000 },
|
|
757
|
+
]);
|
|
758
|
+
|
|
759
|
+
controller.renderHistoryDropdown(container, { onSelectConversation });
|
|
760
|
+
|
|
761
|
+
expect(container.children.length).toBe(2); // header + list
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it('should highlight conversations already open in a tab', () => {
|
|
765
|
+
const container = createMockEl();
|
|
766
|
+
|
|
767
|
+
deps.state.currentConversationId = 'conv-1';
|
|
768
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
769
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
770
|
+
{ id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 },
|
|
771
|
+
]);
|
|
772
|
+
|
|
773
|
+
controller.renderHistoryDropdown(container, {
|
|
774
|
+
onSelectConversation: jest.fn(),
|
|
775
|
+
getConversationOpenState: (id) => id === 'conv-2' ? 'open' : 'current',
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
const list = container.children[1];
|
|
779
|
+
const openItem = list.children[1];
|
|
780
|
+
const openItemDate = openItem.querySelector('.claudian-history-item-date');
|
|
781
|
+
|
|
782
|
+
expect(openItem.hasClass('open')).toBe(true);
|
|
783
|
+
expect(openItem.hasClass('active')).toBe(false);
|
|
784
|
+
expect(openItem.getAttribute('data-open-state')).toBe('open');
|
|
785
|
+
expect(openItemDate?.textContent).toBe('Open in tab');
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it('should display the current tab number when available', () => {
|
|
789
|
+
const container = createMockEl();
|
|
790
|
+
|
|
791
|
+
deps.state.currentConversationId = 'conv-1';
|
|
792
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
793
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
794
|
+
]);
|
|
795
|
+
|
|
796
|
+
controller.renderHistoryDropdown(container, {
|
|
797
|
+
onSelectConversation: jest.fn(),
|
|
798
|
+
getConversationStatus: () => ({
|
|
799
|
+
openState: 'current',
|
|
800
|
+
isRunning: false,
|
|
801
|
+
location: 'current-view',
|
|
802
|
+
tabIndex: 1,
|
|
803
|
+
}),
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
const list = container.children[1];
|
|
807
|
+
const currentItem = list.children[0];
|
|
808
|
+
const currentItemDate = currentItem.querySelector('.claudian-history-item-date');
|
|
809
|
+
|
|
810
|
+
expect(currentItem.getAttribute('data-tab-index')).toBe('1');
|
|
811
|
+
expect(currentItem.getAttribute('data-tab-location')).toBe('current-view');
|
|
812
|
+
expect(currentItemDate?.textContent).toBe('Current tab 1');
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
it('should display the open tab number when available', () => {
|
|
816
|
+
const container = createMockEl();
|
|
817
|
+
|
|
818
|
+
deps.state.currentConversationId = 'conv-1';
|
|
819
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
820
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
821
|
+
{ id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 },
|
|
822
|
+
]);
|
|
823
|
+
|
|
824
|
+
controller.renderHistoryDropdown(container, {
|
|
825
|
+
onSelectConversation: jest.fn(),
|
|
826
|
+
getConversationStatus: (id) => id === 'conv-2'
|
|
827
|
+
? { openState: 'open', isRunning: false, location: 'current-view', tabIndex: 2 }
|
|
828
|
+
: { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 },
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
const list = container.children[1];
|
|
832
|
+
const openItem = list.children[1];
|
|
833
|
+
const openItemDate = openItem.querySelector('.claudian-history-item-date');
|
|
834
|
+
|
|
835
|
+
expect(openItem.getAttribute('data-tab-index')).toBe('2');
|
|
836
|
+
expect(openItem.getAttribute('data-tab-location')).toBe('current-view');
|
|
837
|
+
expect(openItemDate?.textContent).toBe('Open in tab 2');
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
it('should display running status for the current conversation', () => {
|
|
841
|
+
const container = createMockEl();
|
|
842
|
+
|
|
843
|
+
deps.state.currentConversationId = 'conv-1';
|
|
844
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
845
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
846
|
+
]);
|
|
847
|
+
|
|
848
|
+
controller.renderHistoryDropdown(container, {
|
|
849
|
+
onSelectConversation: jest.fn(),
|
|
850
|
+
getConversationStatus: () => ({
|
|
851
|
+
openState: 'current',
|
|
852
|
+
isRunning: true,
|
|
853
|
+
}),
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
const list = container.children[1];
|
|
857
|
+
const currentItem = list.children[0];
|
|
858
|
+
const currentItemDate = currentItem.querySelector('.claudian-history-item-date');
|
|
859
|
+
|
|
860
|
+
expect(currentItem.hasClass('active')).toBe(true);
|
|
861
|
+
expect(currentItem.hasClass('running')).toBe(true);
|
|
862
|
+
expect(currentItem.getAttribute('data-running')).toBe('true');
|
|
863
|
+
expect(currentItemDate?.textContent).toBe('Running in current tab');
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
it('should display running status for a conversation open in another tab', () => {
|
|
867
|
+
const container = createMockEl();
|
|
868
|
+
|
|
869
|
+
deps.state.currentConversationId = 'conv-1';
|
|
870
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
871
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
872
|
+
{ id: 'conv-2', title: 'Running elsewhere', createdAt: 2000, lastResponseAt: 1000 },
|
|
873
|
+
]);
|
|
874
|
+
|
|
875
|
+
controller.renderHistoryDropdown(container, {
|
|
876
|
+
onSelectConversation: jest.fn(),
|
|
877
|
+
getConversationStatus: (id) => id === 'conv-2'
|
|
878
|
+
? { openState: 'open', isRunning: true, location: 'current-view', tabIndex: 2 }
|
|
879
|
+
: { openState: 'current', isRunning: false },
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const list = container.children[1];
|
|
883
|
+
const runningItem = list.children[1];
|
|
884
|
+
const runningItemDate = runningItem.querySelector('.claudian-history-item-date');
|
|
885
|
+
|
|
886
|
+
expect(runningItem.hasClass('open')).toBe(true);
|
|
887
|
+
expect(runningItem.hasClass('running')).toBe(true);
|
|
888
|
+
expect(runningItem.getAttribute('data-open-state')).toBe('open');
|
|
889
|
+
expect(runningItem.getAttribute('data-running')).toBe('true');
|
|
890
|
+
expect(runningItemDate?.textContent).toBe('Running in tab 2');
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it('should display another-pane status without a local tab number', () => {
|
|
894
|
+
const container = createMockEl();
|
|
895
|
+
|
|
896
|
+
deps.state.currentConversationId = 'conv-1';
|
|
897
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
898
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
899
|
+
{ id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 },
|
|
900
|
+
{ id: 'conv-3', title: 'Running elsewhere', createdAt: 3000, lastResponseAt: 500 },
|
|
901
|
+
]);
|
|
902
|
+
|
|
903
|
+
controller.renderHistoryDropdown(container, {
|
|
904
|
+
onSelectConversation: jest.fn(),
|
|
905
|
+
getConversationStatus: (id) => {
|
|
906
|
+
if (id === 'conv-2') {
|
|
907
|
+
return { openState: 'open', isRunning: false, location: 'other-view' };
|
|
908
|
+
}
|
|
909
|
+
if (id === 'conv-3') {
|
|
910
|
+
return { openState: 'open', isRunning: true, location: 'other-view' };
|
|
911
|
+
}
|
|
912
|
+
return { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 };
|
|
913
|
+
},
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
const list = container.children[1];
|
|
917
|
+
const openOtherPaneItem = list.children[1];
|
|
918
|
+
const runningOtherPaneItem = list.children[2];
|
|
919
|
+
const runningOtherPaneDate = runningOtherPaneItem.querySelector('.claudian-history-item-date');
|
|
920
|
+
const openOtherPaneDate = openOtherPaneItem.querySelector('.claudian-history-item-date');
|
|
921
|
+
|
|
922
|
+
expect(runningOtherPaneItem.getAttribute('data-tab-location')).toBe('other-view');
|
|
923
|
+
expect(runningOtherPaneItem.getAttribute('data-tab-index')).toBeNull();
|
|
924
|
+
expect(runningOtherPaneDate?.textContent).toBe('Running in another pane');
|
|
925
|
+
expect(openOtherPaneDate?.textContent).toBe('Open in another pane');
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
it('should render a new-tab button for closed conversations', async () => {
|
|
929
|
+
const container = createMockEl();
|
|
930
|
+
const onSelectConversation = jest.fn();
|
|
931
|
+
const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined);
|
|
932
|
+
|
|
933
|
+
deps.state.currentConversationId = 'conv-1';
|
|
934
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
935
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
936
|
+
{ id: 'conv-2', title: 'Closed', createdAt: 2000, lastResponseAt: 1000 },
|
|
937
|
+
]);
|
|
938
|
+
|
|
939
|
+
controller.renderHistoryDropdown(container, {
|
|
940
|
+
onSelectConversation,
|
|
941
|
+
onOpenConversationInNewTab,
|
|
942
|
+
getConversationOpenState: (id) => id === 'conv-2' ? 'closed' : 'current',
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const list = container.children[1];
|
|
946
|
+
const closedItem = list.children[1];
|
|
947
|
+
const openInNewTabBtn = closedItem.querySelector('.claudian-open-new-tab-btn');
|
|
948
|
+
const clickHandlers = openInNewTabBtn?._eventListeners?.get('click');
|
|
949
|
+
|
|
950
|
+
expect(openInNewTabBtn).toBeTruthy();
|
|
951
|
+
expect(clickHandlers).toBeDefined();
|
|
952
|
+
|
|
953
|
+
await clickHandlers });
|
|
954
|
+
|
|
955
|
+
expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true);
|
|
956
|
+
expect(onSelectConversation).not.toHaveBeenCalled();
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('should not render a new-tab button for already-open conversations', () => {
|
|
960
|
+
const container = createMockEl();
|
|
961
|
+
|
|
962
|
+
deps.state.currentConversationId = 'conv-1';
|
|
963
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
964
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
965
|
+
{ id: 'conv-2', title: 'Open elsewhere', createdAt: 2000, lastResponseAt: 1000 },
|
|
966
|
+
]);
|
|
967
|
+
|
|
968
|
+
controller.renderHistoryDropdown(container, {
|
|
969
|
+
onSelectConversation: jest.fn(),
|
|
970
|
+
onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined),
|
|
971
|
+
getConversationOpenState: (id) => id === 'conv-2' ? 'open' : 'current',
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
const list = container.children[1];
|
|
975
|
+
const openItem = list.children[1];
|
|
976
|
+
|
|
977
|
+
expect(openItem.querySelector('.claudian-open-new-tab-btn')).toBeNull();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('should open a conversation in a new tab on modifier click when supported', async () => {
|
|
981
|
+
const container = createMockEl();
|
|
982
|
+
const onSelectConversation = jest.fn();
|
|
983
|
+
const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined);
|
|
984
|
+
|
|
985
|
+
deps.state.currentConversationId = 'conv-1';
|
|
986
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
987
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
988
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
989
|
+
]);
|
|
990
|
+
|
|
991
|
+
controller.renderHistoryDropdown(container, {
|
|
992
|
+
onSelectConversation,
|
|
993
|
+
onOpenConversationInNewTab,
|
|
994
|
+
getConversationOpenState: () => 'closed',
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const list = container.children[1];
|
|
998
|
+
const otherItem = list.children[1];
|
|
999
|
+
const content = otherItem.querySelector('.claudian-history-item-content');
|
|
1000
|
+
const clickHandlers = content?._eventListeners?.get('click');
|
|
1001
|
+
expect(clickHandlers).toBeDefined();
|
|
1002
|
+
|
|
1003
|
+
await clickHandlers,
|
|
1005
|
+
preventDefault: jest.fn(),
|
|
1006
|
+
metaKey: true,
|
|
1007
|
+
ctrlKey: false,
|
|
1008
|
+
shiftKey: false,
|
|
1009
|
+
altKey: false,
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true);
|
|
1013
|
+
expect(onSelectConversation).not.toHaveBeenCalled();
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
it('should open a conversation in a new tab on middle click when supported', async () => {
|
|
1017
|
+
const container = createMockEl();
|
|
1018
|
+
const onSelectConversation = jest.fn();
|
|
1019
|
+
const onOpenConversationInNewTab = jest.fn().mockResolvedValue(undefined);
|
|
1020
|
+
|
|
1021
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1022
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1023
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1024
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1025
|
+
]);
|
|
1026
|
+
|
|
1027
|
+
controller.renderHistoryDropdown(container, {
|
|
1028
|
+
onSelectConversation,
|
|
1029
|
+
onOpenConversationInNewTab,
|
|
1030
|
+
getConversationOpenState: () => 'closed',
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const list = container.children[1];
|
|
1034
|
+
const otherItem = list.children[1];
|
|
1035
|
+
const content = otherItem.querySelector('.claudian-history-item-content');
|
|
1036
|
+
const auxClickHandlers = content?._eventListeners?.get('auxclick');
|
|
1037
|
+
expect(auxClickHandlers).toBeDefined();
|
|
1038
|
+
|
|
1039
|
+
await auxClickHandlers,
|
|
1042
|
+
preventDefault: jest.fn(),
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
expect(onOpenConversationInNewTab).toHaveBeenCalledWith('conv-2', true);
|
|
1046
|
+
expect(onSelectConversation).not.toHaveBeenCalled();
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
it('should show new-tab actions in the context menu for closed conversations', () => {
|
|
1050
|
+
const container = createMockEl();
|
|
1051
|
+
|
|
1052
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1053
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1054
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1055
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1056
|
+
]);
|
|
1057
|
+
|
|
1058
|
+
controller.renderHistoryDropdown(container, {
|
|
1059
|
+
onSelectConversation: jest.fn(),
|
|
1060
|
+
onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined),
|
|
1061
|
+
getConversationOpenState: () => 'closed',
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
const list = container.children[1];
|
|
1065
|
+
const otherItem = list.children[1];
|
|
1066
|
+
otherItem.dispatchEvent({
|
|
1067
|
+
type: 'contextmenu',
|
|
1068
|
+
stopPropagation: jest.fn(),
|
|
1069
|
+
preventDefault: jest.fn(),
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0];
|
|
1073
|
+
expect(menu.items.map(item => item.title)).toEqual([
|
|
1074
|
+
'Open in new tab',
|
|
1075
|
+
'Open in background tab',
|
|
1076
|
+
'Rename',
|
|
1077
|
+
'Delete',
|
|
1078
|
+
]);
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
it('should show switch action in the context menu for already-open conversations', () => {
|
|
1082
|
+
const container = createMockEl();
|
|
1083
|
+
|
|
1084
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1085
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1086
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1087
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1088
|
+
]);
|
|
1089
|
+
|
|
1090
|
+
controller.renderHistoryDropdown(container, {
|
|
1091
|
+
onSelectConversation: jest.fn(),
|
|
1092
|
+
onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined),
|
|
1093
|
+
getConversationOpenState: () => 'open',
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
const list = container.children[1];
|
|
1097
|
+
const otherItem = list.children[1];
|
|
1098
|
+
otherItem.dispatchEvent({
|
|
1099
|
+
type: 'contextmenu',
|
|
1100
|
+
stopPropagation: jest.fn(),
|
|
1101
|
+
preventDefault: jest.fn(),
|
|
1102
|
+
});
|
|
1103
|
+
|
|
1104
|
+
const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0];
|
|
1105
|
+
expect(menu.items.map(item => item.title)).toEqual([
|
|
1106
|
+
'Switch to open session',
|
|
1107
|
+
'Rename',
|
|
1108
|
+
'Delete',
|
|
1109
|
+
]);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('should derive context menu open state from conversation status', () => {
|
|
1113
|
+
const container = createMockEl();
|
|
1114
|
+
|
|
1115
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1116
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1117
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1118
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1119
|
+
]);
|
|
1120
|
+
|
|
1121
|
+
controller.renderHistoryDropdown(container, {
|
|
1122
|
+
onSelectConversation: jest.fn(),
|
|
1123
|
+
onOpenConversationInNewTab: jest.fn().mockResolvedValue(undefined),
|
|
1124
|
+
getConversationStatus: (id) => id === 'conv-2'
|
|
1125
|
+
? { openState: 'open', isRunning: false, location: 'current-view', tabIndex: 2 }
|
|
1126
|
+
: { openState: 'current', isRunning: false, location: 'current-view', tabIndex: 1 },
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
const list = container.children[1];
|
|
1130
|
+
const otherItem = list.children[1];
|
|
1131
|
+
otherItem.dispatchEvent({
|
|
1132
|
+
type: 'contextmenu',
|
|
1133
|
+
stopPropagation: jest.fn(),
|
|
1134
|
+
preventDefault: jest.fn(),
|
|
1135
|
+
});
|
|
1136
|
+
|
|
1137
|
+
const menu = (Menu as typeof Menu & { instances: Array<{ items: Array<{ title: string }> }> }).instances[0];
|
|
1138
|
+
expect(menu.items.map(item => item.title)).toEqual([
|
|
1139
|
+
'Switch to open session',
|
|
1140
|
+
'Rename',
|
|
1141
|
+
'Delete',
|
|
1142
|
+
]);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
describe('History Item Interactions', () => {
|
|
1148
|
+
let dropdown: any;
|
|
1149
|
+
|
|
1150
|
+
beforeEach(() => {
|
|
1151
|
+
dropdown = createMockEl();
|
|
1152
|
+
deps.getHistoryDropdown = () => dropdown;
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
it('should switch conversation when clicking a non-current item content', async () => {
|
|
1156
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1157
|
+
|
|
1158
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1159
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1160
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1161
|
+
]);
|
|
1162
|
+
|
|
1163
|
+
controller.updateHistoryDropdown();
|
|
1164
|
+
|
|
1165
|
+
const list = dropdown.children[1];
|
|
1166
|
+
const otherItem = list.children[1];
|
|
1167
|
+
const content = otherItem.querySelector('.claudian-history-item-content');
|
|
1168
|
+
const clickHandlers = content?._eventListeners?.get('click');
|
|
1169
|
+
expect(clickHandlers).toBeDefined();
|
|
1170
|
+
|
|
1171
|
+
await clickHandlers });
|
|
1172
|
+
await Promise.resolve();
|
|
1173
|
+
|
|
1174
|
+
expect(deps.plugin.switchConversation).toHaveBeenCalledWith('conv-2');
|
|
1175
|
+
});
|
|
1176
|
+
|
|
1177
|
+
it('should call regenerateTitle when clicking regenerate button on failed item', async () => {
|
|
1178
|
+
const mockTitleService = {
|
|
1179
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1180
|
+
cancel: jest.fn(),
|
|
1181
|
+
};
|
|
1182
|
+
deps.getTitleGenerationService = () => mockTitleService as any;
|
|
1183
|
+
|
|
1184
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1185
|
+
{ id: 'conv-1', title: 'Failed', createdAt: 1000, lastResponseAt: 1000, titleGenerationStatus: 'failed' },
|
|
1186
|
+
]);
|
|
1187
|
+
|
|
1188
|
+
controller.updateHistoryDropdown();
|
|
1189
|
+
|
|
1190
|
+
const list = dropdown.children[1];
|
|
1191
|
+
const item = list.children[0];
|
|
1192
|
+
const actions = item.querySelector('.claudian-history-item-actions');
|
|
1193
|
+
// First child is the regenerate button
|
|
1194
|
+
const regenerateBtn = actions!.children[0];
|
|
1195
|
+
const clickHandlers = regenerateBtn._eventListeners?.get('click');
|
|
1196
|
+
expect(clickHandlers).toBeDefined();
|
|
1197
|
+
|
|
1198
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
1199
|
+
id: 'conv-1',
|
|
1200
|
+
title: 'Failed',
|
|
1201
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
await clickHandlers });
|
|
1205
|
+
|
|
1206
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
1207
|
+
titleGenerationStatus: 'pending',
|
|
1208
|
+
});
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it('should invoke rename handler when clicking rename button', () => {
|
|
1212
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1213
|
+
{ id: 'conv-1', title: 'Test Title', createdAt: 1000, lastResponseAt: 1000 },
|
|
1214
|
+
]);
|
|
1215
|
+
|
|
1216
|
+
controller.updateHistoryDropdown();
|
|
1217
|
+
|
|
1218
|
+
const list = dropdown.children[1];
|
|
1219
|
+
const item = list.children[0];
|
|
1220
|
+
const actions = item.querySelector('.claudian-history-item-actions');
|
|
1221
|
+
expect(actions).toBeTruthy();
|
|
1222
|
+
// For non-failed items: rename is children[0], delete is children[1]
|
|
1223
|
+
const rBtn = actions!.children[0];
|
|
1224
|
+
expect(rBtn).toBeTruthy();
|
|
1225
|
+
const clickHandlers = rBtn._eventListeners?.get('click');
|
|
1226
|
+
expect(clickHandlers).toBeDefined();
|
|
1227
|
+
|
|
1228
|
+
const mockInput = createMockEl();
|
|
1229
|
+
(mockInput as any).type = '';
|
|
1230
|
+
(mockInput as any).className = '';
|
|
1231
|
+
(mockInput as any).value = '';
|
|
1232
|
+
(mockInput as any).focus = jest.fn();
|
|
1233
|
+
(mockInput as any).select = jest.fn();
|
|
1234
|
+
|
|
1235
|
+
const titleEl = item.querySelector('.claudian-history-item-title');
|
|
1236
|
+
if (titleEl) {
|
|
1237
|
+
(titleEl as any).replaceWith = jest.fn();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const origDocument = global.document;
|
|
1241
|
+
global.document = { createElement: jest.fn().mockReturnValue(mockInput) } as any;
|
|
1242
|
+
|
|
1243
|
+
try {
|
|
1244
|
+
clickHandlers });
|
|
1245
|
+
|
|
1246
|
+
expect(global.document.createElement).toHaveBeenCalledWith('input');
|
|
1247
|
+
expect((mockInput as any).value).toBe('Test Title');
|
|
1248
|
+
expect(titleEl!.replaceWith).toHaveBeenCalledWith(mockInput);
|
|
1249
|
+
} finally {
|
|
1250
|
+
global.document = origDocument;
|
|
1251
|
+
}
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
it('should delete conversation and reload active when deleting current conversation', async () => {
|
|
1255
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1256
|
+
|
|
1257
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1258
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 1000 },
|
|
1259
|
+
]);
|
|
1260
|
+
|
|
1261
|
+
controller.updateHistoryDropdown();
|
|
1262
|
+
|
|
1263
|
+
const list = dropdown.children[1];
|
|
1264
|
+
const item = list.children[0];
|
|
1265
|
+
const deleteBtn = item.querySelector('.claudian-delete-btn');
|
|
1266
|
+
expect(deleteBtn).toBeTruthy();
|
|
1267
|
+
|
|
1268
|
+
const clickHandlers = deleteBtn!._eventListeners?.get('click');
|
|
1269
|
+
expect(clickHandlers).toBeDefined();
|
|
1270
|
+
|
|
1271
|
+
await clickHandlers });
|
|
1272
|
+
|
|
1273
|
+
expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-1');
|
|
1274
|
+
});
|
|
1275
|
+
|
|
1276
|
+
it('should delete non-current conversation without calling loadActive', async () => {
|
|
1277
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1278
|
+
|
|
1279
|
+
(deps.plugin.getConversationList as jest.Mock).mockReturnValue([
|
|
1280
|
+
{ id: 'conv-1', title: 'Current', createdAt: 1000, lastResponseAt: 2000 },
|
|
1281
|
+
{ id: 'conv-2', title: 'Other', createdAt: 2000, lastResponseAt: 1000 },
|
|
1282
|
+
]);
|
|
1283
|
+
|
|
1284
|
+
controller.updateHistoryDropdown();
|
|
1285
|
+
|
|
1286
|
+
const list = dropdown.children[1];
|
|
1287
|
+
const otherItem = list.children[1]; // conv-2
|
|
1288
|
+
const deleteBtn = otherItem.querySelector('.claudian-delete-btn');
|
|
1289
|
+
const clickHandlers = deleteBtn!._eventListeners?.get('click');
|
|
1290
|
+
|
|
1291
|
+
await clickHandlers });
|
|
1292
|
+
|
|
1293
|
+
expect(deps.plugin.deleteConversation).toHaveBeenCalledWith('conv-2');
|
|
1294
|
+
// Should not have called switchConversation (which is used in loadActive path)
|
|
1295
|
+
// The key check is that deleteConversation was called with conv-2
|
|
1296
|
+
});
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
describe('loadActive with greeting', () => {
|
|
1300
|
+
it('should show welcome and return early when no conversation exists', async () => {
|
|
1301
|
+
deps.state.currentConversationId = null;
|
|
1302
|
+
|
|
1303
|
+
await controller.loadActive();
|
|
1304
|
+
|
|
1305
|
+
const welcomeEl = deps.getWelcomeEl();
|
|
1306
|
+
expect(welcomeEl?.style.display).not.toBe('none');
|
|
1307
|
+
});
|
|
1308
|
+
});
|
|
1309
|
+
|
|
1310
|
+
describe('Greeting Time Branches', () => {
|
|
1311
|
+
it.each([
|
|
1312
|
+
{ name: 'morning (5-12)', hour: 9, day: 1, patterns: ['morning', 'Coffee'] },
|
|
1313
|
+
{ name: 'afternoon (12-18)', hour: 14, day: 2, patterns: ['afternoon'] },
|
|
1314
|
+
{ name: 'evening (18-22)', hour: 20, day: 3, patterns: ['evening', 'Evening', 'your day'] },
|
|
1315
|
+
{ name: 'night owl (22+)', hour: 23, day: 4, patterns: ['night owl', 'Evening'] },
|
|
1316
|
+
{ name: 'early morning night owl (0-4)', hour: 2, day: 0, patterns: ['night owl', 'Evening'] },
|
|
1317
|
+
])('should include $name greetings', ({ hour, day, patterns }) => {
|
|
1318
|
+
jest.spyOn(Date.prototype, 'getHours').mockReturnValue(hour);
|
|
1319
|
+
jest.spyOn(Date.prototype, 'getDay').mockReturnValue(day);
|
|
1320
|
+
|
|
1321
|
+
const greetings = new Set<string>();
|
|
1322
|
+
for (let i = 0; i < 50; i++) {
|
|
1323
|
+
jest.spyOn(Math, 'random').mockReturnValue(i / 50);
|
|
1324
|
+
greetings.add(controller.getGreeting());
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
const hasTimeBased = [...greetings].some(g =>
|
|
1328
|
+
patterns.some(p => g.includes(p))
|
|
1329
|
+
);
|
|
1330
|
+
expect(hasTimeBased).toBe(true);
|
|
1331
|
+
|
|
1332
|
+
jest.restoreAllMocks();
|
|
1333
|
+
});
|
|
1334
|
+
});
|
|
1335
|
+
});
|
|
1336
|
+
|
|
1337
|
+
describe('ConversationController - Callbacks', () => {
|
|
1338
|
+
it('should call onNewConversation callback', async () => {
|
|
1339
|
+
const onNewConversation = jest.fn();
|
|
1340
|
+
const deps = createMockDeps();
|
|
1341
|
+
const controller = new ConversationController(deps, { onNewConversation });
|
|
1342
|
+
|
|
1343
|
+
await controller.createNew();
|
|
1344
|
+
|
|
1345
|
+
expect(onNewConversation).toHaveBeenCalled();
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
it('should call onConversationSwitched callback', async () => {
|
|
1349
|
+
const onConversationSwitched = jest.fn();
|
|
1350
|
+
const deps = createMockDeps();
|
|
1351
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1352
|
+
const controller = new ConversationController(deps, { onConversationSwitched });
|
|
1353
|
+
|
|
1354
|
+
await controller.switchTo('new-conv');
|
|
1355
|
+
|
|
1356
|
+
expect(onConversationSwitched).toHaveBeenCalled();
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
it('should call onConversationLoaded callback', async () => {
|
|
1360
|
+
const onConversationLoaded = jest.fn();
|
|
1361
|
+
const deps = createMockDeps();
|
|
1362
|
+
const controller = new ConversationController(deps, { onConversationLoaded });
|
|
1363
|
+
|
|
1364
|
+
await controller.loadActive();
|
|
1365
|
+
|
|
1366
|
+
expect(onConversationLoaded).toHaveBeenCalled();
|
|
1367
|
+
});
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
describe('ConversationController - Title Generation', () => {
|
|
1371
|
+
let controller: ConversationController;
|
|
1372
|
+
let deps: ConversationControllerDeps;
|
|
1373
|
+
let mockTitleService: any;
|
|
1374
|
+
|
|
1375
|
+
beforeEach(() => {
|
|
1376
|
+
jest.clearAllMocks();
|
|
1377
|
+
mockTitleService = {
|
|
1378
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1379
|
+
cancel: jest.fn(),
|
|
1380
|
+
};
|
|
1381
|
+
deps = createMockDeps({
|
|
1382
|
+
getTitleGenerationService: () => mockTitleService,
|
|
1383
|
+
});
|
|
1384
|
+
controller = new ConversationController(deps);
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
describe('regenerateTitle', () => {
|
|
1388
|
+
it('should not regenerate if titleService is null', async () => {
|
|
1389
|
+
const depsNoService = createMockDeps({
|
|
1390
|
+
getTitleGenerationService: () => null,
|
|
1391
|
+
});
|
|
1392
|
+
const controllerNoService = new ConversationController(depsNoService);
|
|
1393
|
+
|
|
1394
|
+
(depsNoService.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1395
|
+
id: 'conv-1',
|
|
1396
|
+
title: 'Old Title',
|
|
1397
|
+
messages: [
|
|
1398
|
+
{ role: 'user', content: 'Hello' },
|
|
1399
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
1400
|
+
],
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
await controllerNoService.regenerateTitle('conv-1');
|
|
1404
|
+
|
|
1405
|
+
expect(depsNoService.plugin.updateConversation).not.toHaveBeenCalled();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
it('should not regenerate if enableAutoTitleGeneration is false', async () => {
|
|
1409
|
+
deps.plugin.settings.enableAutoTitleGeneration = false;
|
|
1410
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1411
|
+
id: 'conv-1',
|
|
1412
|
+
title: 'Old Title',
|
|
1413
|
+
messages: [
|
|
1414
|
+
{ role: 'user', content: 'Hello' },
|
|
1415
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
1416
|
+
],
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
await controller.regenerateTitle('conv-1');
|
|
1420
|
+
|
|
1421
|
+
expect(mockTitleService.generateTitle).not.toHaveBeenCalled();
|
|
1422
|
+
expect(deps.plugin.updateConversation).not.toHaveBeenCalled();
|
|
1423
|
+
|
|
1424
|
+
deps.plugin.settings.enableAutoTitleGeneration = true;
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
it('should not regenerate if conversation not found', async () => {
|
|
1428
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue(null);
|
|
1429
|
+
|
|
1430
|
+
await controller.regenerateTitle('non-existent');
|
|
1431
|
+
|
|
1432
|
+
expect(mockTitleService.generateTitle).not.toHaveBeenCalled();
|
|
1433
|
+
});
|
|
1434
|
+
|
|
1435
|
+
it('should not regenerate if conversation has no messages', async () => {
|
|
1436
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1437
|
+
id: 'conv-1',
|
|
1438
|
+
title: 'Title',
|
|
1439
|
+
messages: [],
|
|
1440
|
+
});
|
|
1441
|
+
|
|
1442
|
+
await controller.regenerateTitle('conv-1');
|
|
1443
|
+
|
|
1444
|
+
expect(mockTitleService.generateTitle).not.toHaveBeenCalled();
|
|
1445
|
+
});
|
|
1446
|
+
|
|
1447
|
+
it('should not regenerate if no user message found', async () => {
|
|
1448
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1449
|
+
id: 'conv-1',
|
|
1450
|
+
title: 'Title',
|
|
1451
|
+
messages: [
|
|
1452
|
+
{ role: 'assistant', content: 'Hi' },
|
|
1453
|
+
{ role: 'assistant', content: 'There' },
|
|
1454
|
+
],
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
await controller.regenerateTitle('conv-1');
|
|
1458
|
+
|
|
1459
|
+
expect(mockTitleService.generateTitle).not.toHaveBeenCalled();
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
it('should set pending status before generating', async () => {
|
|
1463
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1464
|
+
id: 'conv-1',
|
|
1465
|
+
title: 'Old Title',
|
|
1466
|
+
messages: [
|
|
1467
|
+
{ role: 'user', content: 'Hello' },
|
|
1468
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
1469
|
+
],
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
await controller.regenerateTitle('conv-1');
|
|
1473
|
+
|
|
1474
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
1475
|
+
titleGenerationStatus: 'pending',
|
|
1476
|
+
});
|
|
1477
|
+
});
|
|
1478
|
+
|
|
1479
|
+
it('should call titleService.generateTitle with correct params', async () => {
|
|
1480
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1481
|
+
id: 'conv-1',
|
|
1482
|
+
title: 'Old Title',
|
|
1483
|
+
messages: [
|
|
1484
|
+
{ role: 'user', content: 'Hello world', displayContent: 'Hello world!' },
|
|
1485
|
+
{ role: 'assistant', content: 'Hi there!' },
|
|
1486
|
+
],
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1489
|
+
await controller.regenerateTitle('conv-1');
|
|
1490
|
+
|
|
1491
|
+
expect(mockTitleService.generateTitle).toHaveBeenCalledWith(
|
|
1492
|
+
'conv-1',
|
|
1493
|
+
'Hello world!', // Uses displayContent
|
|
1494
|
+
expect.any(Function)
|
|
1495
|
+
);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it('should regenerate title with only user message (no assistant yet)', async () => {
|
|
1499
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1500
|
+
id: 'conv-1',
|
|
1501
|
+
title: 'Old Title',
|
|
1502
|
+
messages: [{ role: 'user', content: 'Hello world' }],
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
await controller.regenerateTitle('conv-1');
|
|
1506
|
+
|
|
1507
|
+
expect(mockTitleService.generateTitle).toHaveBeenCalledWith(
|
|
1508
|
+
'conv-1',
|
|
1509
|
+
'Hello world',
|
|
1510
|
+
expect.any(Function)
|
|
1511
|
+
);
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1514
|
+
it('should rename conversation with generated title', async () => {
|
|
1515
|
+
(deps.plugin.getConversationById as any) = jest.fn().mockResolvedValue({
|
|
1516
|
+
id: 'conv-1',
|
|
1517
|
+
title: 'Old Title',
|
|
1518
|
+
messages: [
|
|
1519
|
+
{ role: 'user', content: 'Create a plan' },
|
|
1520
|
+
{ role: 'assistant', content: 'Here is the plan...' },
|
|
1521
|
+
],
|
|
1522
|
+
});
|
|
1523
|
+
|
|
1524
|
+
mockTitleService.generateTitle.mockImplementation(
|
|
1525
|
+
async (convId: string, _user: string, callback: any) => {
|
|
1526
|
+
await callback(convId, { success: true, title: 'New Generated Title' });
|
|
1527
|
+
}
|
|
1528
|
+
);
|
|
1529
|
+
|
|
1530
|
+
(deps.plugin.renameConversation as any) = jest.fn().mockResolvedValue(undefined);
|
|
1531
|
+
|
|
1532
|
+
await controller.regenerateTitle('conv-1');
|
|
1533
|
+
|
|
1534
|
+
expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'New Generated Title');
|
|
1535
|
+
});
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
describe('generateFallbackTitle', () => {
|
|
1539
|
+
it('should generate title from first sentence', () => {
|
|
1540
|
+
const title = controller.generateFallbackTitle('How do I set up React? I need help.');
|
|
1541
|
+
|
|
1542
|
+
expect(title).toBe('How do I set up React');
|
|
1543
|
+
});
|
|
1544
|
+
|
|
1545
|
+
it('should truncate long titles to 50 chars', () => {
|
|
1546
|
+
const longMessage = 'A'.repeat(100);
|
|
1547
|
+
const title = controller.generateFallbackTitle(longMessage);
|
|
1548
|
+
|
|
1549
|
+
expect(title.length).toBeLessThanOrEqual(53); // 50 + '...'
|
|
1550
|
+
expect(title).toContain('...');
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
it('should handle messages with no sentence breaks', () => {
|
|
1554
|
+
const title = controller.generateFallbackTitle('Hello world');
|
|
1555
|
+
|
|
1556
|
+
expect(title).toBe('Hello world');
|
|
1557
|
+
});
|
|
1558
|
+
});
|
|
1559
|
+
});
|
|
1560
|
+
|
|
1561
|
+
describe('ConversationController - MCP Server Persistence', () => {
|
|
1562
|
+
let controller: ConversationController;
|
|
1563
|
+
let deps: ConversationControllerDeps;
|
|
1564
|
+
let mockMcpServerSelector: any;
|
|
1565
|
+
|
|
1566
|
+
beforeEach(() => {
|
|
1567
|
+
jest.clearAllMocks();
|
|
1568
|
+
mockMcpServerSelector = {
|
|
1569
|
+
clearEnabled: jest.fn(),
|
|
1570
|
+
getEnabledServers: jest.fn().mockReturnValue(new Set(['mcp-server-1', 'mcp-server-2'])),
|
|
1571
|
+
setEnabledServers: jest.fn(),
|
|
1572
|
+
};
|
|
1573
|
+
deps = createMockDeps({
|
|
1574
|
+
getMcpServerSelector: () => mockMcpServerSelector,
|
|
1575
|
+
});
|
|
1576
|
+
controller = new ConversationController(deps);
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
describe('save', () => {
|
|
1580
|
+
it('should save enabled MCP servers to conversation', async () => {
|
|
1581
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1582
|
+
|
|
1583
|
+
await controller.save();
|
|
1584
|
+
|
|
1585
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
1586
|
+
'conv-1',
|
|
1587
|
+
expect.objectContaining({
|
|
1588
|
+
enabledMcpServers: ['mcp-server-1', 'mcp-server-2'],
|
|
1589
|
+
})
|
|
1590
|
+
);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it('should save undefined when no MCP servers enabled', async () => {
|
|
1594
|
+
mockMcpServerSelector.getEnabledServers.mockReturnValue(new Set());
|
|
1595
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1596
|
+
|
|
1597
|
+
await controller.save();
|
|
1598
|
+
|
|
1599
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
1600
|
+
'conv-1',
|
|
1601
|
+
expect.objectContaining({
|
|
1602
|
+
enabledMcpServers: undefined,
|
|
1603
|
+
})
|
|
1604
|
+
);
|
|
1605
|
+
});
|
|
1606
|
+
});
|
|
1607
|
+
|
|
1608
|
+
describe('loadActive', () => {
|
|
1609
|
+
it('should restore enabled MCP servers from conversation', async () => {
|
|
1610
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1611
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
1612
|
+
id: 'conv-1',
|
|
1613
|
+
messages: [],
|
|
1614
|
+
sessionId: null,
|
|
1615
|
+
enabledMcpServers: ['restored-server-1', 'restored-server-2'],
|
|
1616
|
+
});
|
|
1617
|
+
|
|
1618
|
+
await controller.loadActive();
|
|
1619
|
+
|
|
1620
|
+
expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith([
|
|
1621
|
+
'restored-server-1',
|
|
1622
|
+
'restored-server-2',
|
|
1623
|
+
]);
|
|
1624
|
+
});
|
|
1625
|
+
|
|
1626
|
+
it('should clear MCP servers when conversation has none', async () => {
|
|
1627
|
+
deps.state.currentConversationId = 'conv-1';
|
|
1628
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
1629
|
+
id: 'conv-1',
|
|
1630
|
+
messages: [],
|
|
1631
|
+
sessionId: null,
|
|
1632
|
+
enabledMcpServers: undefined,
|
|
1633
|
+
});
|
|
1634
|
+
|
|
1635
|
+
await controller.loadActive();
|
|
1636
|
+
|
|
1637
|
+
expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled();
|
|
1638
|
+
});
|
|
1639
|
+
});
|
|
1640
|
+
|
|
1641
|
+
describe('switchTo', () => {
|
|
1642
|
+
it('should restore enabled MCP servers when switching conversations', async () => {
|
|
1643
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1644
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
1645
|
+
id: 'new-conv',
|
|
1646
|
+
providerId: 'claude',
|
|
1647
|
+
messages: [],
|
|
1648
|
+
sessionId: null,
|
|
1649
|
+
enabledMcpServers: ['switched-server'],
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
await controller.switchTo('new-conv');
|
|
1653
|
+
|
|
1654
|
+
expect(mockMcpServerSelector.setEnabledServers).toHaveBeenCalledWith(['switched-server']);
|
|
1655
|
+
});
|
|
1656
|
+
|
|
1657
|
+
it('should clear MCP servers when switching to conversation with no servers', async () => {
|
|
1658
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1659
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
1660
|
+
id: 'new-conv',
|
|
1661
|
+
providerId: 'claude',
|
|
1662
|
+
messages: [],
|
|
1663
|
+
sessionId: null,
|
|
1664
|
+
enabledMcpServers: undefined,
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
await controller.switchTo('new-conv');
|
|
1668
|
+
|
|
1669
|
+
expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled();
|
|
1670
|
+
});
|
|
1671
|
+
|
|
1672
|
+
it('should ensure the tab service matches the switched conversation provider', async () => {
|
|
1673
|
+
const ensureServiceForConversation = jest.fn().mockResolvedValue(undefined);
|
|
1674
|
+
const switchedConversation = {
|
|
1675
|
+
id: 'new-conv',
|
|
1676
|
+
providerId: 'codex',
|
|
1677
|
+
title: 'Codex Conversation',
|
|
1678
|
+
messages: [],
|
|
1679
|
+
sessionId: null,
|
|
1680
|
+
createdAt: Date.now(),
|
|
1681
|
+
updatedAt: Date.now(),
|
|
1682
|
+
};
|
|
1683
|
+
|
|
1684
|
+
deps = createMockDeps({
|
|
1685
|
+
ensureServiceForConversation,
|
|
1686
|
+
plugin: {
|
|
1687
|
+
...createMockDeps().plugin,
|
|
1688
|
+
switchConversation: jest.fn().mockResolvedValue(switchedConversation),
|
|
1689
|
+
} as any,
|
|
1690
|
+
});
|
|
1691
|
+
controller = new ConversationController(deps);
|
|
1692
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1693
|
+
|
|
1694
|
+
await controller.switchTo('new-conv');
|
|
1695
|
+
|
|
1696
|
+
expect(ensureServiceForConversation).toHaveBeenCalledWith(switchedConversation);
|
|
1697
|
+
});
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
describe('createNew', () => {
|
|
1701
|
+
it('should clear enabled MCP servers for new conversation', async () => {
|
|
1702
|
+
await controller.createNew();
|
|
1703
|
+
|
|
1704
|
+
expect(mockMcpServerSelector.clearEnabled).toHaveBeenCalled();
|
|
1705
|
+
});
|
|
1706
|
+
});
|
|
1707
|
+
});
|
|
1708
|
+
|
|
1709
|
+
describe('ConversationController - Race Condition Guards', () => {
|
|
1710
|
+
let controller: ConversationController;
|
|
1711
|
+
let deps: ConversationControllerDeps;
|
|
1712
|
+
|
|
1713
|
+
beforeEach(() => {
|
|
1714
|
+
jest.clearAllMocks();
|
|
1715
|
+
deps = createMockDeps();
|
|
1716
|
+
controller = new ConversationController(deps);
|
|
1717
|
+
});
|
|
1718
|
+
|
|
1719
|
+
describe('createNew guards', () => {
|
|
1720
|
+
it('should not create when isCreatingConversation is already true', async () => {
|
|
1721
|
+
deps.state.isCreatingConversation = true;
|
|
1722
|
+
|
|
1723
|
+
await controller.createNew();
|
|
1724
|
+
|
|
1725
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
1726
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
it('should not create when isSwitchingConversation is true', async () => {
|
|
1730
|
+
deps.state.isSwitchingConversation = true;
|
|
1731
|
+
|
|
1732
|
+
await controller.createNew();
|
|
1733
|
+
|
|
1734
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
1735
|
+
});
|
|
1736
|
+
|
|
1737
|
+
it('should reset even when streaming if force is true', async () => {
|
|
1738
|
+
deps.state.isStreaming = true;
|
|
1739
|
+
deps.state.cancelRequested = false;
|
|
1740
|
+
const initialGeneration = deps.state.streamGeneration;
|
|
1741
|
+
|
|
1742
|
+
await controller.createNew({ force: true });
|
|
1743
|
+
|
|
1744
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
1745
|
+
expect(deps.state.cancelRequested).toBe(true);
|
|
1746
|
+
expect(deps.state.streamGeneration).toBe(initialGeneration + 1);
|
|
1747
|
+
expect(deps.state.currentConversationId).toBeNull();
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
it('should set and reset isCreatingConversation flag during entry point reset', async () => {
|
|
1751
|
+
// Entry point model: createNew() just resets state, doesn't create conversation
|
|
1752
|
+
// But isCreatingConversation flag should still be set during the reset
|
|
1753
|
+
let flagDuringExecution = false;
|
|
1754
|
+
|
|
1755
|
+
deps.state.clearMessages = jest.fn(() => {
|
|
1756
|
+
flagDuringExecution = deps.state.isCreatingConversation;
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
await controller.createNew();
|
|
1760
|
+
|
|
1761
|
+
expect(flagDuringExecution).toBe(true);
|
|
1762
|
+
expect(deps.state.isCreatingConversation).toBe(false);
|
|
1763
|
+
});
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
describe('switchTo guards', () => {
|
|
1767
|
+
it('should not switch when isSwitchingConversation is already true', async () => {
|
|
1768
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1769
|
+
deps.state.isSwitchingConversation = true;
|
|
1770
|
+
|
|
1771
|
+
await controller.switchTo('new-conv');
|
|
1772
|
+
|
|
1773
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
1774
|
+
});
|
|
1775
|
+
|
|
1776
|
+
it('should not switch when isCreatingConversation is true', async () => {
|
|
1777
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1778
|
+
deps.state.isCreatingConversation = true;
|
|
1779
|
+
|
|
1780
|
+
await controller.switchTo('new-conv');
|
|
1781
|
+
|
|
1782
|
+
expect(deps.plugin.switchConversation).not.toHaveBeenCalled();
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it('should reset isSwitchingConversation flag even on error', async () => {
|
|
1786
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1787
|
+
(deps.plugin.switchConversation as jest.Mock).mockRejectedValue(new Error('Switch failed'));
|
|
1788
|
+
|
|
1789
|
+
await expect(controller.switchTo('new-conv')).rejects.toThrow('Switch failed');
|
|
1790
|
+
|
|
1791
|
+
expect(deps.state.isSwitchingConversation).toBe(false);
|
|
1792
|
+
});
|
|
1793
|
+
|
|
1794
|
+
it('should reset isSwitchingConversation flag when conversation not found', async () => {
|
|
1795
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1796
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue(null);
|
|
1797
|
+
|
|
1798
|
+
await controller.switchTo('non-existent');
|
|
1799
|
+
|
|
1800
|
+
expect(deps.state.isSwitchingConversation).toBe(false);
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it('should set isSwitchingConversation flag during switch', async () => {
|
|
1804
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1805
|
+
let flagDuringSwitch = false;
|
|
1806
|
+
(deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => {
|
|
1807
|
+
flagDuringSwitch = deps.state.isSwitchingConversation;
|
|
1808
|
+
return {
|
|
1809
|
+
id: 'new-conv',
|
|
1810
|
+
title: 'New Conversation',
|
|
1811
|
+
messages: [],
|
|
1812
|
+
sessionId: null,
|
|
1813
|
+
createdAt: Date.now(),
|
|
1814
|
+
updatedAt: Date.now(),
|
|
1815
|
+
};
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
await controller.switchTo('new-conv');
|
|
1819
|
+
|
|
1820
|
+
expect(flagDuringSwitch).toBe(true);
|
|
1821
|
+
expect(deps.state.isSwitchingConversation).toBe(false);
|
|
1822
|
+
});
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
describe('mutual exclusion', () => {
|
|
1826
|
+
it('should prevent createNew during switchTo', async () => {
|
|
1827
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1828
|
+
|
|
1829
|
+
// Simulate switchTo in progress
|
|
1830
|
+
let switchPromiseResolve: () => void;
|
|
1831
|
+
const switchPromise = new Promise<void>((resolve) => {
|
|
1832
|
+
switchPromiseResolve = resolve;
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
(deps.plugin.switchConversation as jest.Mock).mockImplementation(async () => {
|
|
1836
|
+
// During switch, try to createNew
|
|
1837
|
+
const createPromise = controller.createNew();
|
|
1838
|
+
|
|
1839
|
+
// createNew should be blocked because isSwitchingConversation is true
|
|
1840
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
1841
|
+
|
|
1842
|
+
switchPromiseResolve!();
|
|
1843
|
+
await createPromise;
|
|
1844
|
+
|
|
1845
|
+
return {
|
|
1846
|
+
id: 'new-conv',
|
|
1847
|
+
messages: [],
|
|
1848
|
+
sessionId: null,
|
|
1849
|
+
};
|
|
1850
|
+
});
|
|
1851
|
+
|
|
1852
|
+
await controller.switchTo('new-conv');
|
|
1853
|
+
await switchPromise;
|
|
1854
|
+
|
|
1855
|
+
expect(deps.plugin.createConversation).not.toHaveBeenCalled();
|
|
1856
|
+
});
|
|
1857
|
+
});
|
|
1858
|
+
});
|
|
1859
|
+
|
|
1860
|
+
describe('ConversationController - Persistent External Context Paths', () => {
|
|
1861
|
+
let controller: ConversationController;
|
|
1862
|
+
let deps: ConversationControllerDeps;
|
|
1863
|
+
let mockExternalContextSelector: any;
|
|
1864
|
+
|
|
1865
|
+
beforeEach(() => {
|
|
1866
|
+
jest.clearAllMocks();
|
|
1867
|
+
mockExternalContextSelector = {
|
|
1868
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1869
|
+
setExternalContexts: jest.fn(),
|
|
1870
|
+
clearExternalContexts: jest.fn(),
|
|
1871
|
+
};
|
|
1872
|
+
deps = createMockDeps({
|
|
1873
|
+
getExternalContextSelector: () => mockExternalContextSelector,
|
|
1874
|
+
});
|
|
1875
|
+
(deps.plugin.settings as any).persistentExternalContextPaths = ['/persistent/path/a', '/persistent/path/b'];
|
|
1876
|
+
controller = new ConversationController(deps);
|
|
1877
|
+
});
|
|
1878
|
+
|
|
1879
|
+
describe('createNew', () => {
|
|
1880
|
+
it('should call clearExternalContexts with persistent paths from settings', async () => {
|
|
1881
|
+
await controller.createNew();
|
|
1882
|
+
|
|
1883
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(
|
|
1884
|
+
['/persistent/path/a', '/persistent/path/b']
|
|
1885
|
+
);
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
it('should call clearExternalContexts with empty array if no persistent paths', async () => {
|
|
1889
|
+
(deps.plugin.settings as any).persistentExternalContextPaths = undefined;
|
|
1890
|
+
|
|
1891
|
+
await controller.createNew();
|
|
1892
|
+
|
|
1893
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith([]);
|
|
1894
|
+
});
|
|
1895
|
+
});
|
|
1896
|
+
|
|
1897
|
+
describe('loadActive', () => {
|
|
1898
|
+
it('should use persistent paths for new conversation (no existing conversation)', async () => {
|
|
1899
|
+
deps.state.currentConversationId = null;
|
|
1900
|
+
|
|
1901
|
+
await controller.loadActive();
|
|
1902
|
+
|
|
1903
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(
|
|
1904
|
+
['/persistent/path/a', '/persistent/path/b']
|
|
1905
|
+
);
|
|
1906
|
+
});
|
|
1907
|
+
|
|
1908
|
+
it('should use persistent paths for empty conversation (msg=0)', async () => {
|
|
1909
|
+
deps.state.currentConversationId = 'existing-conv';
|
|
1910
|
+
deps.plugin.getConversationById = jest.fn().mockResolvedValue({
|
|
1911
|
+
id: 'existing-conv',
|
|
1912
|
+
messages: [],
|
|
1913
|
+
sessionId: null,
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
await controller.loadActive();
|
|
1917
|
+
|
|
1918
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(
|
|
1919
|
+
['/persistent/path/a', '/persistent/path/b']
|
|
1920
|
+
);
|
|
1921
|
+
});
|
|
1922
|
+
|
|
1923
|
+
it('should restore saved paths for conversation with messages (msg>0)', async () => {
|
|
1924
|
+
deps.state.currentConversationId = 'existing-conv';
|
|
1925
|
+
deps.plugin.getConversationById = jest.fn().mockResolvedValue({
|
|
1926
|
+
id: 'existing-conv',
|
|
1927
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
1928
|
+
sessionId: null,
|
|
1929
|
+
externalContextPaths: ['/saved/path'],
|
|
1930
|
+
});
|
|
1931
|
+
|
|
1932
|
+
await controller.loadActive();
|
|
1933
|
+
|
|
1934
|
+
expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith(['/saved/path']);
|
|
1935
|
+
expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled();
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
it('should restore empty paths for conversation with messages but no saved paths', async () => {
|
|
1939
|
+
deps.state.currentConversationId = 'existing-conv';
|
|
1940
|
+
deps.plugin.getConversationById = jest.fn().mockResolvedValue({
|
|
1941
|
+
id: 'existing-conv',
|
|
1942
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
1943
|
+
sessionId: null,
|
|
1944
|
+
externalContextPaths: undefined,
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
await controller.loadActive();
|
|
1948
|
+
|
|
1949
|
+
expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]);
|
|
1950
|
+
});
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
describe('switchTo', () => {
|
|
1954
|
+
beforeEach(() => {
|
|
1955
|
+
deps.state.currentConversationId = 'old-conv';
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
it('should use persistent paths when switching to empty conversation (msg=0)', async () => {
|
|
1959
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
1960
|
+
id: 'empty-conv',
|
|
1961
|
+
messages: [],
|
|
1962
|
+
sessionId: null,
|
|
1963
|
+
externalContextPaths: ['/old/saved/path'],
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
await controller.switchTo('empty-conv');
|
|
1967
|
+
|
|
1968
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(
|
|
1969
|
+
['/persistent/path/a', '/persistent/path/b']
|
|
1970
|
+
);
|
|
1971
|
+
expect(mockExternalContextSelector.setExternalContexts).not.toHaveBeenCalled();
|
|
1972
|
+
});
|
|
1973
|
+
|
|
1974
|
+
it('should restore saved paths when switching to conversation with messages', async () => {
|
|
1975
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
1976
|
+
id: 'conv-with-messages',
|
|
1977
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
1978
|
+
sessionId: null,
|
|
1979
|
+
externalContextPaths: ['/saved/path/from/session'],
|
|
1980
|
+
});
|
|
1981
|
+
|
|
1982
|
+
await controller.switchTo('conv-with-messages');
|
|
1983
|
+
|
|
1984
|
+
expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith(
|
|
1985
|
+
['/saved/path/from/session']
|
|
1986
|
+
);
|
|
1987
|
+
expect(mockExternalContextSelector.clearExternalContexts).not.toHaveBeenCalled();
|
|
1988
|
+
});
|
|
1989
|
+
|
|
1990
|
+
it('should restore empty array for conversation with messages but no saved paths', async () => {
|
|
1991
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
1992
|
+
id: 'conv-with-messages',
|
|
1993
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
1994
|
+
sessionId: null,
|
|
1995
|
+
externalContextPaths: undefined,
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
await controller.switchTo('conv-with-messages');
|
|
1999
|
+
|
|
2000
|
+
expect(mockExternalContextSelector.setExternalContexts).toHaveBeenCalledWith([]);
|
|
2001
|
+
});
|
|
2002
|
+
});
|
|
2003
|
+
|
|
2004
|
+
describe('Scenario: Adding persistent paths across sessions', () => {
|
|
2005
|
+
it('should show all persistent paths when returning to empty session', async () => {
|
|
2006
|
+
// Scenario:
|
|
2007
|
+
// 1. User is in session 0 (empty), adds path A as persistent
|
|
2008
|
+
// 2. User switches to session 1 (with messages), adds path B as persistent
|
|
2009
|
+
// 3. User returns to session 0 (empty) - should see both A and B
|
|
2010
|
+
|
|
2011
|
+
// Step 1: Session 0 is empty, persistent paths = [A]
|
|
2012
|
+
(deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a'];
|
|
2013
|
+
deps.state.currentConversationId = null;
|
|
2014
|
+
await controller.loadActive();
|
|
2015
|
+
|
|
2016
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(['/path/a']);
|
|
2017
|
+
|
|
2018
|
+
// Step 2: User switches to session 1 and adds path B, settings now have [A, B]
|
|
2019
|
+
deps.state.currentConversationId = 'session-0'; // Currently in session 0
|
|
2020
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
2021
|
+
id: 'session-1',
|
|
2022
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
2023
|
+
sessionId: null,
|
|
2024
|
+
externalContextPaths: [],
|
|
2025
|
+
});
|
|
2026
|
+
await controller.switchTo('session-1');
|
|
2027
|
+
|
|
2028
|
+
// User adds path B in session 1, settings now have [A, B]
|
|
2029
|
+
(deps.plugin.settings as any).persistentExternalContextPaths = ['/path/a', '/path/b'];
|
|
2030
|
+
|
|
2031
|
+
// Step 3: User returns to session 0 (empty)
|
|
2032
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
2033
|
+
id: 'session-0',
|
|
2034
|
+
messages: [], // Empty session
|
|
2035
|
+
sessionId: null,
|
|
2036
|
+
externalContextPaths: ['/path/a'], // Only had A when originally created
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
jest.clearAllMocks();
|
|
2040
|
+
await controller.switchTo('session-0');
|
|
2041
|
+
|
|
2042
|
+
// Should get BOTH paths because session is empty (msg=0)
|
|
2043
|
+
expect(mockExternalContextSelector.clearExternalContexts).toHaveBeenCalledWith(
|
|
2044
|
+
['/path/a', '/path/b']
|
|
2045
|
+
);
|
|
2046
|
+
});
|
|
2047
|
+
});
|
|
2048
|
+
});
|
|
2049
|
+
|
|
2050
|
+
function createMockBuildSessionUpdates(mockService: any) {
|
|
2051
|
+
return jest.fn().mockImplementation(({ conversation, sessionInvalidated }: any) => {
|
|
2052
|
+
const sessionId = mockService.getSessionId();
|
|
2053
|
+
const legacyMessages = conversation?.messages ?? [];
|
|
2054
|
+
const hasSession = !!sessionId;
|
|
2055
|
+
const legacyCutoffAt = hasSession && !conversation?.providerSessionId
|
|
2056
|
+
? legacyMessages[legacyMessages.length - 1]?.timestamp
|
|
2057
|
+
: conversation?.legacyCutoffAt;
|
|
2058
|
+
const oldSdkSessionId = conversation?.providerSessionId;
|
|
2059
|
+
const sessionChanged = hasSession && sessionId && oldSdkSessionId && sessionId !== oldSdkSessionId;
|
|
2060
|
+
const previousProviderSessionIds = sessionChanged
|
|
2061
|
+
? [...new Set([...(conversation?.previousProviderSessionIds || []), oldSdkSessionId])]
|
|
2062
|
+
: conversation?.previousProviderSessionIds;
|
|
2063
|
+
const isForkSourceOnly = !!conversation?.forkSource &&
|
|
2064
|
+
!conversation?.providerSessionId &&
|
|
2065
|
+
sessionId === conversation.forkSource.sessionId;
|
|
2066
|
+
let resolvedSessionId: string | null;
|
|
2067
|
+
if (sessionInvalidated) {
|
|
2068
|
+
resolvedSessionId = null;
|
|
2069
|
+
} else if (isForkSourceOnly) {
|
|
2070
|
+
resolvedSessionId = conversation?.sessionId ?? null;
|
|
2071
|
+
} else {
|
|
2072
|
+
resolvedSessionId = sessionId ?? conversation?.sessionId ?? null;
|
|
2073
|
+
}
|
|
2074
|
+
const updates: any = {
|
|
2075
|
+
sessionId: resolvedSessionId,
|
|
2076
|
+
providerSessionId: hasSession && sessionId && !isForkSourceOnly ? sessionId : conversation?.providerSessionId,
|
|
2077
|
+
previousProviderSessionIds,
|
|
2078
|
+
legacyCutoffAt,
|
|
2079
|
+
};
|
|
2080
|
+
if (conversation?.forkSource && sessionId && sessionId !== conversation.forkSource.sessionId) {
|
|
2081
|
+
updates.forkSource = undefined;
|
|
2082
|
+
}
|
|
2083
|
+
return { updates };
|
|
2084
|
+
});
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
describe('ConversationController - Previous SDK Session IDs', () => {
|
|
2088
|
+
let controller: ConversationController;
|
|
2089
|
+
let deps: ConversationControllerDeps;
|
|
2090
|
+
let mockAgentService: any;
|
|
2091
|
+
|
|
2092
|
+
beforeEach(() => {
|
|
2093
|
+
jest.clearAllMocks();
|
|
2094
|
+
mockAgentService = {
|
|
2095
|
+
getSessionId: jest.fn().mockReturnValue(null),
|
|
2096
|
+
setSessionId: jest.fn(),
|
|
2097
|
+
consumeSessionInvalidation: jest.fn().mockReturnValue(false),
|
|
2098
|
+
buildSessionUpdates: null as any,
|
|
2099
|
+
};
|
|
2100
|
+
mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService);
|
|
2101
|
+
deps = createMockDeps({
|
|
2102
|
+
getAgentService: () => mockAgentService,
|
|
2103
|
+
});
|
|
2104
|
+
controller = new ConversationController(deps);
|
|
2105
|
+
});
|
|
2106
|
+
|
|
2107
|
+
describe('save - session change detection', () => {
|
|
2108
|
+
it('should accumulate old providerSessionId when SDK creates new session', async () => {
|
|
2109
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2110
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2111
|
+
|
|
2112
|
+
// Existing conversation has providerSessionId 'session-A'
|
|
2113
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2114
|
+
id: 'conv-1',
|
|
2115
|
+
messages: [],
|
|
2116
|
+
providerSessionId: 'session-A',
|
|
2117
|
+
previousProviderSessionIds: undefined,
|
|
2118
|
+
});
|
|
2119
|
+
|
|
2120
|
+
// Agent service reports new session 'session-B' (resume failed, new session created)
|
|
2121
|
+
mockAgentService.getSessionId.mockReturnValue('session-B');
|
|
2122
|
+
|
|
2123
|
+
await controller.save();
|
|
2124
|
+
|
|
2125
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2126
|
+
'conv-1',
|
|
2127
|
+
expect.objectContaining({
|
|
2128
|
+
providerSessionId: 'session-B',
|
|
2129
|
+
previousProviderSessionIds: ['session-A'],
|
|
2130
|
+
})
|
|
2131
|
+
);
|
|
2132
|
+
});
|
|
2133
|
+
|
|
2134
|
+
it('should preserve existing previousProviderSessionIds when session changes again', async () => {
|
|
2135
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2136
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2137
|
+
|
|
2138
|
+
// Conversation already has previous sessions [A], current is B
|
|
2139
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2140
|
+
id: 'conv-1',
|
|
2141
|
+
messages: [],
|
|
2142
|
+
providerSessionId: 'session-B',
|
|
2143
|
+
previousProviderSessionIds: ['session-A'],
|
|
2144
|
+
});
|
|
2145
|
+
|
|
2146
|
+
// Agent service reports new session 'session-C'
|
|
2147
|
+
mockAgentService.getSessionId.mockReturnValue('session-C');
|
|
2148
|
+
|
|
2149
|
+
await controller.save();
|
|
2150
|
+
|
|
2151
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2152
|
+
'conv-1',
|
|
2153
|
+
expect.objectContaining({
|
|
2154
|
+
providerSessionId: 'session-C',
|
|
2155
|
+
previousProviderSessionIds: ['session-A', 'session-B'],
|
|
2156
|
+
})
|
|
2157
|
+
);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it('should not modify previousProviderSessionIds when session has not changed', async () => {
|
|
2161
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2162
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2163
|
+
|
|
2164
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2165
|
+
id: 'conv-1',
|
|
2166
|
+
messages: [],
|
|
2167
|
+
providerSessionId: 'session-A',
|
|
2168
|
+
previousProviderSessionIds: undefined,
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
mockAgentService.getSessionId.mockReturnValue('session-A');
|
|
2172
|
+
|
|
2173
|
+
await controller.save();
|
|
2174
|
+
|
|
2175
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2176
|
+
'conv-1',
|
|
2177
|
+
expect.objectContaining({
|
|
2178
|
+
providerSessionId: 'session-A',
|
|
2179
|
+
previousProviderSessionIds: undefined,
|
|
2180
|
+
})
|
|
2181
|
+
);
|
|
2182
|
+
});
|
|
2183
|
+
|
|
2184
|
+
it('should deduplicate session IDs to prevent duplicates from race conditions', async () => {
|
|
2185
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2186
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2187
|
+
|
|
2188
|
+
// Simulate a race condition where session-A is already in previousProviderSessionIds
|
|
2189
|
+
// but providerSessionId is still session-A (should not duplicate)
|
|
2190
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2191
|
+
id: 'conv-1',
|
|
2192
|
+
messages: [],
|
|
2193
|
+
providerSessionId: 'session-A',
|
|
2194
|
+
previousProviderSessionIds: ['session-A'], // Already contains A (from prior bug/race)
|
|
2195
|
+
});
|
|
2196
|
+
|
|
2197
|
+
// Agent reports new session-B
|
|
2198
|
+
mockAgentService.getSessionId.mockReturnValue('session-B');
|
|
2199
|
+
|
|
2200
|
+
await controller.save();
|
|
2201
|
+
|
|
2202
|
+
// Should deduplicate: [A, A] -> [A]
|
|
2203
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2204
|
+
'conv-1',
|
|
2205
|
+
expect.objectContaining({
|
|
2206
|
+
providerSessionId: 'session-B',
|
|
2207
|
+
previousProviderSessionIds: ['session-A'], // Deduplicated, not ['session-A', 'session-A']
|
|
2208
|
+
})
|
|
2209
|
+
);
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
describe('ConversationController - Fork Session ID Isolation', () => {
|
|
2215
|
+
let controller: ConversationController;
|
|
2216
|
+
let deps: ConversationControllerDeps;
|
|
2217
|
+
let mockAgentService: any;
|
|
2218
|
+
|
|
2219
|
+
beforeEach(() => {
|
|
2220
|
+
jest.clearAllMocks();
|
|
2221
|
+
mockAgentService = {
|
|
2222
|
+
getSessionId: jest.fn().mockReturnValue(null),
|
|
2223
|
+
setSessionId: jest.fn(),
|
|
2224
|
+
consumeSessionInvalidation: jest.fn().mockReturnValue(false),
|
|
2225
|
+
buildSessionUpdates: null as any,
|
|
2226
|
+
};
|
|
2227
|
+
mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService);
|
|
2228
|
+
deps = createMockDeps({
|
|
2229
|
+
getAgentService: () => mockAgentService,
|
|
2230
|
+
});
|
|
2231
|
+
controller = new ConversationController(deps);
|
|
2232
|
+
});
|
|
2233
|
+
|
|
2234
|
+
it('should not persist fork source session ID as conversation own sessionId/providerSessionId', async () => {
|
|
2235
|
+
deps.state.currentConversationId = 'fork-conv';
|
|
2236
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2237
|
+
|
|
2238
|
+
// Fork conversation: has forkSource but no own providerSessionId yet
|
|
2239
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2240
|
+
id: 'fork-conv',
|
|
2241
|
+
messages: [],
|
|
2242
|
+
sessionId: null,
|
|
2243
|
+
providerSessionId: undefined,
|
|
2244
|
+
forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' },
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
// Agent service has the fork source ID set for resume purposes
|
|
2248
|
+
mockAgentService.getSessionId.mockReturnValue('source-session-abc');
|
|
2249
|
+
|
|
2250
|
+
await controller.save();
|
|
2251
|
+
|
|
2252
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2253
|
+
'fork-conv',
|
|
2254
|
+
expect.objectContaining({
|
|
2255
|
+
sessionId: null,
|
|
2256
|
+
providerSessionId: undefined,
|
|
2257
|
+
})
|
|
2258
|
+
);
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
it('should persist new session ID after SDK captures a different session for fork', async () => {
|
|
2262
|
+
deps.state.currentConversationId = 'fork-conv';
|
|
2263
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2264
|
+
|
|
2265
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2266
|
+
id: 'fork-conv',
|
|
2267
|
+
messages: [],
|
|
2268
|
+
sessionId: null,
|
|
2269
|
+
providerSessionId: undefined,
|
|
2270
|
+
forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' },
|
|
2271
|
+
});
|
|
2272
|
+
|
|
2273
|
+
// SDK captured a new session (different from fork source)
|
|
2274
|
+
mockAgentService.getSessionId.mockReturnValue('new-session-xyz');
|
|
2275
|
+
|
|
2276
|
+
await controller.save();
|
|
2277
|
+
|
|
2278
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2279
|
+
'fork-conv',
|
|
2280
|
+
expect.objectContaining({
|
|
2281
|
+
sessionId: 'new-session-xyz',
|
|
2282
|
+
providerSessionId: 'new-session-xyz',
|
|
2283
|
+
forkSource: undefined,
|
|
2284
|
+
})
|
|
2285
|
+
);
|
|
2286
|
+
});
|
|
2287
|
+
|
|
2288
|
+
it('should allow normal session ID persistence when fork metadata is already cleared', async () => {
|
|
2289
|
+
deps.state.currentConversationId = 'fork-conv';
|
|
2290
|
+
deps.state.messages = [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }];
|
|
2291
|
+
|
|
2292
|
+
// Fork conversation after fork metadata was cleared (has its own providerSessionId)
|
|
2293
|
+
(deps.plugin.getConversationSync as jest.Mock).mockReturnValue({
|
|
2294
|
+
id: 'fork-conv',
|
|
2295
|
+
messages: [],
|
|
2296
|
+
sessionId: 'new-session-xyz',
|
|
2297
|
+
providerSessionId: 'new-session-xyz',
|
|
2298
|
+
forkSource: undefined,
|
|
2299
|
+
});
|
|
2300
|
+
|
|
2301
|
+
mockAgentService.getSessionId.mockReturnValue('new-session-xyz');
|
|
2302
|
+
|
|
2303
|
+
await controller.save();
|
|
2304
|
+
|
|
2305
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2306
|
+
'fork-conv',
|
|
2307
|
+
expect.objectContaining({
|
|
2308
|
+
sessionId: 'new-session-xyz',
|
|
2309
|
+
providerSessionId: 'new-session-xyz',
|
|
2310
|
+
})
|
|
2311
|
+
);
|
|
2312
|
+
});
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
describe('ConversationController - switchTo fork path', () => {
|
|
2316
|
+
let controller: ConversationController;
|
|
2317
|
+
let deps: ConversationControllerDeps;
|
|
2318
|
+
let mockAgentService: any;
|
|
2319
|
+
|
|
2320
|
+
beforeEach(() => {
|
|
2321
|
+
jest.clearAllMocks();
|
|
2322
|
+
mockAgentService = {
|
|
2323
|
+
getSessionId: jest.fn().mockReturnValue(null),
|
|
2324
|
+
syncConversationState: jest.fn(),
|
|
2325
|
+
consumeSessionInvalidation: jest.fn().mockReturnValue(false),
|
|
2326
|
+
buildSessionUpdates: null as any,
|
|
2327
|
+
};
|
|
2328
|
+
mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService);
|
|
2329
|
+
deps = createMockDeps({
|
|
2330
|
+
getAgentService: () => mockAgentService,
|
|
2331
|
+
});
|
|
2332
|
+
controller = new ConversationController(deps);
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
it('should sync conversation state for pending fork conversations', async () => {
|
|
2336
|
+
deps.state.currentConversationId = 'old-conv';
|
|
2337
|
+
|
|
2338
|
+
const forkConversation = {
|
|
2339
|
+
id: 'fork-conv',
|
|
2340
|
+
messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }],
|
|
2341
|
+
sessionId: null,
|
|
2342
|
+
providerSessionId: undefined,
|
|
2343
|
+
forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' },
|
|
2344
|
+
};
|
|
2345
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation);
|
|
2346
|
+
|
|
2347
|
+
await controller.switchTo('fork-conv');
|
|
2348
|
+
|
|
2349
|
+
expect(mockAgentService.syncConversationState).toHaveBeenCalledWith(
|
|
2350
|
+
forkConversation,
|
|
2351
|
+
expect.any(Array),
|
|
2352
|
+
);
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
it('should resolve to own sessionId when fork already has its own session', async () => {
|
|
2356
|
+
deps.state.currentConversationId = 'old-conv';
|
|
2357
|
+
|
|
2358
|
+
const forkConversation = {
|
|
2359
|
+
id: 'fork-conv',
|
|
2360
|
+
messages: [{ id: '1', role: 'user', content: 'forked msg', timestamp: Date.now() }],
|
|
2361
|
+
sessionId: 'own-session-xyz',
|
|
2362
|
+
providerSessionId: 'own-session-xyz',
|
|
2363
|
+
forkSource: { sessionId: 'source-session-abc', resumeAt: 'assistant-uuid-1' },
|
|
2364
|
+
};
|
|
2365
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue(forkConversation);
|
|
2366
|
+
|
|
2367
|
+
await controller.switchTo('fork-conv');
|
|
2368
|
+
|
|
2369
|
+
expect(mockAgentService.syncConversationState).toHaveBeenCalledWith(
|
|
2370
|
+
forkConversation,
|
|
2371
|
+
expect.any(Array),
|
|
2372
|
+
);
|
|
2373
|
+
});
|
|
2374
|
+
});
|
|
2375
|
+
|
|
2376
|
+
describe('ConversationController - restoreExternalContextPaths null selector', () => {
|
|
2377
|
+
it('should return early when external context selector is null', async () => {
|
|
2378
|
+
const deps = createMockDeps({
|
|
2379
|
+
getExternalContextSelector: () => null,
|
|
2380
|
+
});
|
|
2381
|
+
const controller = new ConversationController(deps);
|
|
2382
|
+
|
|
2383
|
+
deps.state.currentConversationId = 'old-conv';
|
|
2384
|
+
(deps.plugin.switchConversation as jest.Mock).mockResolvedValue({
|
|
2385
|
+
id: 'new-conv',
|
|
2386
|
+
messages: [{ id: '1', role: 'user', content: 'test', timestamp: Date.now() }],
|
|
2387
|
+
sessionId: null,
|
|
2388
|
+
externalContextPaths: ['/some/path'],
|
|
2389
|
+
});
|
|
2390
|
+
|
|
2391
|
+
// Should not throw even though selector is null
|
|
2392
|
+
await expect(controller.switchTo('new-conv')).resolves.not.toThrow();
|
|
2393
|
+
});
|
|
2394
|
+
});
|
|
2395
|
+
|
|
2396
|
+
describe('ConversationController - regenerateTitle callback branches', () => {
|
|
2397
|
+
let controller: ConversationController;
|
|
2398
|
+
let deps: ConversationControllerDeps;
|
|
2399
|
+
let mockTitleService: any;
|
|
2400
|
+
|
|
2401
|
+
beforeEach(() => {
|
|
2402
|
+
jest.clearAllMocks();
|
|
2403
|
+
mockTitleService = {
|
|
2404
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
2405
|
+
cancel: jest.fn(),
|
|
2406
|
+
};
|
|
2407
|
+
deps = createMockDeps({
|
|
2408
|
+
getTitleGenerationService: () => mockTitleService,
|
|
2409
|
+
});
|
|
2410
|
+
controller = new ConversationController(deps);
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
it('should mark as failed when generation fails and user has not renamed', async () => {
|
|
2414
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2415
|
+
id: 'conv-1',
|
|
2416
|
+
title: 'Original Title',
|
|
2417
|
+
messages: [
|
|
2418
|
+
{ role: 'user', content: 'Hello' },
|
|
2419
|
+
{ role: 'assistant', content: 'Hi!' },
|
|
2420
|
+
],
|
|
2421
|
+
});
|
|
2422
|
+
|
|
2423
|
+
mockTitleService.generateTitle.mockImplementation(
|
|
2424
|
+
async (_convId: string, _user: string, callback: any) => {
|
|
2425
|
+
// On callback, getConversationById returns same title (user didn't rename)
|
|
2426
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2427
|
+
id: 'conv-1',
|
|
2428
|
+
title: 'Original Title',
|
|
2429
|
+
messages: [],
|
|
2430
|
+
});
|
|
2431
|
+
await callback('conv-1', { success: false, title: '' });
|
|
2432
|
+
}
|
|
2433
|
+
);
|
|
2434
|
+
|
|
2435
|
+
await controller.regenerateTitle('conv-1');
|
|
2436
|
+
|
|
2437
|
+
expect(deps.plugin.renameConversation).not.toHaveBeenCalled();
|
|
2438
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
2439
|
+
titleGenerationStatus: 'failed',
|
|
2440
|
+
});
|
|
2441
|
+
});
|
|
2442
|
+
|
|
2443
|
+
it('should clear status when user manually renamed during generation', async () => {
|
|
2444
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2445
|
+
id: 'conv-1',
|
|
2446
|
+
title: 'Original Title',
|
|
2447
|
+
messages: [
|
|
2448
|
+
{ role: 'user', content: 'Hello' },
|
|
2449
|
+
{ role: 'assistant', content: 'Hi!' },
|
|
2450
|
+
],
|
|
2451
|
+
});
|
|
2452
|
+
|
|
2453
|
+
// Simulate callback where user has renamed the conversation
|
|
2454
|
+
mockTitleService.generateTitle.mockImplementation(
|
|
2455
|
+
async (_convId: string, _user: string, callback: any) => {
|
|
2456
|
+
// On callback, getConversationById returns a different title (user renamed)
|
|
2457
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2458
|
+
id: 'conv-1',
|
|
2459
|
+
title: 'User Renamed Title',
|
|
2460
|
+
messages: [],
|
|
2461
|
+
});
|
|
2462
|
+
await callback('conv-1', { success: true, title: 'AI Generated Title' });
|
|
2463
|
+
}
|
|
2464
|
+
);
|
|
2465
|
+
|
|
2466
|
+
await controller.regenerateTitle('conv-1');
|
|
2467
|
+
|
|
2468
|
+
// Should NOT rename because user already renamed
|
|
2469
|
+
expect(deps.plugin.renameConversation).not.toHaveBeenCalled();
|
|
2470
|
+
// Should clear the status since user's choice takes precedence
|
|
2471
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
2472
|
+
titleGenerationStatus: undefined,
|
|
2473
|
+
});
|
|
2474
|
+
});
|
|
2475
|
+
|
|
2476
|
+
it('should not apply title when conversation no longer exists during callback', async () => {
|
|
2477
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2478
|
+
id: 'conv-1',
|
|
2479
|
+
title: 'Original Title',
|
|
2480
|
+
messages: [
|
|
2481
|
+
{ role: 'user', content: 'Hello' },
|
|
2482
|
+
{ role: 'assistant', content: 'Hi!' },
|
|
2483
|
+
],
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
// Simulate callback where conversation was deleted
|
|
2487
|
+
mockTitleService.generateTitle.mockImplementation(
|
|
2488
|
+
async (_convId: string, _user: string, callback: any) => {
|
|
2489
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue(null);
|
|
2490
|
+
await callback('conv-1', { success: true, title: 'New Title' });
|
|
2491
|
+
}
|
|
2492
|
+
);
|
|
2493
|
+
|
|
2494
|
+
await controller.regenerateTitle('conv-1');
|
|
2495
|
+
|
|
2496
|
+
expect(deps.plugin.renameConversation).not.toHaveBeenCalled();
|
|
2497
|
+
});
|
|
2498
|
+
});
|
|
2499
|
+
|
|
2500
|
+
describe('ConversationController - Rewind', () => {
|
|
2501
|
+
let controller: ConversationController;
|
|
2502
|
+
let deps: ConversationControllerDeps;
|
|
2503
|
+
let mockAgentService: any;
|
|
2504
|
+
|
|
2505
|
+
beforeEach(() => {
|
|
2506
|
+
jest.clearAllMocks();
|
|
2507
|
+
mockAgentService = {
|
|
2508
|
+
getSessionId: jest.fn().mockReturnValue(null),
|
|
2509
|
+
setSessionId: jest.fn(),
|
|
2510
|
+
consumeSessionInvalidation: jest.fn().mockReturnValue(false),
|
|
2511
|
+
rewind: jest.fn().mockResolvedValue({ canRewind: true, filesChanged: ['a.ts'] }),
|
|
2512
|
+
getCapabilities: jest.fn().mockReturnValue({ supportsRewind: true }),
|
|
2513
|
+
buildSessionUpdates: null as any,
|
|
2514
|
+
};
|
|
2515
|
+
mockAgentService.buildSessionUpdates = createMockBuildSessionUpdates(mockAgentService);
|
|
2516
|
+
deps = createMockDeps({
|
|
2517
|
+
getAgentService: () => mockAgentService,
|
|
2518
|
+
});
|
|
2519
|
+
controller = new ConversationController(deps);
|
|
2520
|
+
});
|
|
2521
|
+
|
|
2522
|
+
it('should find prev/response assistants with bounded scan (skipping non-uuid messages)', async () => {
|
|
2523
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2524
|
+
deps.state.messages = [
|
|
2525
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
|
|
2526
|
+
{ id: 'm2', role: 'assistant', content: 'boundary', timestamp: 2 }, // No uuid
|
|
2527
|
+
{ id: 'm3', role: 'user', content: 'test', timestamp: 3, userMessageId: 'user-uuid' },
|
|
2528
|
+
{ id: 'm4', role: 'assistant', content: 'boundary2', timestamp: 4 }, // No uuid
|
|
2529
|
+
{ id: 'm5', role: 'assistant', content: 'resp', timestamp: 5, assistantMessageId: 'resp-a' },
|
|
2530
|
+
];
|
|
2531
|
+
|
|
2532
|
+
await controller.rewind('m3');
|
|
2533
|
+
|
|
2534
|
+
expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation');
|
|
2535
|
+
});
|
|
2536
|
+
|
|
2537
|
+
it('should show Notice when message ID not found', async () => {
|
|
2538
|
+
deps.state.messages = [
|
|
2539
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2540
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2541
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2542
|
+
];
|
|
2543
|
+
|
|
2544
|
+
await controller.rewind('nonexistent');
|
|
2545
|
+
|
|
2546
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2547
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2548
|
+
});
|
|
2549
|
+
|
|
2550
|
+
it('should show Notice when streaming', async () => {
|
|
2551
|
+
deps.state.isStreaming = true;
|
|
2552
|
+
deps.state.messages = [
|
|
2553
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2554
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2555
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2556
|
+
];
|
|
2557
|
+
|
|
2558
|
+
await controller.rewind('m2');
|
|
2559
|
+
|
|
2560
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2561
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2562
|
+
});
|
|
2563
|
+
|
|
2564
|
+
it('should show Notice when user message has no userMessageId', async () => {
|
|
2565
|
+
deps.state.messages = [
|
|
2566
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2567
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2 }, // No userMessageId
|
|
2568
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2569
|
+
];
|
|
2570
|
+
|
|
2571
|
+
await controller.rewind('m2');
|
|
2572
|
+
|
|
2573
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2574
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2575
|
+
});
|
|
2576
|
+
|
|
2577
|
+
it('should show Notice when no previous assistant with uuid exists', async () => {
|
|
2578
|
+
deps.state.messages = [
|
|
2579
|
+
{ id: 'm1', role: 'user', content: 'test', timestamp: 1, userMessageId: 'u1' },
|
|
2580
|
+
{ id: 'm2', role: 'assistant', content: '', timestamp: 2, assistantMessageId: 'a1' },
|
|
2581
|
+
];
|
|
2582
|
+
|
|
2583
|
+
await controller.rewind('m1');
|
|
2584
|
+
|
|
2585
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2586
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2587
|
+
});
|
|
2588
|
+
|
|
2589
|
+
it('should show Notice when no response assistant with uuid exists', async () => {
|
|
2590
|
+
deps.state.messages = [
|
|
2591
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2592
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2593
|
+
];
|
|
2594
|
+
|
|
2595
|
+
await controller.rewind('m2');
|
|
2596
|
+
|
|
2597
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2598
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2599
|
+
});
|
|
2600
|
+
|
|
2601
|
+
it('should show i18n Notice on SDK rewind exception', async () => {
|
|
2602
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2603
|
+
deps.state.messages = [
|
|
2604
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2605
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2606
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2607
|
+
];
|
|
2608
|
+
mockAgentService.rewind.mockRejectedValue(new Error('SDK error'));
|
|
2609
|
+
|
|
2610
|
+
await controller.rewind('m2');
|
|
2611
|
+
|
|
2612
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2613
|
+
const msg = mockNotice.mock.calls[0][0] as string;
|
|
2614
|
+
expect(msg).toContain('SDK error');
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
it('should show i18n Notice when canRewind is false', async () => {
|
|
2618
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2619
|
+
deps.state.messages = [
|
|
2620
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2621
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2622
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2623
|
+
];
|
|
2624
|
+
mockAgentService.rewind.mockResolvedValue({ canRewind: false, error: 'No checkpoints' });
|
|
2625
|
+
|
|
2626
|
+
await controller.rewind('m2');
|
|
2627
|
+
|
|
2628
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2629
|
+
const msg = mockNotice.mock.calls[0][0] as string;
|
|
2630
|
+
expect(msg).toContain('No checkpoints');
|
|
2631
|
+
});
|
|
2632
|
+
|
|
2633
|
+
it('should truncateAt, save with resumeAtMessageId, and renderMessages on success', async () => {
|
|
2634
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2635
|
+
deps.state.messages = [
|
|
2636
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
|
|
2637
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' },
|
|
2638
|
+
{ id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' },
|
|
2639
|
+
];
|
|
2640
|
+
|
|
2641
|
+
const truncateSpy = jest.spyOn(deps.state, 'truncateAt');
|
|
2642
|
+
|
|
2643
|
+
await controller.rewind('m2');
|
|
2644
|
+
|
|
2645
|
+
expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation');
|
|
2646
|
+
expect(truncateSpy).toHaveBeenCalledWith('m2');
|
|
2647
|
+
expect(deps.renderer.renderMessages).toHaveBeenCalledWith(
|
|
2648
|
+
expect.any(Array),
|
|
2649
|
+
expect.any(Function)
|
|
2650
|
+
);
|
|
2651
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2652
|
+
'conv-1',
|
|
2653
|
+
expect.objectContaining({ resumeAtMessageId: 'prev-a' })
|
|
2654
|
+
);
|
|
2655
|
+
|
|
2656
|
+
// Should populate input with rewound message content
|
|
2657
|
+
const inputEl = deps.getInputEl();
|
|
2658
|
+
expect(inputEl.value).toBe('test');
|
|
2659
|
+
expect(inputEl.focus).toHaveBeenCalled();
|
|
2660
|
+
|
|
2661
|
+
// Should show success notice with file count
|
|
2662
|
+
const noticeMsg = mockNotice.mock.calls[0][0] as string;
|
|
2663
|
+
expect(noticeMsg).toContain('1');
|
|
2664
|
+
|
|
2665
|
+
truncateSpy.mockRestore();
|
|
2666
|
+
});
|
|
2667
|
+
|
|
2668
|
+
it('should pass conversation-only mode and keep file changes', async () => {
|
|
2669
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2670
|
+
deps.state.messages = [
|
|
2671
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
|
|
2672
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' },
|
|
2673
|
+
{ id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' },
|
|
2674
|
+
];
|
|
2675
|
+
|
|
2676
|
+
await controller.rewind('m2', 'conversation');
|
|
2677
|
+
|
|
2678
|
+
expect(confirm).toHaveBeenCalledWith(
|
|
2679
|
+
deps.plugin.app,
|
|
2680
|
+
'Rewind conversation to this point? File changes will be kept.',
|
|
2681
|
+
'Rewind',
|
|
2682
|
+
);
|
|
2683
|
+
expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'conversation');
|
|
2684
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith(
|
|
2685
|
+
'conv-1',
|
|
2686
|
+
expect.objectContaining({ resumeAtMessageId: 'prev-a' })
|
|
2687
|
+
);
|
|
2688
|
+
const noticeMsg = mockNotice.mock.calls[0][0] as string;
|
|
2689
|
+
expect(noticeMsg).toBe('Rewound conversation; file changes kept');
|
|
2690
|
+
});
|
|
2691
|
+
|
|
2692
|
+
it('should abort when confirmation is declined', async () => {
|
|
2693
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2694
|
+
deps.state.messages = [
|
|
2695
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2696
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2697
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2698
|
+
];
|
|
2699
|
+
(confirm as jest.Mock).mockResolvedValueOnce(false);
|
|
2700
|
+
|
|
2701
|
+
await controller.rewind('m2');
|
|
2702
|
+
|
|
2703
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2704
|
+
expect(mockNotice).not.toHaveBeenCalled();
|
|
2705
|
+
});
|
|
2706
|
+
|
|
2707
|
+
it('should re-check streaming state after confirmation dialog', async () => {
|
|
2708
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2709
|
+
deps.state.messages = [
|
|
2710
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'a1' },
|
|
2711
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'u1' },
|
|
2712
|
+
{ id: 'm3', role: 'assistant', content: '', timestamp: 3, assistantMessageId: 'a2' },
|
|
2713
|
+
];
|
|
2714
|
+
(confirm as jest.Mock).mockImplementationOnce(async () => {
|
|
2715
|
+
deps.state.isStreaming = true;
|
|
2716
|
+
return true;
|
|
2717
|
+
});
|
|
2718
|
+
|
|
2719
|
+
await controller.rewind('m2');
|
|
2720
|
+
|
|
2721
|
+
expect(mockAgentService.rewind).not.toHaveBeenCalled();
|
|
2722
|
+
expect(mockNotice).toHaveBeenCalled();
|
|
2723
|
+
});
|
|
2724
|
+
|
|
2725
|
+
it('should show a warning notice when rewind succeeded but save failed', async () => {
|
|
2726
|
+
deps.state.currentConversationId = 'conv-1';
|
|
2727
|
+
deps.state.messages = [
|
|
2728
|
+
{ id: 'm1', role: 'assistant', content: '', timestamp: 1, assistantMessageId: 'prev-a' },
|
|
2729
|
+
{ id: 'm2', role: 'user', content: 'test', timestamp: 2, userMessageId: 'user-uuid' },
|
|
2730
|
+
{ id: 'm3', role: 'assistant', content: 'resp', timestamp: 3, assistantMessageId: 'resp-a' },
|
|
2731
|
+
];
|
|
2732
|
+
|
|
2733
|
+
(deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('Save failed'));
|
|
2734
|
+
|
|
2735
|
+
await controller.rewind('m2');
|
|
2736
|
+
|
|
2737
|
+
expect(mockAgentService.rewind).toHaveBeenCalledWith('user-uuid', 'prev-a', 'code-and-conversation');
|
|
2738
|
+
const msg = mockNotice.mock.calls[0][0] as string;
|
|
2739
|
+
expect(msg).toContain('Save failed');
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
describe('Inline prompt dismissal', () => {
|
|
2743
|
+
it('dismisses pending inline prompts during createNew()', async () => {
|
|
2744
|
+
const dismissFn = jest.fn();
|
|
2745
|
+
deps = createMockDeps({ dismissPendingInlinePrompts: dismissFn });
|
|
2746
|
+
controller = new ConversationController(deps);
|
|
2747
|
+
|
|
2748
|
+
await controller.createNew();
|
|
2749
|
+
|
|
2750
|
+
expect(dismissFn).toHaveBeenCalled();
|
|
2751
|
+
});
|
|
2752
|
+
|
|
2753
|
+
it('dismisses pending inline prompts during switchTo()', async () => {
|
|
2754
|
+
const dismissFn = jest.fn();
|
|
2755
|
+
deps = createMockDeps({ dismissPendingInlinePrompts: dismissFn });
|
|
2756
|
+
controller = new ConversationController(deps);
|
|
2757
|
+
deps.state.currentConversationId = 'old-conv';
|
|
2758
|
+
|
|
2759
|
+
await controller.switchTo('switched-conv');
|
|
2760
|
+
|
|
2761
|
+
expect(dismissFn).toHaveBeenCalled();
|
|
2762
|
+
});
|
|
2763
|
+
});
|
|
2764
|
+
});
|