@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,3188 @@
|
|
|
1
|
+
import { createMockEl } from '@test/helpers/mockElement';
|
|
2
|
+
import { Notice } from 'obsidian';
|
|
3
|
+
|
|
4
|
+
import { InputController, type InputControllerDeps } from '@/features/chat/controllers/InputController';
|
|
5
|
+
import { ChatState } from '@/features/chat/state/ChatState';
|
|
6
|
+
import { encodeClaudeTurn } from '@/providers/claude/prompt/ClaudeTurnEncoder';
|
|
7
|
+
import { ResumeSessionDropdown } from '@/shared/components/ResumeSessionDropdown';
|
|
8
|
+
|
|
9
|
+
jest.mock('@/shared/components/ResumeSessionDropdown', () => ({
|
|
10
|
+
ResumeSessionDropdown: jest.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
globalThis.requestAnimationFrame = (cb: FrameRequestCallback) => {
|
|
15
|
+
cb(0);
|
|
16
|
+
return 0;
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const mockNotice = Notice as jest.Mock;
|
|
21
|
+
|
|
22
|
+
function createMockInputEl() {
|
|
23
|
+
return {
|
|
24
|
+
value: '',
|
|
25
|
+
focus: jest.fn(),
|
|
26
|
+
} as unknown as HTMLTextAreaElement;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function createMockWelcomeEl() {
|
|
30
|
+
return createMockEl();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function createMockFileContextManager() {
|
|
34
|
+
return {
|
|
35
|
+
startSession: jest.fn(),
|
|
36
|
+
getCurrentNotePath: jest.fn().mockReturnValue(null),
|
|
37
|
+
shouldSendCurrentNote: jest.fn().mockReturnValue(false),
|
|
38
|
+
markCurrentNoteSent: jest.fn(),
|
|
39
|
+
transformContextMentions: jest.fn().mockImplementation((text: string) => text),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createMockImageContextManager() {
|
|
44
|
+
return {
|
|
45
|
+
hasImages: jest.fn().mockReturnValue(false),
|
|
46
|
+
getAttachedImages: jest.fn().mockReturnValue([]),
|
|
47
|
+
clearImages: jest.fn(),
|
|
48
|
+
setImages: jest.fn(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function* createMockStream(chunks: any[]) {
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
yield chunk;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const mockMcpForEncoder = {
|
|
59
|
+
extractMentions: jest.fn().mockReturnValue(new Set<string>()),
|
|
60
|
+
transformMentions: jest.fn().mockImplementation((text: string) => text),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function createMockAgentService() {
|
|
64
|
+
return {
|
|
65
|
+
providerId: 'claude',
|
|
66
|
+
getCapabilities: jest.fn().mockReturnValue({
|
|
67
|
+
providerId: 'claude',
|
|
68
|
+
supportsPersistentRuntime: true,
|
|
69
|
+
supportsNativeHistory: true,
|
|
70
|
+
supportsPlanMode: true,
|
|
71
|
+
supportsRewind: true,
|
|
72
|
+
supportsFork: true,
|
|
73
|
+
supportsProviderCommands: true,
|
|
74
|
+
supportsTurnSteer: false,
|
|
75
|
+
reasoningControl: 'effort',
|
|
76
|
+
}),
|
|
77
|
+
prepareTurn: jest.fn().mockImplementation((request: any) =>
|
|
78
|
+
encodeClaudeTurn(request, mockMcpForEncoder),
|
|
79
|
+
),
|
|
80
|
+
query: jest.fn(),
|
|
81
|
+
steer: jest.fn().mockResolvedValue(true),
|
|
82
|
+
cancel: jest.fn(),
|
|
83
|
+
resetSession: jest.fn(),
|
|
84
|
+
setResumeCheckpoint: jest.fn(),
|
|
85
|
+
setApprovedPlanContent: jest.fn(),
|
|
86
|
+
setCurrentPlanFilePath: jest.fn(),
|
|
87
|
+
getApprovedPlanContent: jest.fn().mockReturnValue(null),
|
|
88
|
+
clearApprovedPlanContent: jest.fn(),
|
|
89
|
+
ensureReady: jest.fn().mockResolvedValue(true),
|
|
90
|
+
getSessionId: jest.fn().mockReturnValue(null),
|
|
91
|
+
getAuxiliaryModel: jest.fn().mockReturnValue(null),
|
|
92
|
+
consumeTurnMetadata: jest.fn().mockReturnValue({}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function createMockInstructionRefineService(overrides: Record<string, jest.Mock> = {}) {
|
|
97
|
+
return {
|
|
98
|
+
refineInstruction: jest.fn().mockResolvedValue({ success: true }),
|
|
99
|
+
resetConversation: jest.fn(),
|
|
100
|
+
continueConversation: jest.fn(),
|
|
101
|
+
cancel: jest.fn(),
|
|
102
|
+
setModelOverride: jest.fn(),
|
|
103
|
+
...overrides,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function createMockInstructionModeManager() {
|
|
108
|
+
return { clear: jest.fn() };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function createMockDeps(overrides: Partial<InputControllerDeps> = {}): InputControllerDeps & { mockAgentService: ReturnType<typeof createMockAgentService> } {
|
|
112
|
+
const state = new ChatState();
|
|
113
|
+
const inputEl = createMockInputEl();
|
|
114
|
+
const queueIndicatorEl = createMockEl();
|
|
115
|
+
queueIndicatorEl.style.display = 'none';
|
|
116
|
+
jest.spyOn(queueIndicatorEl, 'setText');
|
|
117
|
+
state.queueIndicatorEl = queueIndicatorEl as any;
|
|
118
|
+
|
|
119
|
+
const imageContextManager = createMockImageContextManager();
|
|
120
|
+
const mockAgentService = createMockAgentService();
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
plugin: {
|
|
124
|
+
saveSettings: jest.fn(),
|
|
125
|
+
settings: {
|
|
126
|
+
permissionMode: 'yolo',
|
|
127
|
+
enableAutoTitleGeneration: true,
|
|
128
|
+
},
|
|
129
|
+
mcpManager: {
|
|
130
|
+
extractMentions: jest.fn().mockReturnValue(new Set()),
|
|
131
|
+
transformMentions: jest.fn().mockImplementation((text: string) => text),
|
|
132
|
+
},
|
|
133
|
+
renameConversation: jest.fn(),
|
|
134
|
+
updateConversation: jest.fn(),
|
|
135
|
+
getConversationSync: jest.fn().mockReturnValue(null),
|
|
136
|
+
getConversationById: jest.fn().mockResolvedValue(null),
|
|
137
|
+
createConversation: jest.fn().mockResolvedValue({ id: 'conv-1' }),
|
|
138
|
+
} as any,
|
|
139
|
+
state,
|
|
140
|
+
renderer: {
|
|
141
|
+
addMessage: jest.fn().mockReturnValue({
|
|
142
|
+
querySelector: jest.fn().mockReturnValue(createMockEl()),
|
|
143
|
+
}),
|
|
144
|
+
refreshActionButtons: jest.fn(),
|
|
145
|
+
removeMessage: jest.fn(),
|
|
146
|
+
updateLiveUserMessage: jest.fn(),
|
|
147
|
+
} as any,
|
|
148
|
+
streamController: {
|
|
149
|
+
showThinkingIndicator: jest.fn(),
|
|
150
|
+
hideThinkingIndicator: jest.fn(),
|
|
151
|
+
handleStreamChunk: jest.fn(),
|
|
152
|
+
finalizeCurrentTextBlock: jest.fn(),
|
|
153
|
+
finalizeCurrentThinkingBlock: jest.fn(),
|
|
154
|
+
appendText: jest.fn(),
|
|
155
|
+
} as any,
|
|
156
|
+
selectionController: {
|
|
157
|
+
getContext: jest.fn().mockReturnValue(null),
|
|
158
|
+
} as any,
|
|
159
|
+
canvasSelectionController: {
|
|
160
|
+
getContext: jest.fn().mockReturnValue(null),
|
|
161
|
+
} as any,
|
|
162
|
+
conversationController: {
|
|
163
|
+
save: jest.fn(),
|
|
164
|
+
generateFallbackTitle: jest.fn().mockReturnValue('Test Title'),
|
|
165
|
+
updateHistoryDropdown: jest.fn(),
|
|
166
|
+
clearTerminalSubagentsFromMessages: jest.fn(),
|
|
167
|
+
} as any,
|
|
168
|
+
getInputEl: () => inputEl,
|
|
169
|
+
getInputContainerEl: () => createMockEl() as any,
|
|
170
|
+
getWelcomeEl: () => null,
|
|
171
|
+
getMessagesEl: () => createMockEl() as any,
|
|
172
|
+
getFileContextManager: () => ({
|
|
173
|
+
startSession: jest.fn(),
|
|
174
|
+
getCurrentNotePath: jest.fn().mockReturnValue(null),
|
|
175
|
+
shouldSendCurrentNote: jest.fn().mockReturnValue(false),
|
|
176
|
+
markCurrentNoteSent: jest.fn(),
|
|
177
|
+
transformContextMentions: jest.fn().mockImplementation((text: string) => text),
|
|
178
|
+
}) as any,
|
|
179
|
+
getImageContextManager: () => imageContextManager as any,
|
|
180
|
+
getMcpServerSelector: () => null,
|
|
181
|
+
getExternalContextSelector: () => null,
|
|
182
|
+
getInstructionModeManager: () => null,
|
|
183
|
+
getInstructionRefineService: () => null,
|
|
184
|
+
getTitleGenerationService: () => null,
|
|
185
|
+
getStatusPanel: () => null,
|
|
186
|
+
generateId: () => `msg-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`,
|
|
187
|
+
resetInputHeight: jest.fn(),
|
|
188
|
+
getAgentService: () => mockAgentService as any,
|
|
189
|
+
getSubagentManager: () => ({ resetSpawnedCount: jest.fn(), resetStreamingState: jest.fn() }) as any,
|
|
190
|
+
mockAgentService,
|
|
191
|
+
...overrides,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Composite helper for tests that need a complete "sendable" deps setup.
|
|
197
|
+
* Creates welcomeEl + fileContextManager and sets conversationId by default,
|
|
198
|
+
* eliminating the repeated boilerplate in send-path tests.
|
|
199
|
+
*/
|
|
200
|
+
function createSendableDeps(
|
|
201
|
+
overrides: Partial<InputControllerDeps> = {},
|
|
202
|
+
conversationId: string | null = 'conv-1',
|
|
203
|
+
): InputControllerDeps & { mockAgentService: ReturnType<typeof createMockAgentService> } {
|
|
204
|
+
const welcomeEl = createMockWelcomeEl();
|
|
205
|
+
const fileContextManager = createMockFileContextManager();
|
|
206
|
+
const result = createMockDeps({
|
|
207
|
+
getWelcomeEl: () => welcomeEl,
|
|
208
|
+
getFileContextManager: () => fileContextManager as any,
|
|
209
|
+
...overrides,
|
|
210
|
+
});
|
|
211
|
+
if (conversationId !== null) {
|
|
212
|
+
result.state.currentConversationId = conversationId;
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
describe('InputController - Message Queue', () => {
|
|
218
|
+
let controller: InputController;
|
|
219
|
+
let deps: InputControllerDeps;
|
|
220
|
+
let inputEl: ReturnType<typeof createMockInputEl>;
|
|
221
|
+
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
jest.clearAllMocks();
|
|
224
|
+
deps = createMockDeps();
|
|
225
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
226
|
+
controller = new InputController(deps);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe('Queuing messages while streaming', () => {
|
|
230
|
+
it('should queue message when isStreaming is true', async () => {
|
|
231
|
+
deps.state.isStreaming = true;
|
|
232
|
+
inputEl.value = 'queued message';
|
|
233
|
+
|
|
234
|
+
await controller.sendMessage();
|
|
235
|
+
|
|
236
|
+
expect(deps.state.queuedMessage).toMatchObject({
|
|
237
|
+
content: 'queued message',
|
|
238
|
+
images: undefined,
|
|
239
|
+
editorContext: null,
|
|
240
|
+
browserContext: null,
|
|
241
|
+
canvasContext: null,
|
|
242
|
+
});
|
|
243
|
+
expect(deps.state.queuedMessage?.turnRequest).toMatchObject({
|
|
244
|
+
text: 'queued message',
|
|
245
|
+
editorSelection: null,
|
|
246
|
+
browserSelection: null,
|
|
247
|
+
canvasSelection: null,
|
|
248
|
+
});
|
|
249
|
+
expect(inputEl.value).toBe('');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should queue message with images when streaming', async () => {
|
|
253
|
+
deps.state.isStreaming = true;
|
|
254
|
+
inputEl.value = 'queued with images';
|
|
255
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
256
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
257
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(true);
|
|
258
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages);
|
|
259
|
+
|
|
260
|
+
await controller.sendMessage();
|
|
261
|
+
|
|
262
|
+
expect(deps.state.queuedMessage).toMatchObject({
|
|
263
|
+
content: 'queued with images',
|
|
264
|
+
images: mockImages,
|
|
265
|
+
editorContext: null,
|
|
266
|
+
browserContext: null,
|
|
267
|
+
canvasContext: null,
|
|
268
|
+
});
|
|
269
|
+
expect(deps.state.queuedMessage?.turnRequest).toMatchObject({
|
|
270
|
+
text: 'queued with images',
|
|
271
|
+
images: mockImages,
|
|
272
|
+
});
|
|
273
|
+
expect(imageContextManager.clearImages).toHaveBeenCalled();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
it('should append new message to existing queued message', async () => {
|
|
277
|
+
deps.state.isStreaming = true;
|
|
278
|
+
inputEl.value = 'first message';
|
|
279
|
+
await controller.sendMessage();
|
|
280
|
+
|
|
281
|
+
inputEl.value = 'second message';
|
|
282
|
+
await controller.sendMessage();
|
|
283
|
+
|
|
284
|
+
expect(deps.state.queuedMessage!.content).toBe('first message\n\nsecond message');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should merge images when appending to queue', async () => {
|
|
288
|
+
deps.state.isStreaming = true;
|
|
289
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
290
|
+
|
|
291
|
+
inputEl.value = 'first';
|
|
292
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(true);
|
|
293
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1' }]);
|
|
294
|
+
await controller.sendMessage();
|
|
295
|
+
|
|
296
|
+
inputEl.value = 'second';
|
|
297
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img2' }]);
|
|
298
|
+
await controller.sendMessage();
|
|
299
|
+
|
|
300
|
+
expect(deps.state.queuedMessage!.images).toHaveLength(2);
|
|
301
|
+
expect(deps.state.queuedMessage!.images![0].id).toBe('img1');
|
|
302
|
+
expect(deps.state.queuedMessage!.images![1].id).toBe('img2');
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it('should not queue empty message', async () => {
|
|
306
|
+
deps.state.isStreaming = true;
|
|
307
|
+
inputEl.value = '';
|
|
308
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
309
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(false);
|
|
310
|
+
|
|
311
|
+
await controller.sendMessage();
|
|
312
|
+
|
|
313
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('Queued message processing', () => {
|
|
318
|
+
it('should send queued message in non-plan mode', async () => {
|
|
319
|
+
jest.useFakeTimers();
|
|
320
|
+
try {
|
|
321
|
+
deps.plugin.settings.permissionMode = 'normal';
|
|
322
|
+
deps.state.queuedMessage = {
|
|
323
|
+
content: 'queued plan',
|
|
324
|
+
images: undefined,
|
|
325
|
+
editorContext: null,
|
|
326
|
+
canvasContext: null,
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined);
|
|
330
|
+
|
|
331
|
+
(controller as any).processQueuedMessage();
|
|
332
|
+
jest.runAllTimers();
|
|
333
|
+
await Promise.resolve();
|
|
334
|
+
|
|
335
|
+
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
336
|
+
content: 'queued plan',
|
|
337
|
+
turnRequestOverride: expect.objectContaining({
|
|
338
|
+
text: 'queued plan',
|
|
339
|
+
editorSelection: null,
|
|
340
|
+
canvasSelection: null,
|
|
341
|
+
}),
|
|
342
|
+
}));
|
|
343
|
+
sendSpy.mockRestore();
|
|
344
|
+
} finally {
|
|
345
|
+
jest.useRealTimers();
|
|
346
|
+
}
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('Queue indicator UI', () => {
|
|
351
|
+
it('should show queue indicator when message is queued', () => {
|
|
352
|
+
deps.state.queuedMessage = { content: 'test message', images: undefined, editorContext: null, canvasContext: null };
|
|
353
|
+
|
|
354
|
+
controller.updateQueueIndicator();
|
|
355
|
+
|
|
356
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
357
|
+
expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent).toBe('⌙ Queued: test message');
|
|
358
|
+
expect(queueIndicatorEl.style.display).toBe('flex');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should hide queue indicator when no message is queued', () => {
|
|
362
|
+
deps.state.queuedMessage = null;
|
|
363
|
+
|
|
364
|
+
controller.updateQueueIndicator();
|
|
365
|
+
|
|
366
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
367
|
+
expect(queueIndicatorEl.style.display).toBe('none');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should withdraw queued message to the composer for editing', () => {
|
|
371
|
+
const mockImages = [{ id: 'img1', name: 'queued.png' }];
|
|
372
|
+
const draftImages = [{ id: 'img2', name: 'draft.png' }];
|
|
373
|
+
deps.state.queuedMessage = {
|
|
374
|
+
content: 'queued content',
|
|
375
|
+
images: mockImages as any,
|
|
376
|
+
editorContext: null,
|
|
377
|
+
canvasContext: null,
|
|
378
|
+
};
|
|
379
|
+
inputEl.value = 'draft content';
|
|
380
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
381
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(draftImages);
|
|
382
|
+
|
|
383
|
+
controller.updateQueueIndicator();
|
|
384
|
+
|
|
385
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
386
|
+
const editButton = queueIndicatorEl
|
|
387
|
+
.querySelectorAll('.claudian-queue-indicator-icon-action')
|
|
388
|
+
.find((button: any) => button.getAttribute('aria-label') === 'Edit queued message');
|
|
389
|
+
editButton?.click();
|
|
390
|
+
|
|
391
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
392
|
+
expect(inputEl.value).toBe('queued content\n\ndraft content');
|
|
393
|
+
expect(imageContextManager.setImages).toHaveBeenCalledWith([...mockImages, ...draftImages]);
|
|
394
|
+
expect(deps.resetInputHeight).toHaveBeenCalled();
|
|
395
|
+
expect(inputEl.focus).toHaveBeenCalled();
|
|
396
|
+
expect(queueIndicatorEl.style.display).toBe('none');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
it('should discard queued message without changing the composer', () => {
|
|
400
|
+
deps.state.queuedMessage = {
|
|
401
|
+
content: 'queued content',
|
|
402
|
+
images: undefined,
|
|
403
|
+
editorContext: null,
|
|
404
|
+
canvasContext: null,
|
|
405
|
+
};
|
|
406
|
+
inputEl.value = 'draft content';
|
|
407
|
+
|
|
408
|
+
controller.updateQueueIndicator();
|
|
409
|
+
|
|
410
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
411
|
+
const discardButton = queueIndicatorEl
|
|
412
|
+
.querySelectorAll('.claudian-queue-indicator-icon-action')
|
|
413
|
+
.find((button: any) => button.getAttribute('aria-label') === 'Discard queued message');
|
|
414
|
+
discardButton?.click();
|
|
415
|
+
|
|
416
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
417
|
+
expect(inputEl.value).toBe('draft content');
|
|
418
|
+
expect(queueIndicatorEl.style.display).toBe('none');
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('should truncate long message preview in indicator', () => {
|
|
422
|
+
const longMessage = 'a'.repeat(100);
|
|
423
|
+
deps.state.queuedMessage = { content: longMessage, images: undefined, editorContext: null, canvasContext: null };
|
|
424
|
+
|
|
425
|
+
controller.updateQueueIndicator();
|
|
426
|
+
|
|
427
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
428
|
+
const text = queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent as string;
|
|
429
|
+
expect(text).toContain('...');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('should include [images] when queue message has images', () => {
|
|
433
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
434
|
+
deps.state.queuedMessage = { content: 'queued content', images: mockImages as any, editorContext: null, canvasContext: null };
|
|
435
|
+
|
|
436
|
+
controller.updateQueueIndicator();
|
|
437
|
+
|
|
438
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
439
|
+
const text = queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent as string;
|
|
440
|
+
expect(text).toContain('queued content');
|
|
441
|
+
expect(text).toContain('[images]');
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('should show [images] when queue message has only images', () => {
|
|
445
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
446
|
+
deps.state.queuedMessage = { content: '', images: mockImages as any, editorContext: null, canvasContext: null };
|
|
447
|
+
|
|
448
|
+
controller.updateQueueIndicator();
|
|
449
|
+
|
|
450
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
451
|
+
expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent).toBe('⌙ Queued: [images]');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should show Codex steer action when queued message can be steered', () => {
|
|
455
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
456
|
+
mockAgentService.providerId = 'codex';
|
|
457
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
458
|
+
providerId: 'codex',
|
|
459
|
+
supportsPersistentRuntime: true,
|
|
460
|
+
supportsNativeHistory: true,
|
|
461
|
+
supportsPlanMode: true,
|
|
462
|
+
supportsRewind: false,
|
|
463
|
+
supportsFork: true,
|
|
464
|
+
supportsProviderCommands: false,
|
|
465
|
+
supportsTurnSteer: true,
|
|
466
|
+
reasoningControl: 'effort',
|
|
467
|
+
});
|
|
468
|
+
deps.state.isStreaming = true;
|
|
469
|
+
deps.state.queuedMessage = { content: 'queued content', images: undefined, editorContext: null, canvasContext: null };
|
|
470
|
+
|
|
471
|
+
controller.updateQueueIndicator();
|
|
472
|
+
|
|
473
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
474
|
+
expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.textContent).toBe('Steer Now');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
it('should steer the queued Codex message when the action is clicked', async () => {
|
|
478
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
479
|
+
mockAgentService.providerId = 'codex';
|
|
480
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
481
|
+
providerId: 'codex',
|
|
482
|
+
supportsPersistentRuntime: true,
|
|
483
|
+
supportsNativeHistory: true,
|
|
484
|
+
supportsPlanMode: true,
|
|
485
|
+
supportsRewind: false,
|
|
486
|
+
supportsFork: true,
|
|
487
|
+
supportsProviderCommands: false,
|
|
488
|
+
supportsTurnSteer: true,
|
|
489
|
+
reasoningControl: 'effort',
|
|
490
|
+
});
|
|
491
|
+
mockAgentService.prepareTurn = jest.fn().mockReturnValue({
|
|
492
|
+
request: { text: 'queued follow-up' },
|
|
493
|
+
persistedContent: 'queued follow-up',
|
|
494
|
+
prompt: 'queued follow-up',
|
|
495
|
+
isCompact: false,
|
|
496
|
+
mcpMentions: new Set(),
|
|
497
|
+
});
|
|
498
|
+
mockAgentService.steer = jest.fn().mockResolvedValue(true);
|
|
499
|
+
|
|
500
|
+
deps.state.isStreaming = true;
|
|
501
|
+
deps.state.messages = [
|
|
502
|
+
{
|
|
503
|
+
id: 'user-1',
|
|
504
|
+
role: 'user',
|
|
505
|
+
content: 'original',
|
|
506
|
+
displayContent: 'original',
|
|
507
|
+
timestamp: Date.now(),
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
id: 'assistant-1',
|
|
511
|
+
role: 'assistant',
|
|
512
|
+
content: '',
|
|
513
|
+
timestamp: Date.now(),
|
|
514
|
+
},
|
|
515
|
+
];
|
|
516
|
+
deps.state.queuedMessage = {
|
|
517
|
+
content: 'queued follow-up',
|
|
518
|
+
images: undefined,
|
|
519
|
+
editorContext: null,
|
|
520
|
+
browserContext: null,
|
|
521
|
+
canvasContext: null,
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
controller.updateQueueIndicator();
|
|
525
|
+
|
|
526
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
527
|
+
queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click();
|
|
528
|
+
await Promise.resolve();
|
|
529
|
+
await Promise.resolve();
|
|
530
|
+
|
|
531
|
+
expect(mockAgentService.prepareTurn).toHaveBeenCalledWith(expect.objectContaining({
|
|
532
|
+
text: 'queued follow-up',
|
|
533
|
+
}));
|
|
534
|
+
expect(mockAgentService.steer).toHaveBeenCalled();
|
|
535
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
536
|
+
expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-text')?.textContent)
|
|
537
|
+
.toBe('⌙ Steering: queued follow-up');
|
|
538
|
+
expect(queueIndicatorEl.querySelector('.claudian-queue-indicator-action')).toBeNull();
|
|
539
|
+
expect(queueIndicatorEl.style.display).toBe('flex');
|
|
540
|
+
expect(deps.state.messages).toHaveLength(2);
|
|
541
|
+
expect(deps.state.messages[0]).toMatchObject({
|
|
542
|
+
id: 'user-1',
|
|
543
|
+
role: 'user',
|
|
544
|
+
content: 'original',
|
|
545
|
+
displayContent: 'original',
|
|
546
|
+
});
|
|
547
|
+
expect((deps.renderer as any).addMessage).not.toHaveBeenCalled();
|
|
548
|
+
expect((deps.renderer as any).updateLiveUserMessage).not.toHaveBeenCalled();
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
it('should restore the queued message when steering fails', async () => {
|
|
552
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
553
|
+
mockAgentService.providerId = 'codex';
|
|
554
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
555
|
+
providerId: 'codex',
|
|
556
|
+
supportsPersistentRuntime: true,
|
|
557
|
+
supportsNativeHistory: true,
|
|
558
|
+
supportsPlanMode: true,
|
|
559
|
+
supportsRewind: false,
|
|
560
|
+
supportsFork: true,
|
|
561
|
+
supportsProviderCommands: false,
|
|
562
|
+
supportsTurnSteer: true,
|
|
563
|
+
reasoningControl: 'effort',
|
|
564
|
+
});
|
|
565
|
+
mockAgentService.prepareTurn = jest.fn().mockReturnValue({
|
|
566
|
+
request: { text: 'queued follow-up' },
|
|
567
|
+
persistedContent: 'queued follow-up',
|
|
568
|
+
prompt: 'queued follow-up',
|
|
569
|
+
isCompact: false,
|
|
570
|
+
mcpMentions: new Set(),
|
|
571
|
+
});
|
|
572
|
+
mockAgentService.steer = jest.fn().mockRejectedValue(new Error('boom'));
|
|
573
|
+
|
|
574
|
+
deps.state.isStreaming = true;
|
|
575
|
+
deps.state.queuedMessage = {
|
|
576
|
+
content: 'queued follow-up',
|
|
577
|
+
images: undefined,
|
|
578
|
+
editorContext: null,
|
|
579
|
+
browserContext: null,
|
|
580
|
+
canvasContext: null,
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
controller.updateQueueIndicator();
|
|
584
|
+
|
|
585
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
586
|
+
queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click();
|
|
587
|
+
await Promise.resolve();
|
|
588
|
+
await Promise.resolve();
|
|
589
|
+
|
|
590
|
+
expect(deps.state.queuedMessage).toEqual({
|
|
591
|
+
content: 'queued follow-up',
|
|
592
|
+
images: undefined,
|
|
593
|
+
editorContext: null,
|
|
594
|
+
browserContext: null,
|
|
595
|
+
canvasContext: null,
|
|
596
|
+
});
|
|
597
|
+
expect(mockNotice).toHaveBeenCalledWith(
|
|
598
|
+
'Failed to steer the queued Codex message. It is still available.',
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it('should not mark the current note as sent when steering is rejected', async () => {
|
|
603
|
+
const fileContextManager = createMockFileContextManager();
|
|
604
|
+
(fileContextManager.getCurrentNotePath as jest.Mock).mockReturnValue('notes/session.md');
|
|
605
|
+
(fileContextManager.shouldSendCurrentNote as jest.Mock).mockReturnValue(true);
|
|
606
|
+
deps = createSendableDeps({
|
|
607
|
+
getFileContextManager: () => fileContextManager as any,
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
611
|
+
mockAgentService.providerId = 'codex';
|
|
612
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
613
|
+
providerId: 'codex',
|
|
614
|
+
supportsPersistentRuntime: true,
|
|
615
|
+
supportsNativeHistory: true,
|
|
616
|
+
supportsPlanMode: true,
|
|
617
|
+
supportsRewind: false,
|
|
618
|
+
supportsFork: true,
|
|
619
|
+
supportsProviderCommands: false,
|
|
620
|
+
supportsTurnSteer: true,
|
|
621
|
+
reasoningControl: 'effort',
|
|
622
|
+
});
|
|
623
|
+
mockAgentService.prepareTurn = jest.fn().mockReturnValue({
|
|
624
|
+
request: { text: 'queued follow-up', currentNotePath: 'notes/session.md' },
|
|
625
|
+
persistedContent: 'queued follow-up',
|
|
626
|
+
prompt: 'queued follow-up',
|
|
627
|
+
isCompact: false,
|
|
628
|
+
mcpMentions: new Set(),
|
|
629
|
+
});
|
|
630
|
+
mockAgentService.steer = jest.fn().mockResolvedValue(false);
|
|
631
|
+
|
|
632
|
+
deps.state.isStreaming = true;
|
|
633
|
+
deps.state.queuedMessage = {
|
|
634
|
+
content: 'queued follow-up',
|
|
635
|
+
images: undefined,
|
|
636
|
+
editorContext: null,
|
|
637
|
+
browserContext: null,
|
|
638
|
+
canvasContext: null,
|
|
639
|
+
};
|
|
640
|
+
controller = new InputController(deps);
|
|
641
|
+
controller.updateQueueIndicator();
|
|
642
|
+
|
|
643
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
644
|
+
queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click();
|
|
645
|
+
await Promise.resolve();
|
|
646
|
+
await Promise.resolve();
|
|
647
|
+
|
|
648
|
+
expect(fileContextManager.markCurrentNoteSent).not.toHaveBeenCalled();
|
|
649
|
+
expect(deps.state.queuedMessage).toEqual({
|
|
650
|
+
content: 'queued follow-up',
|
|
651
|
+
images: undefined,
|
|
652
|
+
editorContext: null,
|
|
653
|
+
browserContext: null,
|
|
654
|
+
canvasContext: null,
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
it('should route subsequent live chunks to a new assistant bubble after steering', async () => {
|
|
659
|
+
deps = createSendableDeps();
|
|
660
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
661
|
+
mockAgentService.providerId = 'codex';
|
|
662
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
663
|
+
providerId: 'codex',
|
|
664
|
+
supportsPersistentRuntime: true,
|
|
665
|
+
supportsNativeHistory: true,
|
|
666
|
+
supportsPlanMode: true,
|
|
667
|
+
supportsRewind: false,
|
|
668
|
+
supportsFork: true,
|
|
669
|
+
supportsProviderCommands: false,
|
|
670
|
+
supportsTurnSteer: true,
|
|
671
|
+
reasoningControl: 'effort',
|
|
672
|
+
});
|
|
673
|
+
mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({
|
|
674
|
+
request: {
|
|
675
|
+
...request,
|
|
676
|
+
currentNotePath: 'notes/steer.md',
|
|
677
|
+
},
|
|
678
|
+
persistedContent: 'persisted steer prompt',
|
|
679
|
+
prompt: request.text,
|
|
680
|
+
isCompact: false,
|
|
681
|
+
mcpMentions: new Set(),
|
|
682
|
+
}));
|
|
683
|
+
mockAgentService.steer = jest.fn().mockResolvedValue(true);
|
|
684
|
+
|
|
685
|
+
let releaseSecondChunk: () => void = () => {
|
|
686
|
+
throw new Error('Second chunk gate was not initialized');
|
|
687
|
+
};
|
|
688
|
+
const secondChunkGate = new Promise<void>((resolve) => {
|
|
689
|
+
releaseSecondChunk = () => resolve();
|
|
690
|
+
});
|
|
691
|
+
const firstChunkHandled = new Promise<void>((resolve) => {
|
|
692
|
+
let handledCount = 0;
|
|
693
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async () => {
|
|
694
|
+
handledCount += 1;
|
|
695
|
+
if (handledCount === 1) {
|
|
696
|
+
resolve();
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
mockAgentService.query = jest.fn().mockImplementation(() => {
|
|
702
|
+
return (async function* () {
|
|
703
|
+
yield { type: 'user_message_start', content: 'first prompt', itemId: 'user-1' };
|
|
704
|
+
yield { type: 'assistant_message_start', itemId: 'assistant-1' };
|
|
705
|
+
yield { type: 'text', content: 'partial' };
|
|
706
|
+
await secondChunkGate;
|
|
707
|
+
yield { type: 'user_message_start', content: 'steer prompt', itemId: 'user-2' };
|
|
708
|
+
yield { type: 'thinking', content: 'thinking after steer' };
|
|
709
|
+
yield { type: 'assistant_message_start', itemId: 'assistant-2' };
|
|
710
|
+
yield { type: 'text', content: 'after steer' };
|
|
711
|
+
yield { type: 'done' };
|
|
712
|
+
})();
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
716
|
+
inputEl.value = 'first prompt';
|
|
717
|
+
controller = new InputController(deps);
|
|
718
|
+
|
|
719
|
+
const sendPromise = controller.sendMessage();
|
|
720
|
+
await firstChunkHandled;
|
|
721
|
+
|
|
722
|
+
deps.state.queuedMessage = {
|
|
723
|
+
content: 'steer prompt',
|
|
724
|
+
images: undefined,
|
|
725
|
+
editorContext: null,
|
|
726
|
+
browserContext: null,
|
|
727
|
+
canvasContext: null,
|
|
728
|
+
};
|
|
729
|
+
controller.updateQueueIndicator();
|
|
730
|
+
|
|
731
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
732
|
+
queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click();
|
|
733
|
+
await Promise.resolve();
|
|
734
|
+
await Promise.resolve();
|
|
735
|
+
|
|
736
|
+
expect(deps.state.messages).toHaveLength(2);
|
|
737
|
+
|
|
738
|
+
const firstAssistant = deps.state.messages[1];
|
|
739
|
+
|
|
740
|
+
releaseSecondChunk();
|
|
741
|
+
await sendPromise;
|
|
742
|
+
|
|
743
|
+
expect(deps.state.messages).toHaveLength(4);
|
|
744
|
+
const steerUser = deps.state.messages[2];
|
|
745
|
+
const secondAssistant = deps.state.messages[3];
|
|
746
|
+
expect(steerUser).toMatchObject({
|
|
747
|
+
role: 'user',
|
|
748
|
+
content: 'persisted steer prompt',
|
|
749
|
+
displayContent: 'steer prompt',
|
|
750
|
+
currentNote: 'notes/steer.md',
|
|
751
|
+
});
|
|
752
|
+
expect(secondAssistant).toMatchObject({
|
|
753
|
+
role: 'assistant',
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith(
|
|
757
|
+
1,
|
|
758
|
+
{ type: 'text', content: 'partial' },
|
|
759
|
+
firstAssistant,
|
|
760
|
+
);
|
|
761
|
+
expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith(
|
|
762
|
+
2,
|
|
763
|
+
{ type: 'thinking', content: 'thinking after steer' },
|
|
764
|
+
secondAssistant,
|
|
765
|
+
);
|
|
766
|
+
expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith(
|
|
767
|
+
3,
|
|
768
|
+
{ type: 'text', content: 'after steer' },
|
|
769
|
+
secondAssistant,
|
|
770
|
+
);
|
|
771
|
+
expect(deps.streamController.finalizeCurrentThinkingBlock).toHaveBeenCalledWith(firstAssistant);
|
|
772
|
+
expect(deps.streamController.finalizeCurrentTextBlock).toHaveBeenCalledWith(firstAssistant);
|
|
773
|
+
expect(queueIndicatorEl.style.display).toBe('none');
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it('should discard the empty assistant placeholder when steer lands before assistant output', async () => {
|
|
777
|
+
deps = createSendableDeps();
|
|
778
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
779
|
+
mockAgentService.providerId = 'codex';
|
|
780
|
+
mockAgentService.getCapabilities = jest.fn().mockReturnValue({
|
|
781
|
+
providerId: 'codex',
|
|
782
|
+
supportsPersistentRuntime: true,
|
|
783
|
+
supportsNativeHistory: true,
|
|
784
|
+
supportsPlanMode: true,
|
|
785
|
+
supportsRewind: false,
|
|
786
|
+
supportsFork: true,
|
|
787
|
+
supportsProviderCommands: false,
|
|
788
|
+
supportsTurnSteer: true,
|
|
789
|
+
reasoningControl: 'effort',
|
|
790
|
+
});
|
|
791
|
+
mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({
|
|
792
|
+
request: {
|
|
793
|
+
...request,
|
|
794
|
+
currentNotePath: 'notes/steer.md',
|
|
795
|
+
},
|
|
796
|
+
persistedContent: request.text === 'steer prompt'
|
|
797
|
+
? 'persisted steer prompt'
|
|
798
|
+
: request.text,
|
|
799
|
+
prompt: request.text,
|
|
800
|
+
isCompact: false,
|
|
801
|
+
mcpMentions: new Set(),
|
|
802
|
+
}));
|
|
803
|
+
mockAgentService.steer = jest.fn().mockResolvedValue(true);
|
|
804
|
+
|
|
805
|
+
let releaseSecondChunk: () => void = () => {
|
|
806
|
+
throw new Error('Second chunk gate was not initialized');
|
|
807
|
+
};
|
|
808
|
+
const secondChunkGate = new Promise<void>((resolve) => {
|
|
809
|
+
releaseSecondChunk = () => resolve();
|
|
810
|
+
});
|
|
811
|
+
mockAgentService.query = jest.fn().mockImplementation(() => {
|
|
812
|
+
return (async function* () {
|
|
813
|
+
yield { type: 'user_message_start', content: 'first prompt', itemId: 'user-1' };
|
|
814
|
+
await secondChunkGate;
|
|
815
|
+
yield { type: 'user_message_start', content: 'steer prompt', itemId: 'user-2' };
|
|
816
|
+
yield { type: 'assistant_message_start', itemId: 'assistant-2' };
|
|
817
|
+
yield { type: 'text', content: 'after steer' };
|
|
818
|
+
yield { type: 'done' };
|
|
819
|
+
})();
|
|
820
|
+
});
|
|
821
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
822
|
+
if (chunk.type === 'text') {
|
|
823
|
+
msg.content += chunk.content;
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
828
|
+
inputEl.value = 'first prompt';
|
|
829
|
+
controller = new InputController(deps);
|
|
830
|
+
|
|
831
|
+
const sendPromise = controller.sendMessage();
|
|
832
|
+
await Promise.resolve();
|
|
833
|
+
await Promise.resolve();
|
|
834
|
+
|
|
835
|
+
expect(deps.state.messages).toHaveLength(2);
|
|
836
|
+
const discardedAssistant = deps.state.messages[1];
|
|
837
|
+
|
|
838
|
+
deps.state.queuedMessage = {
|
|
839
|
+
content: 'steer prompt',
|
|
840
|
+
images: undefined,
|
|
841
|
+
editorContext: null,
|
|
842
|
+
browserContext: null,
|
|
843
|
+
canvasContext: null,
|
|
844
|
+
};
|
|
845
|
+
controller.updateQueueIndicator();
|
|
846
|
+
|
|
847
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
848
|
+
queueIndicatorEl.querySelector('.claudian-queue-indicator-action')?.click();
|
|
849
|
+
await Promise.resolve();
|
|
850
|
+
await Promise.resolve();
|
|
851
|
+
|
|
852
|
+
releaseSecondChunk();
|
|
853
|
+
await sendPromise;
|
|
854
|
+
|
|
855
|
+
expect((deps.renderer as any).removeMessage).toHaveBeenCalledWith(discardedAssistant.id);
|
|
856
|
+
expect(deps.state.messages).toHaveLength(3);
|
|
857
|
+
expect(deps.state.messages.map((message) => message.role)).toEqual(['user', 'user', 'assistant']);
|
|
858
|
+
expect(deps.state.messages[1]).toMatchObject({
|
|
859
|
+
content: 'persisted steer prompt',
|
|
860
|
+
displayContent: 'steer prompt',
|
|
861
|
+
currentNote: 'notes/steer.md',
|
|
862
|
+
});
|
|
863
|
+
expect(deps.state.messages[2]).toMatchObject({
|
|
864
|
+
role: 'assistant',
|
|
865
|
+
content: 'after steer',
|
|
866
|
+
});
|
|
867
|
+
expect(deps.streamController.handleStreamChunk).toHaveBeenCalledTimes(2);
|
|
868
|
+
expect(deps.streamController.handleStreamChunk).toHaveBeenNthCalledWith(
|
|
869
|
+
1,
|
|
870
|
+
{ type: 'text', content: 'after steer' },
|
|
871
|
+
deps.state.messages[2],
|
|
872
|
+
);
|
|
873
|
+
});
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
describe('Clearing queued message', () => {
|
|
877
|
+
it('should clear queued message and update indicator', () => {
|
|
878
|
+
deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null };
|
|
879
|
+
|
|
880
|
+
controller.clearQueuedMessage();
|
|
881
|
+
|
|
882
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
883
|
+
const queueIndicatorEl = deps.state.queueIndicatorEl as any;
|
|
884
|
+
expect(queueIndicatorEl.style.display).toBe('none');
|
|
885
|
+
});
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
describe('Cancel streaming', () => {
|
|
889
|
+
it('should clear queue on cancel', () => {
|
|
890
|
+
deps.state.queuedMessage = { content: 'test', images: undefined, editorContext: null, canvasContext: null };
|
|
891
|
+
deps.state.isStreaming = true;
|
|
892
|
+
|
|
893
|
+
controller.cancelStreaming();
|
|
894
|
+
|
|
895
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
896
|
+
expect(deps.state.cancelRequested).toBe(true);
|
|
897
|
+
expect((deps as any).mockAgentService.cancel).toHaveBeenCalled();
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
it('should restore a pending steer message to input on cancel', () => {
|
|
901
|
+
deps.state.isStreaming = true;
|
|
902
|
+
(controller as any).pendingSteerMessage = {
|
|
903
|
+
content: 'steered follow-up',
|
|
904
|
+
images: undefined,
|
|
905
|
+
editorContext: null,
|
|
906
|
+
browserContext: null,
|
|
907
|
+
canvasContext: null,
|
|
908
|
+
};
|
|
909
|
+
(controller as any).steerInFlight = true;
|
|
910
|
+
|
|
911
|
+
controller.cancelStreaming();
|
|
912
|
+
|
|
913
|
+
expect(inputEl.value).toBe('steered follow-up');
|
|
914
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
915
|
+
expect((deps.state.queueIndicatorEl as any).style.display).toBe('none');
|
|
916
|
+
expect((deps as any).mockAgentService.cancel).toHaveBeenCalled();
|
|
917
|
+
});
|
|
918
|
+
|
|
919
|
+
it('should not cancel if not streaming', () => {
|
|
920
|
+
deps.state.isStreaming = false;
|
|
921
|
+
|
|
922
|
+
controller.cancelStreaming();
|
|
923
|
+
|
|
924
|
+
expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled();
|
|
925
|
+
});
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
describe('Sending messages', () => {
|
|
929
|
+
it('should send message, hide welcome, and save conversation', async () => {
|
|
930
|
+
const welcomeEl = createMockWelcomeEl();
|
|
931
|
+
const fileContextManager = createMockFileContextManager();
|
|
932
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
933
|
+
|
|
934
|
+
deps.getWelcomeEl = () => welcomeEl;
|
|
935
|
+
deps.getFileContextManager = () => fileContextManager as any;
|
|
936
|
+
deps.state.currentConversationId = 'conv-1';
|
|
937
|
+
(deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }]));
|
|
938
|
+
|
|
939
|
+
inputEl.value = 'See ![[image.png]]';
|
|
940
|
+
|
|
941
|
+
await controller.sendMessage();
|
|
942
|
+
|
|
943
|
+
expect(welcomeEl.style.display).toBe('none');
|
|
944
|
+
expect(fileContextManager.startSession).toHaveBeenCalled();
|
|
945
|
+
expect(deps.renderer.addMessage).toHaveBeenCalledTimes(2);
|
|
946
|
+
expect(deps.state.messages).toHaveLength(2);
|
|
947
|
+
// Without XML context tags, content equals displayContent (no <query> wrapper)
|
|
948
|
+
expect(deps.state.messages[0].content).toBe('See ![[image.png]]');
|
|
949
|
+
expect(deps.state.messages[0].displayContent).toBe('See ![[image.png]]');
|
|
950
|
+
expect(deps.state.messages[0].images).toBeUndefined();
|
|
951
|
+
expect(imageContextManager.clearImages).toHaveBeenCalled();
|
|
952
|
+
expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title');
|
|
953
|
+
// No user_message_sent in stream → save without clearing resumeAtMessageId
|
|
954
|
+
expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined);
|
|
955
|
+
expect((deps as any).mockAgentService.query).toHaveBeenCalled();
|
|
956
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
it('should persist replay-safe user content instead of transport-only prompt', async () => {
|
|
960
|
+
deps = createSendableDeps();
|
|
961
|
+
(deps as any).mockAgentService.prepareTurn = jest.fn().mockReturnValue({
|
|
962
|
+
request: { text: '@server-a hello' },
|
|
963
|
+
persistedContent: '@server-a hello',
|
|
964
|
+
prompt: '@server-a MCP hello',
|
|
965
|
+
isCompact: false,
|
|
966
|
+
mcpMentions: new Set(['server-a']),
|
|
967
|
+
});
|
|
968
|
+
(deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }]));
|
|
969
|
+
|
|
970
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
971
|
+
inputEl.value = '@server-a hello';
|
|
972
|
+
controller = new InputController(deps);
|
|
973
|
+
|
|
974
|
+
await controller.sendMessage();
|
|
975
|
+
|
|
976
|
+
expect(deps.state.messages[0].content).toBe('@server-a hello');
|
|
977
|
+
expect(deps.state.messages[0].content).not.toBe('@server-a MCP hello');
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('should prepend current note only once per session', async () => {
|
|
981
|
+
const prompts: string[] = [];
|
|
982
|
+
let currentNoteSent = false;
|
|
983
|
+
const fileContextManager = {
|
|
984
|
+
startSession: jest.fn(),
|
|
985
|
+
getCurrentNotePath: jest.fn().mockReturnValue('notes/session.md'),
|
|
986
|
+
shouldSendCurrentNote: jest.fn().mockImplementation(() => !currentNoteSent),
|
|
987
|
+
markCurrentNoteSent: jest.fn().mockImplementation(() => { currentNoteSent = true; }),
|
|
988
|
+
transformContextMentions: jest.fn().mockImplementation((text: string) => text),
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
deps.getFileContextManager = () => fileContextManager as any;
|
|
992
|
+
(deps as any).mockAgentService.query = jest.fn().mockImplementation((turn: any) => {
|
|
993
|
+
prompts.push(turn.prompt);
|
|
994
|
+
return createMockStream([{ type: 'done' }]);
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
inputEl.value = 'First message';
|
|
998
|
+
await controller.sendMessage();
|
|
999
|
+
|
|
1000
|
+
inputEl.value = 'Second message';
|
|
1001
|
+
await controller.sendMessage();
|
|
1002
|
+
|
|
1003
|
+
expect(prompts[0]).toContain('<current_note>');
|
|
1004
|
+
expect(prompts[1]).not.toContain('<current_note>');
|
|
1005
|
+
});
|
|
1006
|
+
|
|
1007
|
+
it('should not persist currentNote metadata for /compact turns', async () => {
|
|
1008
|
+
const fileContextManager = {
|
|
1009
|
+
startSession: jest.fn(),
|
|
1010
|
+
getCurrentNotePath: jest.fn().mockReturnValue('notes/session.md'),
|
|
1011
|
+
shouldSendCurrentNote: jest.fn().mockReturnValue(true),
|
|
1012
|
+
markCurrentNoteSent: jest.fn(),
|
|
1013
|
+
transformContextMentions: jest.fn().mockImplementation((text: string) => text),
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
deps = createSendableDeps({
|
|
1017
|
+
getFileContextManager: () => fileContextManager as any,
|
|
1018
|
+
});
|
|
1019
|
+
(deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }]));
|
|
1020
|
+
|
|
1021
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1022
|
+
inputEl.value = '/compact';
|
|
1023
|
+
controller = new InputController(deps);
|
|
1024
|
+
|
|
1025
|
+
await controller.sendMessage();
|
|
1026
|
+
|
|
1027
|
+
expect(deps.state.messages[0].content).toBe('/compact');
|
|
1028
|
+
expect(deps.state.messages[0].currentNote).toBeUndefined();
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it('should include MCP options in query when mentions are present', async () => {
|
|
1032
|
+
const mcpMentions = new Set(['server-a']);
|
|
1033
|
+
const enabledServers = new Set(['server-b']);
|
|
1034
|
+
|
|
1035
|
+
(deps as any).mockAgentService.prepareTurn = jest.fn().mockImplementation((request: any) => ({
|
|
1036
|
+
request,
|
|
1037
|
+
persistedContent: request.text,
|
|
1038
|
+
prompt: request.text,
|
|
1039
|
+
isCompact: false,
|
|
1040
|
+
mcpMentions,
|
|
1041
|
+
}));
|
|
1042
|
+
deps.getMcpServerSelector = () => ({
|
|
1043
|
+
getEnabledServers: () => enabledServers,
|
|
1044
|
+
}) as any;
|
|
1045
|
+
(deps as any).mockAgentService.query = jest.fn().mockImplementation(() => createMockStream([{ type: 'done' }]));
|
|
1046
|
+
|
|
1047
|
+
inputEl.value = 'hello';
|
|
1048
|
+
|
|
1049
|
+
await controller.sendMessage();
|
|
1050
|
+
|
|
1051
|
+
const prepareTurnCall = ((deps as any).mockAgentService.prepareTurn as jest.Mock).mock.calls[0];
|
|
1052
|
+
expect(prepareTurnCall[0].enabledMcpServers).toBe(enabledServers);
|
|
1053
|
+
const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0];
|
|
1054
|
+
expect(queryCall[0].mcpMentions).toBe(mcpMentions);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
it('should append browser selection context when available', async () => {
|
|
1058
|
+
const mockAgentService = createMockAgentService();
|
|
1059
|
+
const localDeps = createSendableDeps({
|
|
1060
|
+
browserSelectionController: {
|
|
1061
|
+
getContext: jest.fn().mockReturnValue({
|
|
1062
|
+
source: 'surfing-view',
|
|
1063
|
+
selectedText: 'selected from browser',
|
|
1064
|
+
title: 'Surfing',
|
|
1065
|
+
}),
|
|
1066
|
+
} as any,
|
|
1067
|
+
getAgentService: () => mockAgentService as any,
|
|
1068
|
+
});
|
|
1069
|
+
const localController = new InputController(localDeps);
|
|
1070
|
+
|
|
1071
|
+
mockAgentService.query.mockImplementation((turn: any) => {
|
|
1072
|
+
expect(turn.prompt).toContain('<browser_selection source="surfing-view" title="Surfing">');
|
|
1073
|
+
expect(turn.prompt).toContain('selected from browser');
|
|
1074
|
+
return createMockStream([{ type: 'done' }]);
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const localInput = localDeps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1078
|
+
localInput.value = 'Summarize this';
|
|
1079
|
+
|
|
1080
|
+
await localController.sendMessage();
|
|
1081
|
+
|
|
1082
|
+
expect(mockAgentService.query).toHaveBeenCalled();
|
|
1083
|
+
});
|
|
1084
|
+
});
|
|
1085
|
+
|
|
1086
|
+
describe('Conversation operation guards', () => {
|
|
1087
|
+
it('should not send message when isCreatingConversation is true', async () => {
|
|
1088
|
+
deps.state.isCreatingConversation = true;
|
|
1089
|
+
inputEl.value = 'test message';
|
|
1090
|
+
|
|
1091
|
+
await controller.sendMessage();
|
|
1092
|
+
|
|
1093
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
1094
|
+
// Input should be preserved for retry
|
|
1095
|
+
expect(inputEl.value).toBe('test message');
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
it('should not send message when isSwitchingConversation is true', async () => {
|
|
1099
|
+
deps.state.isSwitchingConversation = true;
|
|
1100
|
+
inputEl.value = 'test message';
|
|
1101
|
+
|
|
1102
|
+
await controller.sendMessage();
|
|
1103
|
+
|
|
1104
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
1105
|
+
// Input should be preserved for retry
|
|
1106
|
+
expect(inputEl.value).toBe('test message');
|
|
1107
|
+
});
|
|
1108
|
+
|
|
1109
|
+
it('should preserve images when blocked by conversation operation', async () => {
|
|
1110
|
+
deps.state.isCreatingConversation = true;
|
|
1111
|
+
inputEl.value = 'test message';
|
|
1112
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
1113
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
1114
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(true);
|
|
1115
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue(mockImages);
|
|
1116
|
+
|
|
1117
|
+
await controller.sendMessage();
|
|
1118
|
+
|
|
1119
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
1120
|
+
// Images should NOT be cleared
|
|
1121
|
+
expect(imageContextManager.clearImages).not.toHaveBeenCalled();
|
|
1122
|
+
});
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
describe('Title generation', () => {
|
|
1126
|
+
it('should set pending status and fallback title after first user message', async () => {
|
|
1127
|
+
const mockTitleService = {
|
|
1128
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1129
|
+
cancel: jest.fn(),
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
// conversationId=null to test the conversation creation path
|
|
1133
|
+
deps = createSendableDeps({
|
|
1134
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
1135
|
+
}, null);
|
|
1136
|
+
|
|
1137
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1138
|
+
createMockStream([
|
|
1139
|
+
{ type: 'text', content: 'Hello, how can I help?' },
|
|
1140
|
+
{ type: 'done' },
|
|
1141
|
+
])
|
|
1142
|
+
);
|
|
1143
|
+
|
|
1144
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1145
|
+
if (chunk.type === 'text') {
|
|
1146
|
+
msg.content = chunk.content;
|
|
1147
|
+
}
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1151
|
+
inputEl.value = 'Hello world';
|
|
1152
|
+
controller = new InputController(deps);
|
|
1153
|
+
|
|
1154
|
+
await controller.sendMessage();
|
|
1155
|
+
|
|
1156
|
+
expect(deps.plugin.createConversation).toHaveBeenCalled();
|
|
1157
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: 'pending' });
|
|
1158
|
+
expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title');
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
it('should find messages by role, not by index', async () => {
|
|
1162
|
+
deps = createSendableDeps();
|
|
1163
|
+
|
|
1164
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1165
|
+
createMockStream([{ type: 'done' }])
|
|
1166
|
+
);
|
|
1167
|
+
|
|
1168
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1169
|
+
inputEl.value = 'Test message';
|
|
1170
|
+
controller = new InputController(deps);
|
|
1171
|
+
|
|
1172
|
+
await controller.sendMessage();
|
|
1173
|
+
|
|
1174
|
+
const userMsg = deps.state.messages.find(m => m.role === 'user');
|
|
1175
|
+
const assistantMsg = deps.state.messages.find(m => m.role === 'assistant');
|
|
1176
|
+
expect(userMsg).toBeDefined();
|
|
1177
|
+
expect(assistantMsg).toBeDefined();
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1180
|
+
it('should call title generation service when available', async () => {
|
|
1181
|
+
const mockTitleService = {
|
|
1182
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1183
|
+
cancel: jest.fn(),
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
deps = createSendableDeps({
|
|
1187
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1191
|
+
createMockStream([
|
|
1192
|
+
{ type: 'text', content: 'Response text' },
|
|
1193
|
+
{ type: 'done' },
|
|
1194
|
+
])
|
|
1195
|
+
);
|
|
1196
|
+
|
|
1197
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1198
|
+
if (chunk.type === 'text') {
|
|
1199
|
+
msg.content = chunk.content;
|
|
1200
|
+
}
|
|
1201
|
+
});
|
|
1202
|
+
|
|
1203
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1204
|
+
inputEl.value = 'Hello world';
|
|
1205
|
+
controller = new InputController(deps);
|
|
1206
|
+
|
|
1207
|
+
await controller.sendMessage();
|
|
1208
|
+
|
|
1209
|
+
expect(mockTitleService.generateTitle).toHaveBeenCalled();
|
|
1210
|
+
const callArgs = mockTitleService.generateTitle.mock.calls[0];
|
|
1211
|
+
expect(callArgs[0]).toBe('conv-1');
|
|
1212
|
+
expect(callArgs[1]).toContain('Hello world');
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
it('should lazily create the conversation with the active runtime provider', async () => {
|
|
1216
|
+
const sendableDeps = createSendableDeps({}, null);
|
|
1217
|
+
sendableDeps.mockAgentService.providerId = 'codex';
|
|
1218
|
+
deps = sendableDeps;
|
|
1219
|
+
(deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: 'conv-codex', providerId: 'codex' });
|
|
1220
|
+
|
|
1221
|
+
(sendableDeps.mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1222
|
+
createMockStream([
|
|
1223
|
+
{ type: 'text', content: 'Response text' },
|
|
1224
|
+
{ type: 'done' },
|
|
1225
|
+
])
|
|
1226
|
+
);
|
|
1227
|
+
|
|
1228
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1229
|
+
if (chunk.type === 'text') {
|
|
1230
|
+
msg.content = chunk.content;
|
|
1231
|
+
}
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1235
|
+
inputEl.value = 'Hello world';
|
|
1236
|
+
controller = new InputController(deps);
|
|
1237
|
+
|
|
1238
|
+
await controller.sendMessage();
|
|
1239
|
+
|
|
1240
|
+
expect(deps.plugin.createConversation).toHaveBeenCalledWith({
|
|
1241
|
+
providerId: 'codex',
|
|
1242
|
+
sessionId: undefined,
|
|
1243
|
+
});
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it('should prefer the blank-tab provider over a stale runtime when lazily creating a conversation', async () => {
|
|
1247
|
+
const sendableDeps = createSendableDeps({
|
|
1248
|
+
getTabProviderId: () => 'claude',
|
|
1249
|
+
}, null);
|
|
1250
|
+
sendableDeps.mockAgentService.providerId = 'codex';
|
|
1251
|
+
deps = sendableDeps;
|
|
1252
|
+
(deps.plugin.createConversation as jest.Mock).mockResolvedValue({ id: 'conv-claude', providerId: 'claude' });
|
|
1253
|
+
|
|
1254
|
+
(sendableDeps.mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1255
|
+
createMockStream([
|
|
1256
|
+
{ type: 'text', content: 'Response text' },
|
|
1257
|
+
{ type: 'done' },
|
|
1258
|
+
])
|
|
1259
|
+
);
|
|
1260
|
+
|
|
1261
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1262
|
+
if (chunk.type === 'text') {
|
|
1263
|
+
msg.content = chunk.content;
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
|
|
1267
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1268
|
+
inputEl.value = 'Hello world';
|
|
1269
|
+
controller = new InputController(deps);
|
|
1270
|
+
|
|
1271
|
+
await controller.sendMessage();
|
|
1272
|
+
|
|
1273
|
+
expect(deps.plugin.createConversation).toHaveBeenCalledWith({
|
|
1274
|
+
providerId: 'claude',
|
|
1275
|
+
sessionId: undefined,
|
|
1276
|
+
});
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
it('should not overwrite user-renamed title in callback', async () => {
|
|
1280
|
+
const mockTitleService = {
|
|
1281
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1282
|
+
cancel: jest.fn(),
|
|
1283
|
+
};
|
|
1284
|
+
|
|
1285
|
+
deps = createSendableDeps({
|
|
1286
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1290
|
+
createMockStream([
|
|
1291
|
+
{ type: 'text', content: 'Response' },
|
|
1292
|
+
{ type: 'done' },
|
|
1293
|
+
])
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1297
|
+
if (chunk.type === 'text') {
|
|
1298
|
+
msg.content = chunk.content;
|
|
1299
|
+
}
|
|
1300
|
+
});
|
|
1301
|
+
|
|
1302
|
+
// Simulate user having renamed the conversation
|
|
1303
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
1304
|
+
id: 'conv-1',
|
|
1305
|
+
title: 'User Custom Title',
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1309
|
+
inputEl.value = 'Test';
|
|
1310
|
+
controller = new InputController(deps);
|
|
1311
|
+
|
|
1312
|
+
await controller.sendMessage();
|
|
1313
|
+
|
|
1314
|
+
const callback = mockTitleService.generateTitle.mock.calls[0][2];
|
|
1315
|
+
await callback('conv-1', { success: true, title: 'AI Generated Title' });
|
|
1316
|
+
|
|
1317
|
+
// Should clear status since user manually renamed (not apply AI title)
|
|
1318
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { titleGenerationStatus: undefined });
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it('should not set pending status when titleService is null', async () => {
|
|
1322
|
+
deps = createSendableDeps({
|
|
1323
|
+
getTitleGenerationService: () => null,
|
|
1324
|
+
});
|
|
1325
|
+
|
|
1326
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1327
|
+
createMockStream([
|
|
1328
|
+
{ type: 'text', content: 'Response' },
|
|
1329
|
+
{ type: 'done' },
|
|
1330
|
+
])
|
|
1331
|
+
);
|
|
1332
|
+
|
|
1333
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1334
|
+
if (chunk.type === 'text') {
|
|
1335
|
+
msg.content = chunk.content;
|
|
1336
|
+
}
|
|
1337
|
+
});
|
|
1338
|
+
|
|
1339
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1340
|
+
inputEl.value = 'Test message';
|
|
1341
|
+
controller = new InputController(deps);
|
|
1342
|
+
|
|
1343
|
+
await controller.sendMessage();
|
|
1344
|
+
|
|
1345
|
+
const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls;
|
|
1346
|
+
const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) =>
|
|
1347
|
+
call[1]?.titleGenerationStatus === 'pending'
|
|
1348
|
+
);
|
|
1349
|
+
expect(pendingCall).toBeUndefined();
|
|
1350
|
+
});
|
|
1351
|
+
|
|
1352
|
+
it('should NOT call title generation service when enableAutoTitleGeneration is false', async () => {
|
|
1353
|
+
const mockTitleService = {
|
|
1354
|
+
generateTitle: jest.fn().mockResolvedValue(undefined),
|
|
1355
|
+
cancel: jest.fn(),
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
deps = createSendableDeps({
|
|
1359
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
1360
|
+
});
|
|
1361
|
+
deps.plugin.settings.enableAutoTitleGeneration = false;
|
|
1362
|
+
|
|
1363
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1364
|
+
createMockStream([
|
|
1365
|
+
{ type: 'text', content: 'Response text' },
|
|
1366
|
+
{ type: 'done' },
|
|
1367
|
+
])
|
|
1368
|
+
);
|
|
1369
|
+
|
|
1370
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
1371
|
+
if (chunk.type === 'text') {
|
|
1372
|
+
msg.content = chunk.content;
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1377
|
+
inputEl.value = 'Hello world';
|
|
1378
|
+
controller = new InputController(deps);
|
|
1379
|
+
|
|
1380
|
+
await controller.sendMessage();
|
|
1381
|
+
|
|
1382
|
+
expect(mockTitleService.generateTitle).not.toHaveBeenCalled();
|
|
1383
|
+
|
|
1384
|
+
const updateCalls = (deps.plugin.updateConversation as jest.Mock).mock.calls;
|
|
1385
|
+
const pendingCall = updateCalls.find((call: [string, { titleGenerationStatus?: string }]) =>
|
|
1386
|
+
call[1]?.titleGenerationStatus === 'pending'
|
|
1387
|
+
);
|
|
1388
|
+
expect(pendingCall).toBeUndefined();
|
|
1389
|
+
|
|
1390
|
+
expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'Test Title');
|
|
1391
|
+
});
|
|
1392
|
+
});
|
|
1393
|
+
|
|
1394
|
+
describe('Auto-hide status panels on response end', () => {
|
|
1395
|
+
it('should clear currentTodos when all todos are completed', async () => {
|
|
1396
|
+
deps = createSendableDeps();
|
|
1397
|
+
deps.state.currentTodos = [
|
|
1398
|
+
{ content: 'Task 1', status: 'completed', activeForm: 'Task 1' },
|
|
1399
|
+
{ content: 'Task 2', status: 'completed', activeForm: 'Task 2' },
|
|
1400
|
+
];
|
|
1401
|
+
|
|
1402
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1403
|
+
createMockStream([{ type: 'done' }])
|
|
1404
|
+
);
|
|
1405
|
+
|
|
1406
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1407
|
+
inputEl.value = 'Test message';
|
|
1408
|
+
controller = new InputController(deps);
|
|
1409
|
+
|
|
1410
|
+
await controller.sendMessage();
|
|
1411
|
+
|
|
1412
|
+
expect(deps.state.currentTodos).toBeNull();
|
|
1413
|
+
});
|
|
1414
|
+
|
|
1415
|
+
it('should NOT clear currentTodos when some todos are pending', async () => {
|
|
1416
|
+
deps = createSendableDeps();
|
|
1417
|
+
deps.state.currentTodos = [
|
|
1418
|
+
{ content: 'Task 1', status: 'completed', activeForm: 'Task 1' },
|
|
1419
|
+
{ content: 'Task 2', status: 'pending', activeForm: 'Task 2' },
|
|
1420
|
+
];
|
|
1421
|
+
|
|
1422
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1423
|
+
createMockStream([{ type: 'done' }])
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1427
|
+
inputEl.value = 'Test message';
|
|
1428
|
+
controller = new InputController(deps);
|
|
1429
|
+
|
|
1430
|
+
await controller.sendMessage();
|
|
1431
|
+
|
|
1432
|
+
expect(deps.state.currentTodos).not.toBeNull();
|
|
1433
|
+
expect(deps.state.currentTodos).toHaveLength(2);
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
it('should handle null statusPanel gracefully', async () => {
|
|
1437
|
+
deps = createSendableDeps({
|
|
1438
|
+
getStatusPanel: () => null,
|
|
1439
|
+
});
|
|
1440
|
+
|
|
1441
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
1442
|
+
createMockStream([{ type: 'done' }])
|
|
1443
|
+
);
|
|
1444
|
+
|
|
1445
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1446
|
+
inputEl.value = 'Test message';
|
|
1447
|
+
controller = new InputController(deps);
|
|
1448
|
+
|
|
1449
|
+
await expect(controller.sendMessage()).resolves.not.toThrow();
|
|
1450
|
+
});
|
|
1451
|
+
});
|
|
1452
|
+
|
|
1453
|
+
describe('Approval inline tracking', () => {
|
|
1454
|
+
it('should dismiss pending inline and clear reference', () => {
|
|
1455
|
+
controller = new InputController(deps);
|
|
1456
|
+
const mockInline = { destroy: jest.fn() };
|
|
1457
|
+
(controller as any).pendingApprovalInline = mockInline;
|
|
1458
|
+
|
|
1459
|
+
controller.dismissPendingApproval();
|
|
1460
|
+
|
|
1461
|
+
expect(mockInline.destroy).toHaveBeenCalled();
|
|
1462
|
+
expect((controller as any).pendingApprovalInline).toBeNull();
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
it('should dismiss pending ask inline and clear reference', () => {
|
|
1466
|
+
controller = new InputController(deps);
|
|
1467
|
+
const mockAskInline = { destroy: jest.fn() };
|
|
1468
|
+
(controller as any).pendingAskInline = mockAskInline;
|
|
1469
|
+
|
|
1470
|
+
controller.dismissPendingApproval();
|
|
1471
|
+
|
|
1472
|
+
expect(mockAskInline.destroy).toHaveBeenCalled();
|
|
1473
|
+
expect((controller as any).pendingAskInline).toBeNull();
|
|
1474
|
+
});
|
|
1475
|
+
|
|
1476
|
+
it('should dismiss both approval and ask inlines', () => {
|
|
1477
|
+
controller = new InputController(deps);
|
|
1478
|
+
const mockApproval = { destroy: jest.fn() };
|
|
1479
|
+
const mockAsk = { destroy: jest.fn() };
|
|
1480
|
+
(controller as any).pendingApprovalInline = mockApproval;
|
|
1481
|
+
(controller as any).pendingAskInline = mockAsk;
|
|
1482
|
+
|
|
1483
|
+
controller.dismissPendingApproval();
|
|
1484
|
+
|
|
1485
|
+
expect(mockApproval.destroy).toHaveBeenCalled();
|
|
1486
|
+
expect(mockAsk.destroy).toHaveBeenCalled();
|
|
1487
|
+
expect((controller as any).pendingApprovalInline).toBeNull();
|
|
1488
|
+
expect((controller as any).pendingAskInline).toBeNull();
|
|
1489
|
+
});
|
|
1490
|
+
|
|
1491
|
+
it('should be a no-op when no inline is pending', () => {
|
|
1492
|
+
controller = new InputController(deps);
|
|
1493
|
+
expect((controller as any).pendingApprovalInline).toBeNull();
|
|
1494
|
+
expect(() => controller.dismissPendingApproval()).not.toThrow();
|
|
1495
|
+
});
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
describe('Built-in commands - /add-dir', () => {
|
|
1499
|
+
beforeEach(() => {
|
|
1500
|
+
mockNotice.mockClear();
|
|
1501
|
+
});
|
|
1502
|
+
|
|
1503
|
+
it('should work on codex tabs', async () => {
|
|
1504
|
+
const mockExternalContextSelector = {
|
|
1505
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1506
|
+
addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: '/some/path' }),
|
|
1507
|
+
};
|
|
1508
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1509
|
+
deps.getAgentService = () => ({
|
|
1510
|
+
...(deps as any).mockAgentService,
|
|
1511
|
+
providerId: 'codex',
|
|
1512
|
+
getCapabilities: jest.fn().mockReturnValue({
|
|
1513
|
+
providerId: 'codex',
|
|
1514
|
+
supportsPersistentRuntime: true,
|
|
1515
|
+
supportsNativeHistory: true,
|
|
1516
|
+
supportsPlanMode: false,
|
|
1517
|
+
supportsRewind: false,
|
|
1518
|
+
supportsFork: false,
|
|
1519
|
+
supportsProviderCommands: false,
|
|
1520
|
+
reasoningControl: 'effort',
|
|
1521
|
+
}),
|
|
1522
|
+
} as any);
|
|
1523
|
+
inputEl.value = '/add-dir /some/path';
|
|
1524
|
+
controller = new InputController(deps);
|
|
1525
|
+
|
|
1526
|
+
await controller.sendMessage();
|
|
1527
|
+
|
|
1528
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('/some/path');
|
|
1529
|
+
expect(mockNotice).toHaveBeenCalledWith('Added external context: /some/path');
|
|
1530
|
+
});
|
|
1531
|
+
|
|
1532
|
+
it('should show error notice when external context selector is not available', async () => {
|
|
1533
|
+
deps.getExternalContextSelector = () => null;
|
|
1534
|
+
inputEl.value = '/add-dir /some/path';
|
|
1535
|
+
controller = new InputController(deps);
|
|
1536
|
+
|
|
1537
|
+
await controller.sendMessage();
|
|
1538
|
+
|
|
1539
|
+
expect(mockNotice).toHaveBeenCalledWith('External context selector not available.');
|
|
1540
|
+
expect(inputEl.value).toBe('');
|
|
1541
|
+
});
|
|
1542
|
+
|
|
1543
|
+
it('should show success notice when path is added successfully', async () => {
|
|
1544
|
+
const mockExternalContextSelector = {
|
|
1545
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1546
|
+
addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: '/some/path' }),
|
|
1547
|
+
};
|
|
1548
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1549
|
+
inputEl.value = '/add-dir /some/path';
|
|
1550
|
+
controller = new InputController(deps);
|
|
1551
|
+
|
|
1552
|
+
await controller.sendMessage();
|
|
1553
|
+
|
|
1554
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('/some/path');
|
|
1555
|
+
expect(mockNotice).toHaveBeenCalledWith('Added external context: /some/path');
|
|
1556
|
+
expect(inputEl.value).toBe('');
|
|
1557
|
+
});
|
|
1558
|
+
|
|
1559
|
+
it('should show error notice when /add-dir is called without path', async () => {
|
|
1560
|
+
const mockExternalContextSelector = {
|
|
1561
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1562
|
+
addExternalContext: jest.fn().mockReturnValue({
|
|
1563
|
+
success: false,
|
|
1564
|
+
error: 'No path provided. Usage: /add-dir /absolute/path',
|
|
1565
|
+
}),
|
|
1566
|
+
};
|
|
1567
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1568
|
+
inputEl.value = '/add-dir';
|
|
1569
|
+
controller = new InputController(deps);
|
|
1570
|
+
|
|
1571
|
+
await controller.sendMessage();
|
|
1572
|
+
|
|
1573
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('');
|
|
1574
|
+
expect(mockNotice).toHaveBeenCalledWith('No path provided. Usage: /add-dir /absolute/path');
|
|
1575
|
+
expect(inputEl.value).toBe('');
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
it('should show error notice when path addition fails', async () => {
|
|
1579
|
+
const mockExternalContextSelector = {
|
|
1580
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1581
|
+
addExternalContext: jest.fn().mockReturnValue({
|
|
1582
|
+
success: false,
|
|
1583
|
+
error: 'Path must be absolute. Usage: /add-dir /absolute/path',
|
|
1584
|
+
}),
|
|
1585
|
+
};
|
|
1586
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1587
|
+
inputEl.value = '/add-dir relative/path';
|
|
1588
|
+
controller = new InputController(deps);
|
|
1589
|
+
|
|
1590
|
+
await controller.sendMessage();
|
|
1591
|
+
|
|
1592
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('relative/path');
|
|
1593
|
+
expect(mockNotice).toHaveBeenCalledWith('Path must be absolute. Usage: /add-dir /absolute/path');
|
|
1594
|
+
expect(inputEl.value).toBe('');
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it('should handle /add-dir with home path expansion', async () => {
|
|
1598
|
+
const expandedPath = '/Users/test/projects';
|
|
1599
|
+
const mockExternalContextSelector = {
|
|
1600
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1601
|
+
addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath: expandedPath }),
|
|
1602
|
+
};
|
|
1603
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1604
|
+
inputEl.value = '/add-dir ~/projects';
|
|
1605
|
+
controller = new InputController(deps);
|
|
1606
|
+
|
|
1607
|
+
await controller.sendMessage();
|
|
1608
|
+
|
|
1609
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('~/projects');
|
|
1610
|
+
expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${expandedPath}`);
|
|
1611
|
+
});
|
|
1612
|
+
|
|
1613
|
+
it('should handle /add-dir with quoted path', async () => {
|
|
1614
|
+
const normalizedPath = '/path/with spaces';
|
|
1615
|
+
const mockExternalContextSelector = {
|
|
1616
|
+
getExternalContexts: jest.fn().mockReturnValue([]),
|
|
1617
|
+
addExternalContext: jest.fn().mockReturnValue({ success: true, normalizedPath }),
|
|
1618
|
+
};
|
|
1619
|
+
deps.getExternalContextSelector = () => mockExternalContextSelector;
|
|
1620
|
+
inputEl.value = '/add-dir "/path/with spaces"';
|
|
1621
|
+
controller = new InputController(deps);
|
|
1622
|
+
|
|
1623
|
+
await controller.sendMessage();
|
|
1624
|
+
|
|
1625
|
+
expect(mockExternalContextSelector.addExternalContext).toHaveBeenCalledWith('"/path/with spaces"');
|
|
1626
|
+
expect(mockNotice).toHaveBeenCalledWith(`Added external context: ${normalizedPath}`);
|
|
1627
|
+
});
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
describe('Built-in commands - /clear', () => {
|
|
1631
|
+
it('should call conversationController.createNew on /clear', async () => {
|
|
1632
|
+
(deps.conversationController as any).createNew = jest.fn().mockResolvedValue(undefined);
|
|
1633
|
+
inputEl.value = '/clear';
|
|
1634
|
+
controller = new InputController(deps);
|
|
1635
|
+
|
|
1636
|
+
await controller.sendMessage();
|
|
1637
|
+
|
|
1638
|
+
expect((deps.conversationController as any).createNew).toHaveBeenCalled();
|
|
1639
|
+
expect(inputEl.value).toBe('');
|
|
1640
|
+
});
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
describe('Built-in commands - /resume', () => {
|
|
1644
|
+
const mockConversations = [
|
|
1645
|
+
{ id: 'conv-1', title: 'Chat 1', createdAt: 1000, updatedAt: 1000, messageCount: 1, preview: '' },
|
|
1646
|
+
];
|
|
1647
|
+
|
|
1648
|
+
let mockDropdownInstance: {
|
|
1649
|
+
isVisible: jest.Mock;
|
|
1650
|
+
handleKeydown: jest.Mock;
|
|
1651
|
+
destroy: jest.Mock;
|
|
1652
|
+
};
|
|
1653
|
+
|
|
1654
|
+
beforeEach(() => {
|
|
1655
|
+
mockNotice.mockClear();
|
|
1656
|
+
mockDropdownInstance = {
|
|
1657
|
+
isVisible: jest.fn().mockReturnValue(true),
|
|
1658
|
+
handleKeydown: jest.fn().mockReturnValue(false),
|
|
1659
|
+
destroy: jest.fn(),
|
|
1660
|
+
};
|
|
1661
|
+
(ResumeSessionDropdown as jest.Mock).mockImplementation(() => mockDropdownInstance);
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it('should reject /resume when the provider lacks native history support', async () => {
|
|
1665
|
+
deps.getAgentService = () => ({
|
|
1666
|
+
...(deps as any).mockAgentService,
|
|
1667
|
+
providerId: 'codex',
|
|
1668
|
+
getCapabilities: jest.fn().mockReturnValue({
|
|
1669
|
+
providerId: 'codex',
|
|
1670
|
+
supportsPersistentRuntime: true,
|
|
1671
|
+
supportsNativeHistory: false,
|
|
1672
|
+
supportsPlanMode: false,
|
|
1673
|
+
supportsRewind: false,
|
|
1674
|
+
supportsFork: false,
|
|
1675
|
+
supportsProviderCommands: false,
|
|
1676
|
+
reasoningControl: 'effort',
|
|
1677
|
+
}),
|
|
1678
|
+
} as any);
|
|
1679
|
+
inputEl.value = '/resume';
|
|
1680
|
+
controller = new InputController(deps);
|
|
1681
|
+
|
|
1682
|
+
await controller.sendMessage();
|
|
1683
|
+
|
|
1684
|
+
expect(mockNotice).toHaveBeenCalledWith('/resume is not supported by this provider.');
|
|
1685
|
+
expect(ResumeSessionDropdown).not.toHaveBeenCalled();
|
|
1686
|
+
});
|
|
1687
|
+
|
|
1688
|
+
it('should show notice when no conversations exist', async () => {
|
|
1689
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue([]);
|
|
1690
|
+
inputEl.value = '/resume';
|
|
1691
|
+
controller = new InputController(deps);
|
|
1692
|
+
|
|
1693
|
+
await controller.sendMessage();
|
|
1694
|
+
|
|
1695
|
+
expect(mockNotice).toHaveBeenCalledWith('No conversations to resume');
|
|
1696
|
+
expect(ResumeSessionDropdown).not.toHaveBeenCalled();
|
|
1697
|
+
expect(inputEl.value).toBe('');
|
|
1698
|
+
});
|
|
1699
|
+
|
|
1700
|
+
it('should create dropdown when conversations exist', async () => {
|
|
1701
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1702
|
+
inputEl.value = '/resume';
|
|
1703
|
+
controller = new InputController(deps);
|
|
1704
|
+
|
|
1705
|
+
await controller.sendMessage();
|
|
1706
|
+
|
|
1707
|
+
expect(ResumeSessionDropdown).toHaveBeenCalledWith(
|
|
1708
|
+
expect.anything(),
|
|
1709
|
+
expect.anything(),
|
|
1710
|
+
mockConversations,
|
|
1711
|
+
deps.state.currentConversationId,
|
|
1712
|
+
expect.objectContaining({ onSelect: expect.any(Function), onDismiss: expect.any(Function) }),
|
|
1713
|
+
);
|
|
1714
|
+
expect(controller.isResumeDropdownVisible()).toBe(true);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('should call switchTo on select callback', async () => {
|
|
1718
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1719
|
+
(deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined);
|
|
1720
|
+
inputEl.value = '/resume';
|
|
1721
|
+
controller = new InputController(deps);
|
|
1722
|
+
|
|
1723
|
+
await controller.sendMessage();
|
|
1724
|
+
|
|
1725
|
+
const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4];
|
|
1726
|
+
callbacks.onSelect('conv-1');
|
|
1727
|
+
|
|
1728
|
+
expect((deps.conversationController as any).switchTo).toHaveBeenCalledWith('conv-1');
|
|
1729
|
+
expect(mockDropdownInstance.destroy).toHaveBeenCalled();
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it('should call openConversation on select callback when provided', async () => {
|
|
1733
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1734
|
+
(deps.conversationController as any).switchTo = jest.fn().mockResolvedValue(undefined);
|
|
1735
|
+
deps.openConversation = jest.fn().mockResolvedValue(undefined);
|
|
1736
|
+
inputEl.value = '/resume';
|
|
1737
|
+
controller = new InputController(deps);
|
|
1738
|
+
|
|
1739
|
+
await controller.sendMessage();
|
|
1740
|
+
|
|
1741
|
+
const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4];
|
|
1742
|
+
callbacks.onSelect('conv-1');
|
|
1743
|
+
|
|
1744
|
+
expect(deps.openConversation).toHaveBeenCalledWith('conv-1');
|
|
1745
|
+
expect((deps.conversationController as any).switchTo).not.toHaveBeenCalled();
|
|
1746
|
+
expect(mockDropdownInstance.destroy).toHaveBeenCalled();
|
|
1747
|
+
});
|
|
1748
|
+
|
|
1749
|
+
it('should destroy dropdown on dismiss callback', async () => {
|
|
1750
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1751
|
+
inputEl.value = '/resume';
|
|
1752
|
+
controller = new InputController(deps);
|
|
1753
|
+
|
|
1754
|
+
await controller.sendMessage();
|
|
1755
|
+
|
|
1756
|
+
const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4];
|
|
1757
|
+
callbacks.onDismiss();
|
|
1758
|
+
|
|
1759
|
+
expect(mockDropdownInstance.destroy).toHaveBeenCalled();
|
|
1760
|
+
expect(controller.isResumeDropdownVisible()).toBe(false);
|
|
1761
|
+
});
|
|
1762
|
+
|
|
1763
|
+
it('should show notice with error message when openConversation rejects', async () => {
|
|
1764
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1765
|
+
deps.openConversation = jest.fn().mockRejectedValue(new Error('session not found'));
|
|
1766
|
+
inputEl.value = '/resume';
|
|
1767
|
+
controller = new InputController(deps);
|
|
1768
|
+
|
|
1769
|
+
await controller.sendMessage();
|
|
1770
|
+
|
|
1771
|
+
const callbacks = (ResumeSessionDropdown as jest.Mock).mock.calls[0][4];
|
|
1772
|
+
callbacks.onSelect('conv-1');
|
|
1773
|
+
|
|
1774
|
+
await Promise.resolve();
|
|
1775
|
+
|
|
1776
|
+
expect(mockNotice).toHaveBeenCalledWith('Failed to open conversation: session not found');
|
|
1777
|
+
});
|
|
1778
|
+
|
|
1779
|
+
it('should destroy existing dropdown before creating new one', async () => {
|
|
1780
|
+
(deps.plugin as any).getConversationList = jest.fn().mockReturnValue(mockConversations);
|
|
1781
|
+
inputEl.value = '/resume';
|
|
1782
|
+
controller = new InputController(deps);
|
|
1783
|
+
|
|
1784
|
+
await controller.sendMessage();
|
|
1785
|
+
const firstInstance = mockDropdownInstance;
|
|
1786
|
+
|
|
1787
|
+
// Create new mock instance for second call
|
|
1788
|
+
const secondInstance = { isVisible: jest.fn().mockReturnValue(true), handleKeydown: jest.fn(), destroy: jest.fn() };
|
|
1789
|
+
(ResumeSessionDropdown as jest.Mock).mockImplementation(() => secondInstance);
|
|
1790
|
+
|
|
1791
|
+
inputEl.value = '/resume';
|
|
1792
|
+
await controller.sendMessage();
|
|
1793
|
+
|
|
1794
|
+
expect(firstInstance.destroy).toHaveBeenCalled();
|
|
1795
|
+
expect(ResumeSessionDropdown).toHaveBeenCalledTimes(2);
|
|
1796
|
+
});
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
describe('Built-in commands - /fork', () => {
|
|
1800
|
+
beforeEach(() => {
|
|
1801
|
+
mockNotice.mockClear();
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
it('should call onForkAll callback when /fork is executed', async () => {
|
|
1805
|
+
const mockOnForkAll = jest.fn().mockResolvedValue(undefined);
|
|
1806
|
+
deps.onForkAll = mockOnForkAll;
|
|
1807
|
+
inputEl.value = '/fork';
|
|
1808
|
+
controller = new InputController(deps);
|
|
1809
|
+
|
|
1810
|
+
await controller.sendMessage();
|
|
1811
|
+
|
|
1812
|
+
expect(mockOnForkAll).toHaveBeenCalled();
|
|
1813
|
+
expect(inputEl.value).toBe('');
|
|
1814
|
+
});
|
|
1815
|
+
|
|
1816
|
+
it('should show notice when onForkAll is not available', async () => {
|
|
1817
|
+
deps.onForkAll = undefined;
|
|
1818
|
+
inputEl.value = '/fork';
|
|
1819
|
+
controller = new InputController(deps);
|
|
1820
|
+
|
|
1821
|
+
await controller.sendMessage();
|
|
1822
|
+
|
|
1823
|
+
expect(mockNotice).toHaveBeenCalledWith('Fork not available.');
|
|
1824
|
+
expect(inputEl.value).toBe('');
|
|
1825
|
+
});
|
|
1826
|
+
});
|
|
1827
|
+
|
|
1828
|
+
describe('Cancel streaming - restore behavior', () => {
|
|
1829
|
+
it('should set cancelRequested and call agent cancel', () => {
|
|
1830
|
+
deps.state.isStreaming = true;
|
|
1831
|
+
controller = new InputController(deps);
|
|
1832
|
+
|
|
1833
|
+
controller.cancelStreaming();
|
|
1834
|
+
|
|
1835
|
+
expect(deps.state.cancelRequested).toBe(true);
|
|
1836
|
+
expect((deps as any).mockAgentService.cancel).toHaveBeenCalled();
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it('should restore queued message to input when cancelling', () => {
|
|
1840
|
+
deps.state.isStreaming = true;
|
|
1841
|
+
deps.state.queuedMessage = { content: 'restored text', images: undefined, editorContext: null, canvasContext: null };
|
|
1842
|
+
controller = new InputController(deps);
|
|
1843
|
+
|
|
1844
|
+
controller.cancelStreaming();
|
|
1845
|
+
|
|
1846
|
+
expect(deps.state.queuedMessage).toBeNull();
|
|
1847
|
+
expect(inputEl.value).toBe('restored text');
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
it('should restore queued images to image context manager when cancelling', () => {
|
|
1851
|
+
deps.state.isStreaming = true;
|
|
1852
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
1853
|
+
deps.state.queuedMessage = { content: 'msg', images: mockImages as any, editorContext: null, canvasContext: null };
|
|
1854
|
+
|
|
1855
|
+
controller = new InputController(deps);
|
|
1856
|
+
controller.cancelStreaming();
|
|
1857
|
+
|
|
1858
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
1859
|
+
expect(imageContextManager.setImages).toHaveBeenCalledWith(mockImages);
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
it('should hide thinking indicator when cancelling', () => {
|
|
1863
|
+
deps.state.isStreaming = true;
|
|
1864
|
+
controller = new InputController(deps);
|
|
1865
|
+
|
|
1866
|
+
controller.cancelStreaming();
|
|
1867
|
+
|
|
1868
|
+
expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled();
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
it('should be a no-op when not streaming', () => {
|
|
1872
|
+
deps.state.isStreaming = false;
|
|
1873
|
+
controller = new InputController(deps);
|
|
1874
|
+
|
|
1875
|
+
controller.cancelStreaming();
|
|
1876
|
+
|
|
1877
|
+
expect(deps.state.cancelRequested).toBe(false);
|
|
1878
|
+
expect((deps as any).mockAgentService.cancel).not.toHaveBeenCalled();
|
|
1879
|
+
});
|
|
1880
|
+
});
|
|
1881
|
+
|
|
1882
|
+
describe('ensureServiceInitialized failure', () => {
|
|
1883
|
+
beforeEach(() => {
|
|
1884
|
+
mockNotice.mockClear();
|
|
1885
|
+
});
|
|
1886
|
+
|
|
1887
|
+
it('should show Notice and reset streaming when ensureServiceInitialized returns false', async () => {
|
|
1888
|
+
deps = createSendableDeps({
|
|
1889
|
+
ensureServiceInitialized: jest.fn().mockResolvedValue(false),
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1893
|
+
inputEl.value = 'test message';
|
|
1894
|
+
controller = new InputController(deps);
|
|
1895
|
+
|
|
1896
|
+
await controller.sendMessage();
|
|
1897
|
+
|
|
1898
|
+
expect(mockNotice).toHaveBeenCalledWith('Failed to initialize agent service. Please try again.');
|
|
1899
|
+
expect(deps.streamController.hideThinkingIndicator).toHaveBeenCalled();
|
|
1900
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
1901
|
+
expect(deps.state.hasPendingConversationSave).toBe(true);
|
|
1902
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
1903
|
+
});
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
describe('Agent service null', () => {
|
|
1907
|
+
beforeEach(() => {
|
|
1908
|
+
mockNotice.mockClear();
|
|
1909
|
+
});
|
|
1910
|
+
|
|
1911
|
+
it('should show Notice when getAgentService returns null', async () => {
|
|
1912
|
+
deps = createSendableDeps({
|
|
1913
|
+
getAgentService: () => null,
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1917
|
+
inputEl.value = 'test message';
|
|
1918
|
+
controller = new InputController(deps);
|
|
1919
|
+
|
|
1920
|
+
await controller.sendMessage();
|
|
1921
|
+
|
|
1922
|
+
expect(mockNotice).toHaveBeenCalledWith('Agent service not available. Please reload the plugin.');
|
|
1923
|
+
expect(deps.state.hasPendingConversationSave).toBe(true);
|
|
1924
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
1925
|
+
});
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
describe('Streaming error handling', () => {
|
|
1929
|
+
it('should catch errors and display via appendText', async () => {
|
|
1930
|
+
deps = createSendableDeps();
|
|
1931
|
+
|
|
1932
|
+
((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => {
|
|
1933
|
+
throw new Error('Network timeout');
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1937
|
+
inputEl.value = 'test message';
|
|
1938
|
+
controller = new InputController(deps);
|
|
1939
|
+
|
|
1940
|
+
await controller.sendMessage();
|
|
1941
|
+
|
|
1942
|
+
expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Network timeout');
|
|
1943
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
it('should handle non-Error thrown values', async () => {
|
|
1947
|
+
deps = createSendableDeps();
|
|
1948
|
+
|
|
1949
|
+
((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => {
|
|
1950
|
+
throw 'string error';
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1954
|
+
inputEl.value = 'test message';
|
|
1955
|
+
controller = new InputController(deps);
|
|
1956
|
+
|
|
1957
|
+
await controller.sendMessage();
|
|
1958
|
+
|
|
1959
|
+
expect(deps.streamController.appendText).toHaveBeenCalledWith('\n\n**Error:** Unknown error');
|
|
1960
|
+
});
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
describe('Stream interruption', () => {
|
|
1964
|
+
it('should append interrupted text when cancelRequested is true', async () => {
|
|
1965
|
+
deps = createSendableDeps();
|
|
1966
|
+
|
|
1967
|
+
((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => {
|
|
1968
|
+
return (async function* () {
|
|
1969
|
+
// Simulate cancel requested during streaming
|
|
1970
|
+
deps.state.cancelRequested = true;
|
|
1971
|
+
yield { type: 'text', content: 'partial' };
|
|
1972
|
+
})();
|
|
1973
|
+
});
|
|
1974
|
+
|
|
1975
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
1976
|
+
inputEl.value = 'test message';
|
|
1977
|
+
controller = new InputController(deps);
|
|
1978
|
+
|
|
1979
|
+
await controller.sendMessage();
|
|
1980
|
+
|
|
1981
|
+
expect(deps.streamController.appendText).toHaveBeenCalledWith(
|
|
1982
|
+
expect.stringContaining('Interrupted')
|
|
1983
|
+
);
|
|
1984
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
1985
|
+
expect(deps.state.cancelRequested).toBe(false);
|
|
1986
|
+
});
|
|
1987
|
+
|
|
1988
|
+
it('should append interrupted text when cancelRequested is set after last stream chunk', async () => {
|
|
1989
|
+
deps = createSendableDeps();
|
|
1990
|
+
|
|
1991
|
+
((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => {
|
|
1992
|
+
return (async function* () {
|
|
1993
|
+
yield { type: 'text', content: 'partial' };
|
|
1994
|
+
})();
|
|
1995
|
+
});
|
|
1996
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async () => {
|
|
1997
|
+
deps.state.cancelRequested = true;
|
|
1998
|
+
});
|
|
1999
|
+
|
|
2000
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2001
|
+
inputEl.value = 'test message';
|
|
2002
|
+
controller = new InputController(deps);
|
|
2003
|
+
|
|
2004
|
+
await controller.sendMessage();
|
|
2005
|
+
|
|
2006
|
+
expect(deps.streamController.appendText).toHaveBeenCalledWith(
|
|
2007
|
+
expect.stringContaining('Interrupted')
|
|
2008
|
+
);
|
|
2009
|
+
expect(deps.state.isStreaming).toBe(false);
|
|
2010
|
+
expect(deps.state.cancelRequested).toBe(false);
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
2013
|
+
|
|
2014
|
+
describe('Duration footer', () => {
|
|
2015
|
+
it('should render response duration footer when durationSeconds > 0', async () => {
|
|
2016
|
+
deps = createSendableDeps();
|
|
2017
|
+
|
|
2018
|
+
// First call sets responseStartTime; must be non-zero (0 is falsy and skips duration)
|
|
2019
|
+
let callCount = 0;
|
|
2020
|
+
jest.spyOn(performance, 'now').mockImplementation(() => {
|
|
2021
|
+
callCount++;
|
|
2022
|
+
// Returns 1000 for responseStartTime, 6000 for elapsed (5 seconds)
|
|
2023
|
+
return callCount <= 1 ? 1000 : 6000;
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2027
|
+
createMockStream([{ type: 'done' }])
|
|
2028
|
+
);
|
|
2029
|
+
|
|
2030
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2031
|
+
inputEl.value = 'test message';
|
|
2032
|
+
controller = new InputController(deps);
|
|
2033
|
+
|
|
2034
|
+
await controller.sendMessage();
|
|
2035
|
+
|
|
2036
|
+
const assistantMsg = deps.state.messages.find((m: any) => m.role === 'assistant');
|
|
2037
|
+
expect(assistantMsg).toBeDefined();
|
|
2038
|
+
expect(assistantMsg!.durationSeconds).toBe(5);
|
|
2039
|
+
expect(assistantMsg!.durationFlavorWord).toBeDefined();
|
|
2040
|
+
|
|
2041
|
+
jest.spyOn(performance, 'now').mockRestore();
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
it('should sync to the true bottom after response completion UI updates', async () => {
|
|
2045
|
+
const messagesEl = createMockEl();
|
|
2046
|
+
messagesEl.scrollTop = 120;
|
|
2047
|
+
messagesEl.scrollHeight = 640;
|
|
2048
|
+
messagesEl.clientHeight = 400;
|
|
2049
|
+
|
|
2050
|
+
deps = createSendableDeps({
|
|
2051
|
+
getMessagesEl: () => messagesEl as any,
|
|
2052
|
+
});
|
|
2053
|
+
|
|
2054
|
+
let callCount = 0;
|
|
2055
|
+
jest.spyOn(performance, 'now').mockImplementation(() => {
|
|
2056
|
+
callCount++;
|
|
2057
|
+
return callCount <= 1 ? 1000 : 6000;
|
|
2058
|
+
});
|
|
2059
|
+
|
|
2060
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2061
|
+
createMockStream([{ type: 'done' }])
|
|
2062
|
+
);
|
|
2063
|
+
|
|
2064
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2065
|
+
inputEl.value = 'test message';
|
|
2066
|
+
controller = new InputController(deps);
|
|
2067
|
+
|
|
2068
|
+
await controller.sendMessage();
|
|
2069
|
+
|
|
2070
|
+
expect(messagesEl.scrollTop).toBe(messagesEl.scrollHeight);
|
|
2071
|
+
jest.spyOn(performance, 'now').mockRestore();
|
|
2072
|
+
});
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
describe('External context in query', () => {
|
|
2076
|
+
it('should pass externalContextPaths in queryOptions', async () => {
|
|
2077
|
+
const externalPaths = ['/external/path1', '/external/path2'];
|
|
2078
|
+
|
|
2079
|
+
deps = createSendableDeps({
|
|
2080
|
+
getExternalContextSelector: () => ({
|
|
2081
|
+
getExternalContexts: () => externalPaths,
|
|
2082
|
+
addExternalContext: jest.fn(),
|
|
2083
|
+
}),
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2087
|
+
createMockStream([{ type: 'done' }])
|
|
2088
|
+
);
|
|
2089
|
+
|
|
2090
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2091
|
+
inputEl.value = 'test message';
|
|
2092
|
+
controller = new InputController(deps);
|
|
2093
|
+
|
|
2094
|
+
await controller.sendMessage();
|
|
2095
|
+
|
|
2096
|
+
const prepareTurnCall = ((deps as any).mockAgentService.prepareTurn as jest.Mock).mock.calls[0];
|
|
2097
|
+
expect(prepareTurnCall[0].externalContextPaths).toEqual(externalPaths);
|
|
2098
|
+
});
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
describe('Editor context', () => {
|
|
2102
|
+
it('should append editorContext to prompt when available', async () => {
|
|
2103
|
+
const editorContext = {
|
|
2104
|
+
notePath: 'test/note.md',
|
|
2105
|
+
mode: 'selection' as const,
|
|
2106
|
+
selectedText: 'selected text content',
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
deps = createSendableDeps();
|
|
2110
|
+
(deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext);
|
|
2111
|
+
|
|
2112
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2113
|
+
createMockStream([{ type: 'done' }])
|
|
2114
|
+
);
|
|
2115
|
+
|
|
2116
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2117
|
+
inputEl.value = 'hello';
|
|
2118
|
+
controller = new InputController(deps);
|
|
2119
|
+
|
|
2120
|
+
await controller.sendMessage();
|
|
2121
|
+
|
|
2122
|
+
const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0];
|
|
2123
|
+
const promptSent = queryCall[0].prompt;
|
|
2124
|
+
expect(promptSent).toContain('selected text content');
|
|
2125
|
+
expect(promptSent).toContain('test/note.md');
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it('should preserve preview selection text without fabricating line attributes', async () => {
|
|
2129
|
+
const editorContext = {
|
|
2130
|
+
notePath: 'test/note.md',
|
|
2131
|
+
mode: 'selection' as const,
|
|
2132
|
+
selectedText: ' selected text\nsecond line ',
|
|
2133
|
+
lineCount: 2,
|
|
2134
|
+
};
|
|
2135
|
+
|
|
2136
|
+
deps = createSendableDeps();
|
|
2137
|
+
(deps.selectionController.getContext as jest.Mock).mockReturnValue(editorContext);
|
|
2138
|
+
|
|
2139
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2140
|
+
createMockStream([{ type: 'done' }])
|
|
2141
|
+
);
|
|
2142
|
+
|
|
2143
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2144
|
+
inputEl.value = 'hello';
|
|
2145
|
+
controller = new InputController(deps);
|
|
2146
|
+
|
|
2147
|
+
await controller.sendMessage();
|
|
2148
|
+
|
|
2149
|
+
const queryCall = ((deps as any).mockAgentService.query as jest.Mock).mock.calls[0];
|
|
2150
|
+
const promptSent = queryCall[0].prompt;
|
|
2151
|
+
expect(promptSent).toContain('<editor_selection path="test/note.md">\n selected text\nsecond line \n</editor_selection>');
|
|
2152
|
+
expect(promptSent).not.toContain('lines=');
|
|
2153
|
+
});
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
describe('Built-in commands - unknown', () => {
|
|
2157
|
+
beforeEach(() => {
|
|
2158
|
+
mockNotice.mockClear();
|
|
2159
|
+
});
|
|
2160
|
+
|
|
2161
|
+
it('should show Notice for unknown built-in command', async () => {
|
|
2162
|
+
// Directly call the private method since there's no public API to trigger unknown commands
|
|
2163
|
+
controller = new InputController(deps);
|
|
2164
|
+
|
|
2165
|
+
await (controller as any).executeBuiltInCommand({ action: 'nonexistent-command', name: 'nonexistent-command' }, '');
|
|
2166
|
+
|
|
2167
|
+
expect(mockNotice).toHaveBeenCalledWith('Unknown command: nonexistent-command');
|
|
2168
|
+
});
|
|
2169
|
+
});
|
|
2170
|
+
|
|
2171
|
+
describe('Title generation callback branches', () => {
|
|
2172
|
+
it('should rename conversation when title generation callback succeeds', async () => {
|
|
2173
|
+
const mockTitleService = {
|
|
2174
|
+
generateTitle: jest.fn().mockImplementation(
|
|
2175
|
+
async (convId: string, _user: string, callback: any) => {
|
|
2176
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2177
|
+
id: convId,
|
|
2178
|
+
title: 'Test Title',
|
|
2179
|
+
});
|
|
2180
|
+
await callback(convId, { success: true, title: 'AI Generated Title' });
|
|
2181
|
+
}
|
|
2182
|
+
),
|
|
2183
|
+
cancel: jest.fn(),
|
|
2184
|
+
};
|
|
2185
|
+
|
|
2186
|
+
deps = createSendableDeps({
|
|
2187
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
2188
|
+
});
|
|
2189
|
+
|
|
2190
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2191
|
+
createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }])
|
|
2192
|
+
);
|
|
2193
|
+
|
|
2194
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
2195
|
+
if (chunk.type === 'text') msg.content = chunk.content;
|
|
2196
|
+
});
|
|
2197
|
+
|
|
2198
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2199
|
+
inputEl.value = 'Hello world';
|
|
2200
|
+
controller = new InputController(deps);
|
|
2201
|
+
|
|
2202
|
+
await controller.sendMessage();
|
|
2203
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
2204
|
+
|
|
2205
|
+
expect(deps.plugin.renameConversation).toHaveBeenCalledWith('conv-1', 'AI Generated Title');
|
|
2206
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
2207
|
+
titleGenerationStatus: 'success',
|
|
2208
|
+
});
|
|
2209
|
+
});
|
|
2210
|
+
|
|
2211
|
+
it('should mark as failed when title generation callback fails', async () => {
|
|
2212
|
+
const mockTitleService = {
|
|
2213
|
+
generateTitle: jest.fn().mockImplementation(
|
|
2214
|
+
async (convId: string, _user: string, callback: any) => {
|
|
2215
|
+
(deps.plugin.getConversationById as jest.Mock).mockResolvedValue({
|
|
2216
|
+
id: convId,
|
|
2217
|
+
title: 'Test Title',
|
|
2218
|
+
});
|
|
2219
|
+
await callback(convId, { success: false, title: '' });
|
|
2220
|
+
}
|
|
2221
|
+
),
|
|
2222
|
+
cancel: jest.fn(),
|
|
2223
|
+
};
|
|
2224
|
+
|
|
2225
|
+
deps = createSendableDeps({
|
|
2226
|
+
getTitleGenerationService: () => mockTitleService as any,
|
|
2227
|
+
});
|
|
2228
|
+
|
|
2229
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2230
|
+
createMockStream([{ type: 'text', content: 'Response' }, { type: 'done' }])
|
|
2231
|
+
);
|
|
2232
|
+
|
|
2233
|
+
(deps.streamController.handleStreamChunk as jest.Mock).mockImplementation(async (chunk, msg) => {
|
|
2234
|
+
if (chunk.type === 'text') msg.content = chunk.content;
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2238
|
+
inputEl.value = 'Hello world';
|
|
2239
|
+
controller = new InputController(deps);
|
|
2240
|
+
|
|
2241
|
+
await controller.sendMessage();
|
|
2242
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
2243
|
+
|
|
2244
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', {
|
|
2245
|
+
titleGenerationStatus: 'failed',
|
|
2246
|
+
});
|
|
2247
|
+
});
|
|
2248
|
+
});
|
|
2249
|
+
|
|
2250
|
+
describe('handleApprovalRequest', () => {
|
|
2251
|
+
it('should create inline approval and store as pending', async () => {
|
|
2252
|
+
const parentEl = createMockEl();
|
|
2253
|
+
const inputContainerEl = createMockEl();
|
|
2254
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2255
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2256
|
+
|
|
2257
|
+
controller = new InputController(deps);
|
|
2258
|
+
|
|
2259
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2260
|
+
'bash',
|
|
2261
|
+
{ command: 'ls -la' },
|
|
2262
|
+
'Run shell command'
|
|
2263
|
+
);
|
|
2264
|
+
|
|
2265
|
+
expect((controller as any).pendingApprovalInline).not.toBeNull();
|
|
2266
|
+
|
|
2267
|
+
controller.dismissPendingApproval();
|
|
2268
|
+
expect((controller as any).pendingApprovalInline).toBeNull();
|
|
2269
|
+
|
|
2270
|
+
const result = await approvalPromise;
|
|
2271
|
+
expect(result).toBe('cancel');
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
it('should throw when input container has no parent', async () => {
|
|
2275
|
+
const inputContainerEl = createMockEl();
|
|
2276
|
+
// no parentElement set
|
|
2277
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2278
|
+
|
|
2279
|
+
controller = new InputController(deps);
|
|
2280
|
+
await expect(controller.handleApprovalRequest('bash', {}, 'test'))
|
|
2281
|
+
.rejects.toThrow('Input container is detached from DOM');
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it.each([
|
|
2285
|
+
['Deny', 'deny'],
|
|
2286
|
+
['Allow once', 'allow'],
|
|
2287
|
+
['Always allow', 'allow-always'],
|
|
2288
|
+
] as const)('should return "%s" → "%s"', async (optionLabel, expected) => {
|
|
2289
|
+
const parentEl = createMockEl();
|
|
2290
|
+
const inputContainerEl = createMockEl();
|
|
2291
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2292
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2293
|
+
|
|
2294
|
+
controller = new InputController(deps);
|
|
2295
|
+
|
|
2296
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2297
|
+
'bash',
|
|
2298
|
+
{ command: 'ls -la' },
|
|
2299
|
+
'Run shell command',
|
|
2300
|
+
);
|
|
2301
|
+
|
|
2302
|
+
const items = parentEl.querySelectorAll('claudian-ask-item');
|
|
2303
|
+
const target = items.find((item: any) => {
|
|
2304
|
+
const label = item.querySelector('claudian-ask-item-label');
|
|
2305
|
+
return label?.textContent === optionLabel;
|
|
2306
|
+
});
|
|
2307
|
+
expect(target).toBeDefined();
|
|
2308
|
+
target!.click();
|
|
2309
|
+
|
|
2310
|
+
const result = await approvalPromise;
|
|
2311
|
+
expect(result).toBe(expected);
|
|
2312
|
+
});
|
|
2313
|
+
|
|
2314
|
+
it('should render header metadata when approvalOptions provided', async () => {
|
|
2315
|
+
const parentEl = createMockEl();
|
|
2316
|
+
const inputContainerEl = createMockEl();
|
|
2317
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2318
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2319
|
+
|
|
2320
|
+
controller = new InputController(deps);
|
|
2321
|
+
|
|
2322
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2323
|
+
'bash',
|
|
2324
|
+
{ command: 'rm -rf /' },
|
|
2325
|
+
'Run dangerous command',
|
|
2326
|
+
{
|
|
2327
|
+
decisionReason: 'Command is destructive',
|
|
2328
|
+
blockedPath: '/usr/bin/rm',
|
|
2329
|
+
agentID: 'agent-42',
|
|
2330
|
+
},
|
|
2331
|
+
);
|
|
2332
|
+
|
|
2333
|
+
const reasonEl = parentEl.querySelector('claudian-ask-approval-reason');
|
|
2334
|
+
expect(reasonEl?.textContent).toBe('Command is destructive');
|
|
2335
|
+
|
|
2336
|
+
const pathEl = parentEl.querySelector('claudian-ask-approval-blocked-path');
|
|
2337
|
+
expect(pathEl?.textContent).toBe('/usr/bin/rm');
|
|
2338
|
+
|
|
2339
|
+
const agentEl = parentEl.querySelector('claudian-ask-approval-agent');
|
|
2340
|
+
expect(agentEl?.textContent).toBe('Agent: agent-42');
|
|
2341
|
+
|
|
2342
|
+
controller.dismissPendingApproval();
|
|
2343
|
+
await approvalPromise;
|
|
2344
|
+
});
|
|
2345
|
+
|
|
2346
|
+
it('should render provider-supplied approval options and network-specific context', async () => {
|
|
2347
|
+
const parentEl = createMockEl();
|
|
2348
|
+
const inputContainerEl = createMockEl();
|
|
2349
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2350
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2351
|
+
|
|
2352
|
+
controller = new InputController(deps);
|
|
2353
|
+
|
|
2354
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2355
|
+
'Bash',
|
|
2356
|
+
{ command: 'curl https://api.openai.com' },
|
|
2357
|
+
'Allow https access to api.openai.com',
|
|
2358
|
+
{
|
|
2359
|
+
networkApprovalContext: { host: 'api.openai.com', protocol: 'https' },
|
|
2360
|
+
decisionOptions: [
|
|
2361
|
+
{ label: 'Allow once', decision: 'allow' },
|
|
2362
|
+
{
|
|
2363
|
+
label: 'Allow similar commands',
|
|
2364
|
+
description: 'Approve and store an exec policy amendment.',
|
|
2365
|
+
decision: {
|
|
2366
|
+
type: 'allow-with-exec-policy-amendment',
|
|
2367
|
+
execPolicyAmendment: ['curl', 'https://api.openai.com/*'],
|
|
2368
|
+
},
|
|
2369
|
+
},
|
|
2370
|
+
{ label: 'Deny', decision: 'deny' },
|
|
2371
|
+
],
|
|
2372
|
+
} as any,
|
|
2373
|
+
);
|
|
2374
|
+
|
|
2375
|
+
const descEl = parentEl.querySelector('claudian-ask-approval-desc');
|
|
2376
|
+
expect(descEl?.textContent).toContain('api.openai.com');
|
|
2377
|
+
|
|
2378
|
+
const items = parentEl.querySelectorAll('claudian-ask-item');
|
|
2379
|
+
const labels = items
|
|
2380
|
+
.map((item: any) => item.querySelector('claudian-ask-item-label')?.textContent)
|
|
2381
|
+
.filter(Boolean);
|
|
2382
|
+
expect(labels).toEqual(expect.arrayContaining([
|
|
2383
|
+
'Allow once',
|
|
2384
|
+
'Allow similar commands',
|
|
2385
|
+
'Deny',
|
|
2386
|
+
]));
|
|
2387
|
+
|
|
2388
|
+
controller.dismissPendingApproval();
|
|
2389
|
+
await approvalPromise;
|
|
2390
|
+
});
|
|
2391
|
+
|
|
2392
|
+
it.each([
|
|
2393
|
+
['Allow once', 'approval-allow-once', 'allow'],
|
|
2394
|
+
['Always allow', 'approval-allow-always', 'allow-always'],
|
|
2395
|
+
['Reject', 'approval-reject', { type: 'select-option', value: 'approval-reject' }],
|
|
2396
|
+
] as const)(
|
|
2397
|
+
'preserves provider option values for "%s"',
|
|
2398
|
+
async (optionLabel, optionValue, expectedDecision) => {
|
|
2399
|
+
const parentEl = createMockEl();
|
|
2400
|
+
const inputContainerEl = createMockEl();
|
|
2401
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2402
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2403
|
+
|
|
2404
|
+
controller = new InputController(deps);
|
|
2405
|
+
|
|
2406
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2407
|
+
'External Directory',
|
|
2408
|
+
{ filepath: '/tmp/outside' },
|
|
2409
|
+
'OpenCode wants to access a path outside the working directory.',
|
|
2410
|
+
{
|
|
2411
|
+
decisionOptions: [
|
|
2412
|
+
{ label: 'Allow once', value: 'approval-allow-once', decision: 'allow' },
|
|
2413
|
+
{ label: 'Always allow', value: 'approval-allow-always', decision: 'allow-always' },
|
|
2414
|
+
{ label: 'Reject', value: 'approval-reject' },
|
|
2415
|
+
],
|
|
2416
|
+
},
|
|
2417
|
+
);
|
|
2418
|
+
|
|
2419
|
+
const items = parentEl.querySelectorAll('claudian-ask-item');
|
|
2420
|
+
const target = items.find((item: any) => {
|
|
2421
|
+
const label = item.querySelector('claudian-ask-item-label');
|
|
2422
|
+
return label?.textContent === optionLabel;
|
|
2423
|
+
});
|
|
2424
|
+
expect(target).toBeDefined();
|
|
2425
|
+
target!.click();
|
|
2426
|
+
|
|
2427
|
+
await expect(approvalPromise).resolves.toEqual(expectedDecision);
|
|
2428
|
+
},
|
|
2429
|
+
);
|
|
2430
|
+
|
|
2431
|
+
it('should return provider-specific amendment decisions from supplied approval options', async () => {
|
|
2432
|
+
const parentEl = createMockEl();
|
|
2433
|
+
const inputContainerEl = createMockEl();
|
|
2434
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2435
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2436
|
+
|
|
2437
|
+
controller = new InputController(deps);
|
|
2438
|
+
|
|
2439
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2440
|
+
'Bash',
|
|
2441
|
+
{ command: 'npm test' },
|
|
2442
|
+
'Run test command',
|
|
2443
|
+
{
|
|
2444
|
+
decisionOptions: [
|
|
2445
|
+
{
|
|
2446
|
+
label: 'Allow similar commands',
|
|
2447
|
+
decision: {
|
|
2448
|
+
type: 'allow-with-exec-policy-amendment',
|
|
2449
|
+
execPolicyAmendment: ['npm', 'test'],
|
|
2450
|
+
},
|
|
2451
|
+
},
|
|
2452
|
+
{ label: 'Deny', decision: 'deny' },
|
|
2453
|
+
],
|
|
2454
|
+
} as any,
|
|
2455
|
+
);
|
|
2456
|
+
|
|
2457
|
+
const items = parentEl.querySelectorAll('claudian-ask-item');
|
|
2458
|
+
const target = items.find((item: any) => {
|
|
2459
|
+
const label = item.querySelector('claudian-ask-item-label');
|
|
2460
|
+
return label?.textContent === 'Allow similar commands';
|
|
2461
|
+
});
|
|
2462
|
+
expect(target).toBeDefined();
|
|
2463
|
+
target!.click();
|
|
2464
|
+
|
|
2465
|
+
await expect(approvalPromise).resolves.toEqual({
|
|
2466
|
+
type: 'allow-with-exec-policy-amendment',
|
|
2467
|
+
execPolicyAmendment: ['npm', 'test'],
|
|
2468
|
+
});
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
it('should restore input visibility after overlapping inline prompts are dismissed', async () => {
|
|
2472
|
+
const parentEl = createMockEl();
|
|
2473
|
+
const inputContainerEl = createMockEl();
|
|
2474
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2475
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2476
|
+
|
|
2477
|
+
controller = new InputController(deps);
|
|
2478
|
+
|
|
2479
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2480
|
+
'bash',
|
|
2481
|
+
{ command: 'ls -la' },
|
|
2482
|
+
'Run shell command',
|
|
2483
|
+
);
|
|
2484
|
+
const askPromise = controller.handleAskUserQuestion({
|
|
2485
|
+
questions: [
|
|
2486
|
+
{
|
|
2487
|
+
question: 'Select one option',
|
|
2488
|
+
options: ['Option A', 'Option B'],
|
|
2489
|
+
},
|
|
2490
|
+
],
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
expect(inputContainerEl.style.display).toBe('none');
|
|
2494
|
+
|
|
2495
|
+
controller.dismissPendingApproval();
|
|
2496
|
+
|
|
2497
|
+
await expect(approvalPromise).resolves.toBe('cancel');
|
|
2498
|
+
await expect(askPromise).resolves.toBeNull();
|
|
2499
|
+
expect(inputContainerEl.style.display).toBe('');
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
it('should keep input hidden until overlapping exit-plan prompt is dismissed', async () => {
|
|
2503
|
+
const parentEl = createMockEl();
|
|
2504
|
+
const inputContainerEl = createMockEl();
|
|
2505
|
+
(inputContainerEl as any).parentElement = parentEl;
|
|
2506
|
+
deps.getInputContainerEl = () => inputContainerEl as any;
|
|
2507
|
+
|
|
2508
|
+
controller = new InputController(deps);
|
|
2509
|
+
|
|
2510
|
+
const approvalPromise = controller.handleApprovalRequest(
|
|
2511
|
+
'bash',
|
|
2512
|
+
{ command: 'ls -la' },
|
|
2513
|
+
'Run shell command',
|
|
2514
|
+
);
|
|
2515
|
+
const exitPlanPromise = controller.handleExitPlanMode({});
|
|
2516
|
+
|
|
2517
|
+
expect(inputContainerEl.style.display).toBe('none');
|
|
2518
|
+
|
|
2519
|
+
const items = parentEl.querySelectorAll('claudian-ask-item');
|
|
2520
|
+
const allowOnceItem = items.find((item: any) => {
|
|
2521
|
+
const label = item.querySelector('claudian-ask-item-label');
|
|
2522
|
+
return label?.textContent === 'Allow once';
|
|
2523
|
+
});
|
|
2524
|
+
expect(allowOnceItem).toBeDefined();
|
|
2525
|
+
|
|
2526
|
+
allowOnceItem!.click();
|
|
2527
|
+
await expect(approvalPromise).resolves.toBe('allow');
|
|
2528
|
+
expect(inputContainerEl.style.display).toBe('none');
|
|
2529
|
+
|
|
2530
|
+
controller.dismissPendingApproval();
|
|
2531
|
+
await expect(exitPlanPromise).resolves.toBeNull();
|
|
2532
|
+
expect(inputContainerEl.style.display).toBe('');
|
|
2533
|
+
});
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
describe('handleInstructionSubmit', () => {
|
|
2537
|
+
it('should create InstructionModal and call refineInstruction', async () => {
|
|
2538
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2539
|
+
refineInstruction: jest.fn().mockResolvedValue({
|
|
2540
|
+
success: true,
|
|
2541
|
+
refinedInstruction: 'refined instruction',
|
|
2542
|
+
}),
|
|
2543
|
+
});
|
|
2544
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2545
|
+
|
|
2546
|
+
deps = createMockDeps({
|
|
2547
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2548
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2549
|
+
});
|
|
2550
|
+
deps.plugin.settings.systemPrompt = '';
|
|
2551
|
+
|
|
2552
|
+
controller = new InputController(deps);
|
|
2553
|
+
|
|
2554
|
+
await controller.handleInstructionSubmit('add logging');
|
|
2555
|
+
|
|
2556
|
+
expect(mockInstructionRefineService.resetConversation).toHaveBeenCalled();
|
|
2557
|
+
expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith(
|
|
2558
|
+
'add logging',
|
|
2559
|
+
''
|
|
2560
|
+
);
|
|
2561
|
+
});
|
|
2562
|
+
|
|
2563
|
+
it('should pass the active chat model into instruction refine service', async () => {
|
|
2564
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2565
|
+
refineInstruction: jest.fn().mockResolvedValue({
|
|
2566
|
+
success: true,
|
|
2567
|
+
refinedInstruction: 'refined instruction',
|
|
2568
|
+
}),
|
|
2569
|
+
});
|
|
2570
|
+
|
|
2571
|
+
deps = createMockDeps({
|
|
2572
|
+
getAuxiliaryModel: () => 'opencode:openai/gpt-5.4',
|
|
2573
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2574
|
+
});
|
|
2575
|
+
deps.plugin.settings.systemPrompt = '';
|
|
2576
|
+
|
|
2577
|
+
controller = new InputController(deps);
|
|
2578
|
+
|
|
2579
|
+
await controller.handleInstructionSubmit('add logging');
|
|
2580
|
+
|
|
2581
|
+
expect(mockInstructionRefineService.setModelOverride).toHaveBeenCalledWith(
|
|
2582
|
+
'opencode:openai/gpt-5.4',
|
|
2583
|
+
);
|
|
2584
|
+
});
|
|
2585
|
+
|
|
2586
|
+
it('should return early when instructionRefineService is null', async () => {
|
|
2587
|
+
deps = createMockDeps({
|
|
2588
|
+
getInstructionRefineService: () => null,
|
|
2589
|
+
});
|
|
2590
|
+
controller = new InputController(deps);
|
|
2591
|
+
|
|
2592
|
+
await expect(controller.handleInstructionSubmit('test')).resolves.not.toThrow();
|
|
2593
|
+
});
|
|
2594
|
+
});
|
|
2595
|
+
|
|
2596
|
+
describe('processQueuedMessage sends the queued snapshot', () => {
|
|
2597
|
+
it('should send images from the queued message without rebuilding composer state', () => {
|
|
2598
|
+
jest.useFakeTimers();
|
|
2599
|
+
try {
|
|
2600
|
+
const mockImages = [{ id: 'img1', name: 'test.png' }];
|
|
2601
|
+
deps.state.queuedMessage = {
|
|
2602
|
+
content: 'queued content',
|
|
2603
|
+
images: mockImages as any,
|
|
2604
|
+
editorContext: null,
|
|
2605
|
+
canvasContext: null,
|
|
2606
|
+
};
|
|
2607
|
+
const sendSpy = jest.spyOn(controller, 'sendMessage').mockResolvedValue(undefined);
|
|
2608
|
+
|
|
2609
|
+
(controller as any).processQueuedMessage();
|
|
2610
|
+
jest.runAllTimers();
|
|
2611
|
+
|
|
2612
|
+
expect(sendSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
2613
|
+
content: 'queued content',
|
|
2614
|
+
images: mockImages,
|
|
2615
|
+
turnRequestOverride: expect.objectContaining({
|
|
2616
|
+
text: 'queued content',
|
|
2617
|
+
images: mockImages,
|
|
2618
|
+
}),
|
|
2619
|
+
}));
|
|
2620
|
+
sendSpy.mockRestore();
|
|
2621
|
+
} finally {
|
|
2622
|
+
jest.useRealTimers();
|
|
2623
|
+
}
|
|
2624
|
+
});
|
|
2625
|
+
});
|
|
2626
|
+
|
|
2627
|
+
describe('Sending messages - edge cases', () => {
|
|
2628
|
+
it('should not send empty message without images', async () => {
|
|
2629
|
+
inputEl.value = '';
|
|
2630
|
+
const imageContextManager = deps.getImageContextManager()!;
|
|
2631
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(false);
|
|
2632
|
+
|
|
2633
|
+
await controller.sendMessage();
|
|
2634
|
+
|
|
2635
|
+
expect((deps as any).mockAgentService.query).not.toHaveBeenCalled();
|
|
2636
|
+
});
|
|
2637
|
+
|
|
2638
|
+
it('should send message with only images (empty text)', async () => {
|
|
2639
|
+
const imageContextManager = createMockImageContextManager();
|
|
2640
|
+
(imageContextManager.hasImages as jest.Mock).mockReturnValue(true);
|
|
2641
|
+
(imageContextManager.getAttachedImages as jest.Mock).mockReturnValue([{ id: 'img1', name: 'test.png' }]);
|
|
2642
|
+
|
|
2643
|
+
deps = createSendableDeps({
|
|
2644
|
+
getImageContextManager: () => imageContextManager as any,
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
((deps as any).mockAgentService.query as jest.Mock).mockReturnValue(
|
|
2648
|
+
createMockStream([{ type: 'done' }])
|
|
2649
|
+
);
|
|
2650
|
+
|
|
2651
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2652
|
+
inputEl.value = '';
|
|
2653
|
+
controller = new InputController(deps);
|
|
2654
|
+
|
|
2655
|
+
await controller.sendMessage();
|
|
2656
|
+
|
|
2657
|
+
expect((deps as any).mockAgentService.query).toHaveBeenCalled();
|
|
2658
|
+
expect(deps.state.messages).toHaveLength(2);
|
|
2659
|
+
expect(deps.state.messages[0].images).toHaveLength(1);
|
|
2660
|
+
});
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
describe('Stream invalidation', () => {
|
|
2664
|
+
it('should break from stream loop and skip cleanup when stream generation changes', async () => {
|
|
2665
|
+
deps = createSendableDeps();
|
|
2666
|
+
|
|
2667
|
+
((deps as any).mockAgentService.query as jest.Mock).mockImplementation(() => {
|
|
2668
|
+
return (async function* () {
|
|
2669
|
+
yield { type: 'text', content: 'partial' };
|
|
2670
|
+
// Simulate stream invalidation (e.g. tab closed during stream)
|
|
2671
|
+
deps.state.bumpStreamGeneration();
|
|
2672
|
+
yield { type: 'text', content: 'should not be processed' };
|
|
2673
|
+
})();
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2677
|
+
inputEl.value = 'test message';
|
|
2678
|
+
controller = new InputController(deps);
|
|
2679
|
+
|
|
2680
|
+
await controller.sendMessage();
|
|
2681
|
+
|
|
2682
|
+
// The stream was invalidated, so isStreaming should still be true
|
|
2683
|
+
// (cleanup was skipped) and no interrupt text should appear
|
|
2684
|
+
expect(deps.streamController.appendText).not.toHaveBeenCalledWith(
|
|
2685
|
+
expect.stringContaining('Interrupted')
|
|
2686
|
+
);
|
|
2687
|
+
});
|
|
2688
|
+
});
|
|
2689
|
+
|
|
2690
|
+
describe('handleInstructionSubmit - advanced paths', () => {
|
|
2691
|
+
it('should show clarification when result has clarification', async () => {
|
|
2692
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2693
|
+
refineInstruction: jest.fn().mockResolvedValue({
|
|
2694
|
+
success: true,
|
|
2695
|
+
clarification: 'Please clarify what you mean',
|
|
2696
|
+
}),
|
|
2697
|
+
});
|
|
2698
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2699
|
+
|
|
2700
|
+
deps = createMockDeps({
|
|
2701
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2702
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2703
|
+
});
|
|
2704
|
+
controller = new InputController(deps);
|
|
2705
|
+
|
|
2706
|
+
await controller.handleInstructionSubmit('ambiguous instruction');
|
|
2707
|
+
|
|
2708
|
+
expect(mockInstructionRefineService.refineInstruction).toHaveBeenCalledWith(
|
|
2709
|
+
'ambiguous instruction',
|
|
2710
|
+
undefined
|
|
2711
|
+
);
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
it('should show error when result has no clarification or instruction', async () => {
|
|
2715
|
+
const mockInstructionRefineService = createMockInstructionRefineService();
|
|
2716
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2717
|
+
|
|
2718
|
+
deps = createMockDeps({
|
|
2719
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2720
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2721
|
+
});
|
|
2722
|
+
controller = new InputController(deps);
|
|
2723
|
+
mockNotice.mockClear();
|
|
2724
|
+
|
|
2725
|
+
await controller.handleInstructionSubmit('empty result');
|
|
2726
|
+
|
|
2727
|
+
expect(mockNotice).toHaveBeenCalledWith('No instruction received');
|
|
2728
|
+
expect(mockInstructionModeManager.clear).toHaveBeenCalled();
|
|
2729
|
+
});
|
|
2730
|
+
|
|
2731
|
+
it('should handle cancelled result from refineInstruction', async () => {
|
|
2732
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2733
|
+
refineInstruction: jest.fn().mockResolvedValue({
|
|
2734
|
+
success: false,
|
|
2735
|
+
error: 'Cancelled',
|
|
2736
|
+
}),
|
|
2737
|
+
});
|
|
2738
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2739
|
+
|
|
2740
|
+
deps = createMockDeps({
|
|
2741
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2742
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2743
|
+
});
|
|
2744
|
+
controller = new InputController(deps);
|
|
2745
|
+
|
|
2746
|
+
await controller.handleInstructionSubmit('cancelled instruction');
|
|
2747
|
+
|
|
2748
|
+
expect(mockInstructionModeManager.clear).toHaveBeenCalled();
|
|
2749
|
+
expect(mockNotice).not.toHaveBeenCalledWith(expect.stringContaining('Cancelled'));
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
it('should handle non-cancelled error from refineInstruction', async () => {
|
|
2753
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2754
|
+
refineInstruction: jest.fn().mockResolvedValue({
|
|
2755
|
+
success: false,
|
|
2756
|
+
error: 'API Error',
|
|
2757
|
+
}),
|
|
2758
|
+
});
|
|
2759
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2760
|
+
|
|
2761
|
+
deps = createMockDeps({
|
|
2762
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2763
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2764
|
+
});
|
|
2765
|
+
controller = new InputController(deps);
|
|
2766
|
+
mockNotice.mockClear();
|
|
2767
|
+
|
|
2768
|
+
await controller.handleInstructionSubmit('error instruction');
|
|
2769
|
+
|
|
2770
|
+
expect(mockNotice).toHaveBeenCalledWith('API Error');
|
|
2771
|
+
expect(mockInstructionModeManager.clear).toHaveBeenCalled();
|
|
2772
|
+
});
|
|
2773
|
+
|
|
2774
|
+
it('should handle exception thrown during refineInstruction', async () => {
|
|
2775
|
+
const mockInstructionRefineService = createMockInstructionRefineService({
|
|
2776
|
+
refineInstruction: jest.fn().mockRejectedValue(new Error('Unexpected error')),
|
|
2777
|
+
});
|
|
2778
|
+
const mockInstructionModeManager = createMockInstructionModeManager();
|
|
2779
|
+
|
|
2780
|
+
deps = createMockDeps({
|
|
2781
|
+
getInstructionRefineService: () => mockInstructionRefineService as any,
|
|
2782
|
+
getInstructionModeManager: () => mockInstructionModeManager as any,
|
|
2783
|
+
});
|
|
2784
|
+
controller = new InputController(deps);
|
|
2785
|
+
mockNotice.mockClear();
|
|
2786
|
+
|
|
2787
|
+
await controller.handleInstructionSubmit('error instruction');
|
|
2788
|
+
|
|
2789
|
+
expect(mockNotice).toHaveBeenCalledWith('Error: Unexpected error');
|
|
2790
|
+
expect(mockInstructionModeManager.clear).toHaveBeenCalled();
|
|
2791
|
+
});
|
|
2792
|
+
});
|
|
2793
|
+
|
|
2794
|
+
describe('resumeAtMessageId lifecycle', () => {
|
|
2795
|
+
beforeEach(() => {
|
|
2796
|
+
mockNotice.mockClear();
|
|
2797
|
+
});
|
|
2798
|
+
|
|
2799
|
+
it('should call setResumeCheckpoint when resumeAtMessageId points to last assistant (still-needed)', async () => {
|
|
2800
|
+
deps = createSendableDeps();
|
|
2801
|
+
const { mockAgentService } = deps as any;
|
|
2802
|
+
mockAgentService.setResumeCheckpoint = jest.fn();
|
|
2803
|
+
mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }]));
|
|
2804
|
+
|
|
2805
|
+
// Pre-populate messages: user → assistant (with assistantMessageId matching resumeAtMessageId)
|
|
2806
|
+
deps.state.messages = [
|
|
2807
|
+
{ id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' },
|
|
2808
|
+
{ id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' },
|
|
2809
|
+
];
|
|
2810
|
+
|
|
2811
|
+
// Set conversation with resumeAtMessageId
|
|
2812
|
+
(deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({
|
|
2813
|
+
id: 'conv-1',
|
|
2814
|
+
resumeAtMessageId: 'a1',
|
|
2815
|
+
});
|
|
2816
|
+
|
|
2817
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2818
|
+
inputEl.value = 'follow up';
|
|
2819
|
+
controller = new InputController(deps);
|
|
2820
|
+
|
|
2821
|
+
await controller.sendMessage();
|
|
2822
|
+
|
|
2823
|
+
expect(mockAgentService.setResumeCheckpoint).toHaveBeenCalledWith('a1');
|
|
2824
|
+
// Should NOT clear metadata eagerly (clearing is done by save(true))
|
|
2825
|
+
expect(deps.plugin.updateConversation).not.toHaveBeenCalledWith('conv-1', { resumeAtMessageId: undefined });
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
it('should NOT call setResumeCheckpoint when follow-up already exists (stale)', async () => {
|
|
2829
|
+
deps = createSendableDeps();
|
|
2830
|
+
const { mockAgentService } = deps as any;
|
|
2831
|
+
mockAgentService.setResumeCheckpoint = jest.fn();
|
|
2832
|
+
mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }]));
|
|
2833
|
+
|
|
2834
|
+
// Messages: user → assistant(a1) → user(follow-up) → assistant
|
|
2835
|
+
// resumeAtMessageId=a1 is stale because there's a follow-up after a1
|
|
2836
|
+
deps.state.messages = [
|
|
2837
|
+
{ id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' },
|
|
2838
|
+
{ id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' },
|
|
2839
|
+
{ id: 'msg-u2', role: 'user', content: 'follow up', timestamp: 3, userMessageId: 'u2' },
|
|
2840
|
+
{ id: 'msg-a2', role: 'assistant', content: 'response', timestamp: 4, assistantMessageId: 'a2' },
|
|
2841
|
+
];
|
|
2842
|
+
|
|
2843
|
+
(deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({
|
|
2844
|
+
id: 'conv-1',
|
|
2845
|
+
resumeAtMessageId: 'a1',
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2848
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2849
|
+
inputEl.value = 'another message';
|
|
2850
|
+
controller = new InputController(deps);
|
|
2851
|
+
|
|
2852
|
+
await controller.sendMessage();
|
|
2853
|
+
|
|
2854
|
+
expect(mockAgentService.setResumeCheckpoint).not.toHaveBeenCalled();
|
|
2855
|
+
// Should clear stale metadata
|
|
2856
|
+
expect(deps.plugin.updateConversation).toHaveBeenCalledWith('conv-1', { resumeAtMessageId: undefined });
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
it('should clear resumeAtMessageId on save when turn metadata reports the message was sent', async () => {
|
|
2860
|
+
deps = createSendableDeps();
|
|
2861
|
+
const { mockAgentService } = deps as any;
|
|
2862
|
+
mockAgentService.setResumeCheckpoint = jest.fn();
|
|
2863
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ wasSent: true });
|
|
2864
|
+
mockAgentService.query = jest.fn().mockReturnValue(
|
|
2865
|
+
createMockStream([
|
|
2866
|
+
{ type: 'text', content: 'hi' },
|
|
2867
|
+
{ type: 'done' },
|
|
2868
|
+
])
|
|
2869
|
+
);
|
|
2870
|
+
|
|
2871
|
+
deps.state.messages = [
|
|
2872
|
+
{ id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' },
|
|
2873
|
+
{ id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' },
|
|
2874
|
+
];
|
|
2875
|
+
|
|
2876
|
+
(deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({
|
|
2877
|
+
id: 'conv-1',
|
|
2878
|
+
resumeAtMessageId: 'a1',
|
|
2879
|
+
});
|
|
2880
|
+
|
|
2881
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2882
|
+
inputEl.value = 'follow up';
|
|
2883
|
+
controller = new InputController(deps);
|
|
2884
|
+
|
|
2885
|
+
await controller.sendMessage();
|
|
2886
|
+
|
|
2887
|
+
// save(true) should include { resumeAtMessageId: undefined } because the turn metadata reports a sent message
|
|
2888
|
+
expect(deps.conversationController.save).toHaveBeenCalledWith(true, { resumeAtMessageId: undefined });
|
|
2889
|
+
});
|
|
2890
|
+
|
|
2891
|
+
it('should NOT clear resumeAtMessageId on save when query fails before enqueue', async () => {
|
|
2892
|
+
deps = createSendableDeps();
|
|
2893
|
+
const { mockAgentService } = deps as any;
|
|
2894
|
+
mockAgentService.setResumeCheckpoint = jest.fn();
|
|
2895
|
+
// Stream throws before yielding user_message_sent
|
|
2896
|
+
mockAgentService.query = jest.fn().mockImplementation(() => {
|
|
2897
|
+
throw new Error('Connection failed');
|
|
2898
|
+
});
|
|
2899
|
+
|
|
2900
|
+
deps.state.messages = [
|
|
2901
|
+
{ id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' },
|
|
2902
|
+
{ id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' },
|
|
2903
|
+
];
|
|
2904
|
+
|
|
2905
|
+
(deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({
|
|
2906
|
+
id: 'conv-1',
|
|
2907
|
+
resumeAtMessageId: 'a1',
|
|
2908
|
+
});
|
|
2909
|
+
|
|
2910
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2911
|
+
inputEl.value = 'follow up';
|
|
2912
|
+
controller = new InputController(deps);
|
|
2913
|
+
|
|
2914
|
+
await controller.sendMessage();
|
|
2915
|
+
|
|
2916
|
+
// save(true) should NOT clear resumeAtMessageId because user_message_sent was never received
|
|
2917
|
+
expect(deps.conversationController.save).toHaveBeenCalledWith(true, undefined);
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
it('should not block send when stale metadata clear fails', async () => {
|
|
2921
|
+
deps = createSendableDeps();
|
|
2922
|
+
const { mockAgentService } = deps as any;
|
|
2923
|
+
mockAgentService.setResumeCheckpoint = jest.fn();
|
|
2924
|
+
mockAgentService.query = jest.fn().mockReturnValue(createMockStream([{ type: 'done' }]));
|
|
2925
|
+
|
|
2926
|
+
deps.state.messages = [
|
|
2927
|
+
{ id: 'msg-u1', role: 'user', content: 'hello', timestamp: 1, userMessageId: 'u1' },
|
|
2928
|
+
{ id: 'msg-a1', role: 'assistant', content: 'hi', timestamp: 2, assistantMessageId: 'a1' },
|
|
2929
|
+
{ id: 'msg-u2', role: 'user', content: 'next', timestamp: 3, userMessageId: 'u2' },
|
|
2930
|
+
{ id: 'msg-a2', role: 'assistant', content: 'resp', timestamp: 4, assistantMessageId: 'a2' },
|
|
2931
|
+
];
|
|
2932
|
+
|
|
2933
|
+
(deps.plugin.getConversationSync as any) = jest.fn().mockReturnValue({
|
|
2934
|
+
id: 'conv-1',
|
|
2935
|
+
resumeAtMessageId: 'a1',
|
|
2936
|
+
});
|
|
2937
|
+
// Make updateConversation throw
|
|
2938
|
+
(deps.plugin.updateConversation as jest.Mock).mockRejectedValueOnce(new Error('disk error'));
|
|
2939
|
+
|
|
2940
|
+
inputEl = deps.getInputEl() as ReturnType<typeof createMockInputEl>;
|
|
2941
|
+
inputEl.value = 'test';
|
|
2942
|
+
controller = new InputController(deps);
|
|
2943
|
+
|
|
2944
|
+
// Should not throw
|
|
2945
|
+
await expect(controller.sendMessage()).resolves.not.toThrow();
|
|
2946
|
+
expect(mockAgentService.query).toHaveBeenCalled();
|
|
2947
|
+
});
|
|
2948
|
+
});
|
|
2949
|
+
|
|
2950
|
+
describe('Codex plan_completed flow', () => {
|
|
2951
|
+
it('opens the Codex approval UI after a successful plan turn', async () => {
|
|
2952
|
+
const deps = createSendableDeps({
|
|
2953
|
+
restorePrePlanPermissionModeIfNeeded: jest.fn(),
|
|
2954
|
+
});
|
|
2955
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
2956
|
+
mockAgentService.providerId = 'codex';
|
|
2957
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
2958
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
2959
|
+
createMockStream([
|
|
2960
|
+
{ type: 'text', content: 'Here is my plan...' },
|
|
2961
|
+
{ type: 'done' },
|
|
2962
|
+
]),
|
|
2963
|
+
);
|
|
2964
|
+
const inputEl = deps.getInputEl();
|
|
2965
|
+
inputEl.value = 'Plan the migration';
|
|
2966
|
+
const controller = new InputController(deps);
|
|
2967
|
+
const showPlanApproval = jest.spyOn(controller as any, 'showPlanApproval').mockResolvedValue({
|
|
2968
|
+
decision: null,
|
|
2969
|
+
invalidated: false,
|
|
2970
|
+
});
|
|
2971
|
+
|
|
2972
|
+
await controller.sendMessage();
|
|
2973
|
+
|
|
2974
|
+
expect(showPlanApproval).toHaveBeenCalled();
|
|
2975
|
+
});
|
|
2976
|
+
|
|
2977
|
+
it('implement restores mode and auto-sends follow-up', async () => {
|
|
2978
|
+
const restoreFn = jest.fn();
|
|
2979
|
+
const deps = createSendableDeps({
|
|
2980
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
2981
|
+
});
|
|
2982
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
2983
|
+
mockAgentService.providerId = 'codex';
|
|
2984
|
+
mockAgentService.consumeTurnMetadata = jest.fn()
|
|
2985
|
+
.mockReturnValueOnce({ planCompleted: true, wasSent: true })
|
|
2986
|
+
.mockReturnValueOnce({ wasSent: true });
|
|
2987
|
+
|
|
2988
|
+
let callCount = 0;
|
|
2989
|
+
mockAgentService.query = jest.fn().mockImplementation(() => {
|
|
2990
|
+
callCount++;
|
|
2991
|
+
if (callCount === 1) {
|
|
2992
|
+
return createMockStream([
|
|
2993
|
+
{ type: 'text', content: 'Plan content' },
|
|
2994
|
+
{ type: 'done' },
|
|
2995
|
+
]);
|
|
2996
|
+
}
|
|
2997
|
+
return createMockStream([{ type: 'done' }]);
|
|
2998
|
+
});
|
|
2999
|
+
|
|
3000
|
+
const controller = new InputController(deps);
|
|
3001
|
+
|
|
3002
|
+
// Mock the showPlanApproval to return 'implement'
|
|
3003
|
+
(controller as any).showPlanApproval = jest.fn().mockResolvedValue({
|
|
3004
|
+
decision: { type: 'implement' },
|
|
3005
|
+
invalidated: false,
|
|
3006
|
+
});
|
|
3007
|
+
|
|
3008
|
+
const inputEl = deps.getInputEl();
|
|
3009
|
+
inputEl.value = 'Plan this feature';
|
|
3010
|
+
await controller.sendMessage();
|
|
3011
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
3012
|
+
|
|
3013
|
+
expect(restoreFn).toHaveBeenCalled();
|
|
3014
|
+
// Auto-send should have been triggered
|
|
3015
|
+
expect(mockAgentService.query).toHaveBeenCalledTimes(2);
|
|
3016
|
+
});
|
|
3017
|
+
|
|
3018
|
+
it('revise keeps plan mode active and populates input', async () => {
|
|
3019
|
+
const restoreFn = jest.fn();
|
|
3020
|
+
const deps = createSendableDeps({
|
|
3021
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
3022
|
+
});
|
|
3023
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
3024
|
+
mockAgentService.providerId = 'codex';
|
|
3025
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
3026
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
3027
|
+
createMockStream([
|
|
3028
|
+
{ type: 'text', content: 'Plan content' },
|
|
3029
|
+
{ type: 'done' },
|
|
3030
|
+
]),
|
|
3031
|
+
);
|
|
3032
|
+
|
|
3033
|
+
const controller = new InputController(deps);
|
|
3034
|
+
(controller as any).showPlanApproval = jest.fn().mockResolvedValue({
|
|
3035
|
+
decision: {
|
|
3036
|
+
type: 'revise',
|
|
3037
|
+
text: 'Add more tests',
|
|
3038
|
+
},
|
|
3039
|
+
invalidated: false,
|
|
3040
|
+
});
|
|
3041
|
+
|
|
3042
|
+
const inputEl = deps.getInputEl();
|
|
3043
|
+
inputEl.value = 'Plan this';
|
|
3044
|
+
await controller.sendMessage();
|
|
3045
|
+
|
|
3046
|
+
expect(restoreFn).not.toHaveBeenCalled();
|
|
3047
|
+
expect(inputEl.value).toBe('Add more tests');
|
|
3048
|
+
});
|
|
3049
|
+
|
|
3050
|
+
it('revise does not let queued input overwrite the revision text', async () => {
|
|
3051
|
+
const restoreFn = jest.fn();
|
|
3052
|
+
const deps = createSendableDeps({
|
|
3053
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
3054
|
+
});
|
|
3055
|
+
deps.state.queuedMessage = {
|
|
3056
|
+
content: 'queued follow-up',
|
|
3057
|
+
images: undefined,
|
|
3058
|
+
editorContext: null,
|
|
3059
|
+
canvasContext: null,
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
3063
|
+
mockAgentService.providerId = 'codex';
|
|
3064
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
3065
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
3066
|
+
createMockStream([
|
|
3067
|
+
{ type: 'text', content: 'Plan content' },
|
|
3068
|
+
{ type: 'done' },
|
|
3069
|
+
]),
|
|
3070
|
+
);
|
|
3071
|
+
|
|
3072
|
+
const controller = new InputController(deps);
|
|
3073
|
+
(controller as any).showPlanApproval = jest.fn().mockResolvedValue({
|
|
3074
|
+
decision: { type: 'revise', text: 'Add more tests' },
|
|
3075
|
+
invalidated: false,
|
|
3076
|
+
});
|
|
3077
|
+
|
|
3078
|
+
const inputEl = deps.getInputEl();
|
|
3079
|
+
inputEl.value = 'Plan this';
|
|
3080
|
+
await controller.sendMessage();
|
|
3081
|
+
|
|
3082
|
+
expect(restoreFn).not.toHaveBeenCalled();
|
|
3083
|
+
expect(inputEl.value).toBe('Add more tests');
|
|
3084
|
+
expect(deps.state.queuedMessage).toEqual({
|
|
3085
|
+
content: 'queued follow-up',
|
|
3086
|
+
images: undefined,
|
|
3087
|
+
editorContext: null,
|
|
3088
|
+
canvasContext: null,
|
|
3089
|
+
});
|
|
3090
|
+
expect(mockAgentService.query).toHaveBeenCalledTimes(1);
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
it('cancel restores mode and does not auto-send', async () => {
|
|
3094
|
+
const restoreFn = jest.fn();
|
|
3095
|
+
const deps = createSendableDeps({
|
|
3096
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
3097
|
+
});
|
|
3098
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
3099
|
+
mockAgentService.providerId = 'codex';
|
|
3100
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
3101
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
3102
|
+
createMockStream([
|
|
3103
|
+
{ type: 'text', content: 'Plan content' },
|
|
3104
|
+
{ type: 'done' },
|
|
3105
|
+
]),
|
|
3106
|
+
);
|
|
3107
|
+
|
|
3108
|
+
const controller = new InputController(deps);
|
|
3109
|
+
(controller as any).showPlanApproval = jest.fn().mockResolvedValue({
|
|
3110
|
+
decision: { type: 'cancel' },
|
|
3111
|
+
invalidated: false,
|
|
3112
|
+
});
|
|
3113
|
+
|
|
3114
|
+
const inputEl = deps.getInputEl();
|
|
3115
|
+
inputEl.value = 'Plan this';
|
|
3116
|
+
await controller.sendMessage();
|
|
3117
|
+
|
|
3118
|
+
expect(restoreFn).toHaveBeenCalled();
|
|
3119
|
+
expect(mockAgentService.query).toHaveBeenCalledTimes(1);
|
|
3120
|
+
});
|
|
3121
|
+
|
|
3122
|
+
it('external dismissal while the approval UI is open bails out without save or restore', async () => {
|
|
3123
|
+
const restoreFn = jest.fn();
|
|
3124
|
+
const parentEl = createMockEl();
|
|
3125
|
+
const inputContainerEl = createMockEl();
|
|
3126
|
+
inputContainerEl.parentElement = parentEl;
|
|
3127
|
+
|
|
3128
|
+
const deps = createSendableDeps({
|
|
3129
|
+
getInputContainerEl: () => inputContainerEl as any,
|
|
3130
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
3131
|
+
});
|
|
3132
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
3133
|
+
mockAgentService.providerId = 'codex';
|
|
3134
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
3135
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
3136
|
+
createMockStream([
|
|
3137
|
+
{ type: 'text', content: 'Plan content' },
|
|
3138
|
+
{ type: 'done' },
|
|
3139
|
+
]),
|
|
3140
|
+
);
|
|
3141
|
+
|
|
3142
|
+
const controller = new InputController(deps);
|
|
3143
|
+
const inputEl = deps.getInputEl();
|
|
3144
|
+
inputEl.value = 'Plan this';
|
|
3145
|
+
|
|
3146
|
+
const sendPromise = controller.sendMessage();
|
|
3147
|
+
await new Promise(resolve => setTimeout(resolve, 0));
|
|
3148
|
+
|
|
3149
|
+
expect((controller as any).pendingPlanApproval).not.toBeNull();
|
|
3150
|
+
|
|
3151
|
+
controller.dismissPendingApproval();
|
|
3152
|
+
await sendPromise;
|
|
3153
|
+
|
|
3154
|
+
expect(restoreFn).not.toHaveBeenCalled();
|
|
3155
|
+
expect(deps.conversationController.save).not.toHaveBeenCalled();
|
|
3156
|
+
expect(mockAgentService.query).toHaveBeenCalledTimes(1);
|
|
3157
|
+
});
|
|
3158
|
+
|
|
3159
|
+
it('null decision (dismiss) restores mode and does not auto-send', async () => {
|
|
3160
|
+
const restoreFn = jest.fn();
|
|
3161
|
+
const deps = createSendableDeps({
|
|
3162
|
+
restorePrePlanPermissionModeIfNeeded: restoreFn,
|
|
3163
|
+
});
|
|
3164
|
+
const mockAgentService = (deps as any).mockAgentService;
|
|
3165
|
+
mockAgentService.providerId = 'codex';
|
|
3166
|
+
mockAgentService.consumeTurnMetadata = jest.fn().mockReturnValue({ planCompleted: true, wasSent: true });
|
|
3167
|
+
mockAgentService.query = jest.fn().mockImplementation(() =>
|
|
3168
|
+
createMockStream([
|
|
3169
|
+
{ type: 'text', content: 'Plan content' },
|
|
3170
|
+
{ type: 'done' },
|
|
3171
|
+
]),
|
|
3172
|
+
);
|
|
3173
|
+
|
|
3174
|
+
const controller = new InputController(deps);
|
|
3175
|
+
(controller as any).showPlanApproval = jest.fn().mockResolvedValue({
|
|
3176
|
+
decision: null,
|
|
3177
|
+
invalidated: false,
|
|
3178
|
+
});
|
|
3179
|
+
|
|
3180
|
+
const inputEl = deps.getInputEl();
|
|
3181
|
+
inputEl.value = 'Plan this';
|
|
3182
|
+
await controller.sendMessage();
|
|
3183
|
+
|
|
3184
|
+
expect(restoreFn).toHaveBeenCalled();
|
|
3185
|
+
expect(mockAgentService.query).toHaveBeenCalledTimes(1);
|
|
3186
|
+
});
|
|
3187
|
+
});
|
|
3188
|
+
});
|