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