@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,3796 @@
|
|
|
1
|
+
import '@/providers';
|
|
2
|
+
|
|
3
|
+
import * as sdkModule from '@anthropic-ai/claude-agent-sdk';
|
|
4
|
+
import { Notice } from 'obsidian';
|
|
5
|
+
|
|
6
|
+
import type { McpServerManager } from '@/core/mcp/McpServerManager';
|
|
7
|
+
import type ClaudianPlugin from '@/main';
|
|
8
|
+
import { ClaudianService } from '@/providers/claude/runtime/ClaudeChatRuntime';
|
|
9
|
+
import { MessageChannel } from '@/providers/claude/runtime/ClaudeMessageChannel';
|
|
10
|
+
import { createResponseHandler } from '@/providers/claude/runtime/types';
|
|
11
|
+
import * as envUtils from '@/utils/env';
|
|
12
|
+
import * as sessionUtils from '@/utils/session';
|
|
13
|
+
|
|
14
|
+
const sdkMock = sdkModule as unknown as {
|
|
15
|
+
setMockMessages: (messages: any[], options?: { appendResult?: boolean }) => void;
|
|
16
|
+
resetMockMessages: () => void;
|
|
17
|
+
simulateCrash: (afterChunks?: number) => void;
|
|
18
|
+
query: typeof sdkModule.query;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type MockMcpServerManager = jest.Mocked<McpServerManager>;
|
|
22
|
+
|
|
23
|
+
describe('ClaudianService', () => {
|
|
24
|
+
let mockPlugin: Partial<ClaudianPlugin>;
|
|
25
|
+
let mockMcpManager: MockMcpServerManager;
|
|
26
|
+
let service: ClaudianService;
|
|
27
|
+
|
|
28
|
+
async function collectChunks(gen: AsyncGenerator<any>): Promise<any[]> {
|
|
29
|
+
const chunks: any[] = [];
|
|
30
|
+
for await (const chunk of gen) {
|
|
31
|
+
chunks.push(chunk);
|
|
32
|
+
}
|
|
33
|
+
return chunks;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
|
|
39
|
+
const storageMock = {
|
|
40
|
+
addDenyRule: jest.fn().mockResolvedValue(undefined),
|
|
41
|
+
addAllowRule: jest.fn().mockResolvedValue(undefined),
|
|
42
|
+
getPermissions: jest.fn().mockResolvedValue({ allow: [], deny: [], ask: [] }),
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
mockPlugin = {
|
|
46
|
+
app: {
|
|
47
|
+
vault: { adapter: { basePath: '/mock/vault/path' } },
|
|
48
|
+
},
|
|
49
|
+
storage: storageMock,
|
|
50
|
+
settings: {
|
|
51
|
+
model: 'claude-3-5-sonnet',
|
|
52
|
+
permissionMode: 'ask' as const,
|
|
53
|
+
thinkingBudget: 0,
|
|
54
|
+
mediaFolder: 'claudian-media',
|
|
55
|
+
systemPrompt: '',
|
|
56
|
+
loadUserClaudeSettings: false,
|
|
57
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
58
|
+
claudeCliPaths: [],
|
|
59
|
+
enableAutoTitleGeneration: true,
|
|
60
|
+
titleGenerationModel: 'claude-3-5-haiku',
|
|
61
|
+
},
|
|
62
|
+
getResolvedProviderCliPath: jest.fn().mockReturnValue('/usr/local/bin/claude'),
|
|
63
|
+
getActiveEnvironmentVariables: jest.fn().mockReturnValue(''),
|
|
64
|
+
pluginManager: {
|
|
65
|
+
getPluginsKey: jest.fn().mockReturnValue(''),
|
|
66
|
+
},
|
|
67
|
+
} as unknown as ClaudianPlugin;
|
|
68
|
+
|
|
69
|
+
mockMcpManager = {
|
|
70
|
+
loadServers: jest.fn().mockResolvedValue(undefined),
|
|
71
|
+
getAllDisallowedMcpTools: jest.fn().mockReturnValue([]),
|
|
72
|
+
getActiveServers: jest.fn().mockReturnValue({}),
|
|
73
|
+
getDisallowedMcpTools: jest.fn().mockReturnValue([]),
|
|
74
|
+
extractMentions: jest.fn().mockReturnValue(new Set<string>()),
|
|
75
|
+
transformMentions: jest.fn().mockImplementation((text: string) => text),
|
|
76
|
+
} as unknown as MockMcpServerManager;
|
|
77
|
+
|
|
78
|
+
service = new ClaudianService(mockPlugin as ClaudianPlugin, mockMcpManager);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('prepareTurn', () => {
|
|
82
|
+
it('should return PreparedChatTurn with encoded prompt', () => {
|
|
83
|
+
const result = service.prepareTurn({ text: 'hello world' });
|
|
84
|
+
expect(result.request.text).toBe('hello world');
|
|
85
|
+
expect(result.prompt).toBe('hello world');
|
|
86
|
+
expect(result.persistedContent).toBe('hello world');
|
|
87
|
+
expect(result.isCompact).toBe(false);
|
|
88
|
+
expect(result.mcpMentions).toEqual(new Set());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should append current note context', () => {
|
|
92
|
+
const result = service.prepareTurn({
|
|
93
|
+
text: 'explain this',
|
|
94
|
+
currentNotePath: 'notes/test.md',
|
|
95
|
+
});
|
|
96
|
+
expect(result.persistedContent).toContain('<current_note>');
|
|
97
|
+
expect(result.persistedContent).toContain('notes/test.md');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('should detect /compact and skip context', () => {
|
|
101
|
+
const result = service.prepareTurn({
|
|
102
|
+
text: '/compact',
|
|
103
|
+
currentNotePath: 'notes/test.md',
|
|
104
|
+
});
|
|
105
|
+
expect(result.isCompact).toBe(true);
|
|
106
|
+
expect(result.persistedContent).toBe('/compact');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should extract MCP mentions', () => {
|
|
110
|
+
(mockMcpManager.extractMentions as jest.Mock).mockReturnValue(new Set(['server-a']));
|
|
111
|
+
const result = service.prepareTurn({ text: '@server-a hello' });
|
|
112
|
+
expect(result.mcpMentions).toEqual(new Set(['server-a']));
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('query with PreparedChatTurn', () => {
|
|
117
|
+
it('should stream chunks when called with PreparedChatTurn', async () => {
|
|
118
|
+
sdkMock.setMockMessages([
|
|
119
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello!' }] } },
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const turn = service.prepareTurn({ text: 'hello' });
|
|
123
|
+
const chunks = await collectChunks(service.query(turn));
|
|
124
|
+
|
|
125
|
+
const textChunks = chunks.filter(c => c.type === 'text');
|
|
126
|
+
expect(textChunks).toHaveLength(1);
|
|
127
|
+
expect(textChunks[0].content).toBe('Hello!');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should forward conversation history', async () => {
|
|
131
|
+
sdkMock.setMockMessages([
|
|
132
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Response' }] } },
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const turn = service.prepareTurn({ text: 'follow up' });
|
|
136
|
+
const history = [
|
|
137
|
+
{ id: 'u1', role: 'user' as const, content: 'first', timestamp: 1 },
|
|
138
|
+
{ id: 'a1', role: 'assistant' as const, content: 'reply', timestamp: 2 },
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const chunks = await collectChunks(service.query(turn, history));
|
|
142
|
+
expect(chunks.some(c => c.type === 'text')).toBe(true);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('Session Management', () => {
|
|
147
|
+
it('should have null session ID initially', () => {
|
|
148
|
+
expect(service.getSessionId()).toBeNull();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should set session ID', () => {
|
|
152
|
+
service.setSessionId('test-session-123');
|
|
153
|
+
expect(service.getSessionId()).toBe('test-session-123');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('should reset session', () => {
|
|
157
|
+
service.setSessionId('test-session-123');
|
|
158
|
+
service.resetSession();
|
|
159
|
+
expect(service.getSessionId()).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('should not close persistent query when setting same session ID', () => {
|
|
163
|
+
service.setSessionId('test-session-123');
|
|
164
|
+
const activeStateBefore = service.isPersistentQueryActive();
|
|
165
|
+
service.setSessionId('test-session-123');
|
|
166
|
+
expect(service.getSessionId()).toBe('test-session-123');
|
|
167
|
+
expect(service.isPersistentQueryActive()).toBe(activeStateBefore);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('should update session ID when switching to different session', () => {
|
|
171
|
+
service.setSessionId('test-session-123');
|
|
172
|
+
service.setSessionId('different-session-456');
|
|
173
|
+
expect(service.getSessionId()).toBe('different-session-456');
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('should handle setting null session ID', () => {
|
|
177
|
+
service.setSessionId('test-session-123');
|
|
178
|
+
service.setSessionId(null);
|
|
179
|
+
expect(service.getSessionId()).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('should NOT call ensureReady when setting session ID (passive sync)', async () => {
|
|
183
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
184
|
+
|
|
185
|
+
service.setSessionId('test-session', ['/path/a', '/path/b']);
|
|
186
|
+
|
|
187
|
+
await Promise.resolve();
|
|
188
|
+
|
|
189
|
+
// setSessionId is now passive — runtime starts on demand in query()
|
|
190
|
+
expect(ensureReadySpy).not.toHaveBeenCalled();
|
|
191
|
+
expect(service.getSessionId()).toBe('test-session');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('should track externalContextPaths for later use without starting runtime', async () => {
|
|
195
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
196
|
+
|
|
197
|
+
service.setSessionId('test-session', ['/path/a']);
|
|
198
|
+
|
|
199
|
+
await Promise.resolve();
|
|
200
|
+
|
|
201
|
+
expect(ensureReadySpy).not.toHaveBeenCalled();
|
|
202
|
+
expect(service.getSessionId()).toBe('test-session');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('MCP Server Management', () => {
|
|
207
|
+
it('should reload MCP servers', async () => {
|
|
208
|
+
await service.reloadMcpServers();
|
|
209
|
+
|
|
210
|
+
expect(mockMcpManager.loadServers).toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe('Persistent Query Management', () => {
|
|
215
|
+
it('should not be active initially', () => {
|
|
216
|
+
expect(service.isPersistentQueryActive()).toBe(false);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('should close persistent query', () => {
|
|
220
|
+
service.setSessionId('test-session');
|
|
221
|
+
service.closePersistentQuery('test reason');
|
|
222
|
+
|
|
223
|
+
expect(service.isPersistentQueryActive()).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('should restart persistent query via ensureReady with force', async () => {
|
|
227
|
+
service.setSessionId('test-session');
|
|
228
|
+
|
|
229
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
230
|
+
startPersistentQuerySpy.mockResolvedValue(undefined);
|
|
231
|
+
|
|
232
|
+
const result = await service.ensureReady({ force: true });
|
|
233
|
+
|
|
234
|
+
expect(result).toBe(true);
|
|
235
|
+
expect(startPersistentQuerySpy).toHaveBeenCalled();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('should return false (no-op) when config unchanged and query running', async () => {
|
|
239
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
240
|
+
|
|
241
|
+
// Mock startPersistentQuery to simulate real side effects (subprocess boundary)
|
|
242
|
+
startPersistentQuerySpy.mockImplementation(async (...args: unknown[]) => {
|
|
243
|
+
const [vaultPath, cliPath, , externalContextPaths] = args as [string, string, string?, string[]?];
|
|
244
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
245
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// First call starts the query
|
|
249
|
+
const result1 = await service.ensureReady();
|
|
250
|
+
expect(result1).toBe(true);
|
|
251
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1);
|
|
252
|
+
|
|
253
|
+
// Second call with same config should no-op
|
|
254
|
+
const result2 = await service.ensureReady();
|
|
255
|
+
expect(result2).toBe(false);
|
|
256
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Still 1, not called again
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should restart when config changed (external context paths)', async () => {
|
|
260
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
261
|
+
|
|
262
|
+
// Mock startPersistentQuery to simulate real side effects (subprocess boundary)
|
|
263
|
+
startPersistentQuerySpy.mockImplementation(async (...args: unknown[]) => {
|
|
264
|
+
const [vaultPath, cliPath, , externalContextPaths] = args as [string, string, string?, string[]?];
|
|
265
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
266
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// First call starts with no external paths (Case 1: not running)
|
|
270
|
+
await service.ensureReady({ externalContextPaths: [] });
|
|
271
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1);
|
|
272
|
+
|
|
273
|
+
// Second call with different paths triggers restart via real needsRestart
|
|
274
|
+
const result = await service.ensureReady({ externalContextPaths: ['/new/path'] });
|
|
275
|
+
expect(result).toBe(true);
|
|
276
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(2);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should pass preserveHandlers: true to closePersistentQuery on force restart', async () => {
|
|
280
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
281
|
+
const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery');
|
|
282
|
+
|
|
283
|
+
startPersistentQuerySpy.mockImplementation(async () => {
|
|
284
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Start the query first
|
|
288
|
+
await service.ensureReady();
|
|
289
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1);
|
|
290
|
+
|
|
291
|
+
// Force restart with preserveHandlers: true (crash recovery scenario)
|
|
292
|
+
await service.ensureReady({ force: true, preserveHandlers: true });
|
|
293
|
+
|
|
294
|
+
expect(closePersistentQuerySpy).toHaveBeenCalledWith('forced restart', { preserveHandlers: true });
|
|
295
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(2);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should pass preserveHandlers through config change restart', async () => {
|
|
299
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
300
|
+
const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery');
|
|
301
|
+
|
|
302
|
+
// Mock startPersistentQuery to simulate real side effects (subprocess boundary)
|
|
303
|
+
startPersistentQuerySpy.mockImplementation(async (...args: unknown[]) => {
|
|
304
|
+
const [vaultPath, cliPath, , externalContextPaths] = args as [string, string, string?, string[]?];
|
|
305
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
306
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// Start the query first
|
|
310
|
+
await service.ensureReady({ externalContextPaths: [] });
|
|
311
|
+
|
|
312
|
+
// Config change with preserveHandlers: true
|
|
313
|
+
await service.ensureReady({ externalContextPaths: ['/new/path'], preserveHandlers: true });
|
|
314
|
+
|
|
315
|
+
expect(closePersistentQuerySpy).toHaveBeenCalledWith('config changed', { preserveHandlers: true });
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it('should return false when CLI unavailable after force close', async () => {
|
|
319
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
320
|
+
const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery');
|
|
321
|
+
|
|
322
|
+
startPersistentQuerySpy.mockImplementation(async () => {
|
|
323
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Start the query first
|
|
327
|
+
await service.ensureReady();
|
|
328
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1);
|
|
329
|
+
|
|
330
|
+
// Now make CLI unavailable
|
|
331
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockReturnValue(null);
|
|
332
|
+
|
|
333
|
+
// Force restart should close but fail to start new one
|
|
334
|
+
const result = await service.ensureReady({ force: true });
|
|
335
|
+
expect(result).toBe(false);
|
|
336
|
+
expect(closePersistentQuerySpy).toHaveBeenCalledWith('forced restart', { preserveHandlers: undefined });
|
|
337
|
+
expect(startPersistentQuerySpy).toHaveBeenCalledTimes(1); // Not called again
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should return false when CLI unavailable after config change close', async () => {
|
|
341
|
+
const startPersistentQuerySpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
342
|
+
const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery');
|
|
343
|
+
|
|
344
|
+
// Mock startPersistentQuery to simulate real side effects (subprocess boundary)
|
|
345
|
+
startPersistentQuerySpy.mockImplementation(async (...args: unknown[]) => {
|
|
346
|
+
const [vaultPath, cliPath, , externalContextPaths] = args as [string, string, string?, string[]?];
|
|
347
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
348
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Start the query first (Case 1: not running)
|
|
352
|
+
await service.ensureReady({ externalContextPaths: [] });
|
|
353
|
+
|
|
354
|
+
// Make CLI unavailable after the config change detection
|
|
355
|
+
// In Case 3, CLI is checked once before needsRestart, then again after close
|
|
356
|
+
let cliCallCount = 0;
|
|
357
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockImplementation(() => {
|
|
358
|
+
cliCallCount++;
|
|
359
|
+
// First call (for config check) returns valid path
|
|
360
|
+
// Second call (after close, for restart) returns null
|
|
361
|
+
return cliCallCount === 1 ? '/usr/local/bin/claude' : null;
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Config change should close but fail to start new one (CLI unavailable)
|
|
365
|
+
const result = await service.ensureReady({ externalContextPaths: ['/new/path'] });
|
|
366
|
+
expect(result).toBe(false);
|
|
367
|
+
expect(closePersistentQuerySpy).toHaveBeenCalledWith('config changed', { preserveHandlers: undefined });
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('should cleanup resources', () => {
|
|
371
|
+
const closePersistentQuerySpy = jest.spyOn(service, 'closePersistentQuery');
|
|
372
|
+
const cancelSpy = jest.spyOn(service, 'cancel');
|
|
373
|
+
|
|
374
|
+
service.cleanup();
|
|
375
|
+
|
|
376
|
+
expect(closePersistentQuerySpy).toHaveBeenCalledWith('plugin cleanup');
|
|
377
|
+
expect(cancelSpy).toHaveBeenCalled();
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
describe('Query Cancellation', () => {
|
|
382
|
+
it('should cancel cold-start query', () => {
|
|
383
|
+
const abortSpy = jest.fn();
|
|
384
|
+
(service as any).abortController = { abort: abortSpy, signal: { aborted: false } };
|
|
385
|
+
|
|
386
|
+
service.cancel();
|
|
387
|
+
|
|
388
|
+
expect(abortSpy).toHaveBeenCalled();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('should mark session as interrupted on cancel', () => {
|
|
392
|
+
const sessionManager = (service as any).sessionManager;
|
|
393
|
+
(service as any).abortController = { abort: jest.fn(), signal: { aborted: false } };
|
|
394
|
+
|
|
395
|
+
service.cancel();
|
|
396
|
+
|
|
397
|
+
expect(sessionManager.wasInterrupted()).toBe(true);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should call approval dismisser on cancel', () => {
|
|
401
|
+
const dismisser = jest.fn();
|
|
402
|
+
service.setApprovalDismisser(dismisser);
|
|
403
|
+
|
|
404
|
+
service.cancel();
|
|
405
|
+
|
|
406
|
+
expect(dismisser).toHaveBeenCalled();
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
it('should not throw when no approval dismisser is set', () => {
|
|
410
|
+
expect(() => service.cancel()).not.toThrow();
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
describe('Approval Callback', () => {
|
|
415
|
+
// approvalCallback is private with no observable side effect from setApprovalCallback alone.
|
|
416
|
+
// Verifying the stored value requires direct access.
|
|
417
|
+
it('should set approval callback', () => {
|
|
418
|
+
const callback = jest.fn();
|
|
419
|
+
service.setApprovalCallback(callback);
|
|
420
|
+
|
|
421
|
+
expect((service as any).approvalCallback).toBe(callback);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('should set null approval callback', () => {
|
|
425
|
+
const callback = jest.fn();
|
|
426
|
+
service.setApprovalCallback(callback);
|
|
427
|
+
service.setApprovalCallback(null);
|
|
428
|
+
|
|
429
|
+
expect((service as any).approvalCallback).toBeNull();
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
describe('createApprovalCallback permission flow', () => {
|
|
433
|
+
const canUseToolOptions = {
|
|
434
|
+
signal: new AbortController().signal,
|
|
435
|
+
toolUseID: 'test-tool-use-id',
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
it('should deny when no approvalCallback is set', async () => {
|
|
439
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
440
|
+
const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions);
|
|
441
|
+
|
|
442
|
+
expect(result.behavior).toBe('deny');
|
|
443
|
+
expect(result.message).toBe('No approval handler available.');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('should return deny when user denies', async () => {
|
|
447
|
+
const callback = jest.fn().mockResolvedValue('deny');
|
|
448
|
+
service.setApprovalCallback(callback);
|
|
449
|
+
|
|
450
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
451
|
+
const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions);
|
|
452
|
+
|
|
453
|
+
expect(result.behavior).toBe('deny');
|
|
454
|
+
expect(result.message).toBe('User denied this action.');
|
|
455
|
+
expect(result).not.toHaveProperty('updatedPermissions');
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it('should return deny without interrupt when approvalCallback throws', async () => {
|
|
459
|
+
const callback = jest.fn().mockRejectedValue(new Error('Modal render failed'));
|
|
460
|
+
service.setApprovalCallback(callback);
|
|
461
|
+
|
|
462
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
463
|
+
const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions);
|
|
464
|
+
|
|
465
|
+
expect(result.behavior).toBe('deny');
|
|
466
|
+
expect(result.message).toContain('Modal render failed');
|
|
467
|
+
expect(result.interrupt).toBe(false);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('should return deny with interrupt for cancel decisions', async () => {
|
|
471
|
+
const callback = jest.fn().mockResolvedValue('cancel');
|
|
472
|
+
service.setApprovalCallback(callback);
|
|
473
|
+
|
|
474
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
475
|
+
const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions);
|
|
476
|
+
|
|
477
|
+
expect(result.behavior).toBe('deny');
|
|
478
|
+
expect(result.message).toBe('User interrupted.');
|
|
479
|
+
expect(result.interrupt).toBe(true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('should prompt again after deny (no session cache)', async () => {
|
|
483
|
+
const callback = jest.fn().mockResolvedValue('deny');
|
|
484
|
+
service.setApprovalCallback(callback);
|
|
485
|
+
|
|
486
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
487
|
+
|
|
488
|
+
await canUseTool('Bash', { command: 'rm -rf /tmp' }, canUseToolOptions);
|
|
489
|
+
await canUseTool('Bash', { command: 'rm -rf /tmp' }, canUseToolOptions);
|
|
490
|
+
|
|
491
|
+
expect(callback).toHaveBeenCalledTimes(2);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
it('should forward decisionReason and blockedPath to approvalCallback', async () => {
|
|
495
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
496
|
+
service.setApprovalCallback(callback);
|
|
497
|
+
|
|
498
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
499
|
+
await canUseTool('Read', { file_path: '/etc/passwd' }, {
|
|
500
|
+
...canUseToolOptions,
|
|
501
|
+
decisionReason: 'Path is outside allowed directories',
|
|
502
|
+
blockedPath: '/etc/passwd',
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
expect(callback).toHaveBeenCalledWith(
|
|
506
|
+
'Read',
|
|
507
|
+
{ file_path: '/etc/passwd' },
|
|
508
|
+
'Read file: /etc/passwd',
|
|
509
|
+
{
|
|
510
|
+
decisionReason: 'Path is outside allowed directories',
|
|
511
|
+
blockedPath: '/etc/passwd',
|
|
512
|
+
agentID: undefined,
|
|
513
|
+
},
|
|
514
|
+
);
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('should forward agentID to approvalCallback', async () => {
|
|
518
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
519
|
+
service.setApprovalCallback(callback);
|
|
520
|
+
|
|
521
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
522
|
+
await canUseTool('Bash', { command: 'ls' }, {
|
|
523
|
+
...canUseToolOptions,
|
|
524
|
+
agentID: 'sub-agent-42',
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
expect(callback).toHaveBeenCalledWith(
|
|
528
|
+
'Bash',
|
|
529
|
+
{ command: 'ls' },
|
|
530
|
+
expect.any(String),
|
|
531
|
+
{
|
|
532
|
+
decisionReason: undefined,
|
|
533
|
+
blockedPath: undefined,
|
|
534
|
+
agentID: 'sub-agent-42',
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
it('should return updatedPermissions with session destination for allow decisions', async () => {
|
|
540
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
541
|
+
service.setApprovalCallback(callback);
|
|
542
|
+
|
|
543
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
544
|
+
const result = await canUseTool('Bash', { command: 'git status' }, canUseToolOptions);
|
|
545
|
+
|
|
546
|
+
expect(result.behavior).toBe('allow');
|
|
547
|
+
expect(result.updatedPermissions).toBeDefined();
|
|
548
|
+
expect(result.updatedPermissions[0]).toMatchObject({
|
|
549
|
+
type: 'addRules',
|
|
550
|
+
behavior: 'allow',
|
|
551
|
+
destination: 'session',
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should return updatedPermissions for allow-always decisions', async () => {
|
|
556
|
+
const callback = jest.fn().mockResolvedValue('allow-always');
|
|
557
|
+
service.setApprovalCallback(callback);
|
|
558
|
+
|
|
559
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
560
|
+
const result = await canUseTool('Bash', { command: 'git status' }, canUseToolOptions);
|
|
561
|
+
|
|
562
|
+
expect(result.behavior).toBe('allow');
|
|
563
|
+
expect(result.updatedPermissions).toBeDefined();
|
|
564
|
+
expect(result.updatedPermissions[0]).toMatchObject({
|
|
565
|
+
type: 'addRules',
|
|
566
|
+
behavior: 'allow',
|
|
567
|
+
destination: 'projectSettings',
|
|
568
|
+
});
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
describe('Session Restoration', () => {
|
|
574
|
+
it('should restore session with custom model', () => {
|
|
575
|
+
const customModel = 'claude-3-opus';
|
|
576
|
+
(mockPlugin as any).settings.model = customModel;
|
|
577
|
+
|
|
578
|
+
service.setSessionId('test-session-123');
|
|
579
|
+
|
|
580
|
+
expect(service.getSessionId()).toBe('test-session-123');
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
it('should invalidate session on reset', () => {
|
|
584
|
+
service.setSessionId('test-session-123');
|
|
585
|
+
service.resetSession();
|
|
586
|
+
|
|
587
|
+
expect(service.getSessionId()).toBeNull();
|
|
588
|
+
});
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
describe('Ready State Change Listeners', () => {
|
|
592
|
+
it('should call listener immediately with current ready state on subscribe', () => {
|
|
593
|
+
const listener = jest.fn();
|
|
594
|
+
|
|
595
|
+
service.onReadyStateChange(listener);
|
|
596
|
+
|
|
597
|
+
expect(listener).toHaveBeenCalledWith(false);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it('should call listener with true when service is ready', () => {
|
|
601
|
+
(service as any).persistentQuery = {};
|
|
602
|
+
(service as any).shuttingDown = false;
|
|
603
|
+
|
|
604
|
+
const listener = jest.fn();
|
|
605
|
+
service.onReadyStateChange(listener);
|
|
606
|
+
|
|
607
|
+
expect(listener).toHaveBeenCalledWith(true);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it('should return unsubscribe function that removes listener', () => {
|
|
611
|
+
const listener = jest.fn();
|
|
612
|
+
const unsubscribe = service.onReadyStateChange(listener);
|
|
613
|
+
|
|
614
|
+
unsubscribe();
|
|
615
|
+
|
|
616
|
+
expect((service as any).readyStateListeners.has(listener)).toBe(false);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('should notify multiple listeners when ready state changes', () => {
|
|
620
|
+
const listener1 = jest.fn();
|
|
621
|
+
const listener2 = jest.fn();
|
|
622
|
+
|
|
623
|
+
service.onReadyStateChange(listener1);
|
|
624
|
+
service.onReadyStateChange(listener2);
|
|
625
|
+
|
|
626
|
+
listener1.mockClear();
|
|
627
|
+
listener2.mockClear();
|
|
628
|
+
|
|
629
|
+
(service as any).notifyReadyStateChange();
|
|
630
|
+
|
|
631
|
+
expect(listener1).toHaveBeenCalledWith(false);
|
|
632
|
+
expect(listener2).toHaveBeenCalledWith(false);
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
it('should not call unsubscribed listeners on notify', () => {
|
|
636
|
+
const listener1 = jest.fn();
|
|
637
|
+
const listener2 = jest.fn();
|
|
638
|
+
|
|
639
|
+
service.onReadyStateChange(listener1);
|
|
640
|
+
const unsubscribe2 = service.onReadyStateChange(listener2);
|
|
641
|
+
|
|
642
|
+
listener1.mockClear();
|
|
643
|
+
listener2.mockClear();
|
|
644
|
+
|
|
645
|
+
unsubscribe2();
|
|
646
|
+
(service as any).notifyReadyStateChange();
|
|
647
|
+
|
|
648
|
+
expect(listener1).toHaveBeenCalled();
|
|
649
|
+
expect(listener2).not.toHaveBeenCalled();
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
it('should isolate listener errors and continue notifying other listeners', () => {
|
|
653
|
+
const errorListener = jest.fn().mockImplementation(() => {
|
|
654
|
+
throw new Error('Listener error');
|
|
655
|
+
});
|
|
656
|
+
const normalListener = jest.fn();
|
|
657
|
+
|
|
658
|
+
service.onReadyStateChange(errorListener);
|
|
659
|
+
service.onReadyStateChange(normalListener);
|
|
660
|
+
|
|
661
|
+
normalListener.mockClear();
|
|
662
|
+
|
|
663
|
+
expect(() => (service as any).notifyReadyStateChange()).not.toThrow();
|
|
664
|
+
expect(normalListener).toHaveBeenCalledWith(false);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
it('should isolate errors on immediate callback during subscribe', () => {
|
|
668
|
+
const errorListener = jest.fn().mockImplementation(() => {
|
|
669
|
+
throw new Error('Listener error');
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
expect(() => service.onReadyStateChange(errorListener)).not.toThrow();
|
|
673
|
+
expect(errorListener).toHaveBeenCalled();
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should skip notification when no listeners registered', () => {
|
|
677
|
+
expect(() => (service as any).notifyReadyStateChange()).not.toThrow();
|
|
678
|
+
});
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
describe('SDK Skills (Supported Commands)', () => {
|
|
682
|
+
it('should report not ready when no persistent query exists', () => {
|
|
683
|
+
expect(service.isReady()).toBe(false);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('should report ready when persistent query is active', () => {
|
|
687
|
+
// Simulate active persistent query
|
|
688
|
+
(service as any).persistentQuery = {};
|
|
689
|
+
(service as any).shuttingDown = false;
|
|
690
|
+
|
|
691
|
+
expect(service.isReady()).toBe(true);
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should report not ready when shutting down', () => {
|
|
695
|
+
(service as any).persistentQuery = {};
|
|
696
|
+
(service as any).shuttingDown = true;
|
|
697
|
+
|
|
698
|
+
expect(service.isReady()).toBe(false);
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('should return empty array when no persistent query', async () => {
|
|
702
|
+
const commands = await service.getSupportedCommands();
|
|
703
|
+
expect(commands).toEqual([]);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('should convert SDK skills to SlashCommand format', async () => {
|
|
707
|
+
const mockSdkCommands = [
|
|
708
|
+
{ name: 'commit', description: 'Create a git commit', argumentHint: '' },
|
|
709
|
+
{ name: 'pr', description: 'Create a pull request', argumentHint: '<title>' },
|
|
710
|
+
];
|
|
711
|
+
|
|
712
|
+
const mockQuery = {
|
|
713
|
+
supportedCommands: jest.fn().mockResolvedValue(mockSdkCommands),
|
|
714
|
+
};
|
|
715
|
+
(service as any).persistentQuery = mockQuery;
|
|
716
|
+
|
|
717
|
+
const commands = await service.getSupportedCommands();
|
|
718
|
+
|
|
719
|
+
expect(mockQuery.supportedCommands).toHaveBeenCalled();
|
|
720
|
+
expect(commands).toHaveLength(2);
|
|
721
|
+
expect(commands[0]).toEqual({
|
|
722
|
+
id: 'sdk:commit',
|
|
723
|
+
name: 'commit',
|
|
724
|
+
description: 'Create a git commit',
|
|
725
|
+
argumentHint: '',
|
|
726
|
+
content: '',
|
|
727
|
+
source: 'sdk',
|
|
728
|
+
});
|
|
729
|
+
expect(commands[1]).toEqual({
|
|
730
|
+
id: 'sdk:pr',
|
|
731
|
+
name: 'pr',
|
|
732
|
+
description: 'Create a pull request',
|
|
733
|
+
argumentHint: '<title>',
|
|
734
|
+
content: '',
|
|
735
|
+
source: 'sdk',
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it('should return empty array on SDK error', async () => {
|
|
740
|
+
const mockQuery = {
|
|
741
|
+
supportedCommands: jest.fn().mockRejectedValue(new Error('SDK error')),
|
|
742
|
+
};
|
|
743
|
+
(service as any).persistentQuery = mockQuery;
|
|
744
|
+
|
|
745
|
+
const commands = await service.getSupportedCommands();
|
|
746
|
+
|
|
747
|
+
expect(commands).toEqual([]);
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('should ignore late supportedCommands results from a stale query', async () => {
|
|
751
|
+
let resolveStaleCommands: (commands: Array<{ name: string; description: string; argumentHint: string }>) => void;
|
|
752
|
+
const staleCommandsPromise = new Promise<Array<{ name: string; description: string; argumentHint: string }>>((resolve) => {
|
|
753
|
+
resolveStaleCommands = resolve;
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
const staleQuery = {
|
|
757
|
+
supportedCommands: jest.fn().mockReturnValue(staleCommandsPromise),
|
|
758
|
+
};
|
|
759
|
+
const activeQuery = {
|
|
760
|
+
supportedCommands: jest.fn().mockResolvedValue([
|
|
761
|
+
{ name: 'review', description: 'Review code', argumentHint: '' },
|
|
762
|
+
]),
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
(service as any).persistentQuery = staleQuery;
|
|
766
|
+
const staleFetch = (service as any).fetchAndCacheCommands(staleQuery);
|
|
767
|
+
|
|
768
|
+
(service as any).persistentQuery = activeQuery;
|
|
769
|
+
const activeCommands = await (service as any).fetchAndCacheCommands(activeQuery);
|
|
770
|
+
|
|
771
|
+
resolveStaleCommands!([
|
|
772
|
+
{ name: 'commit', description: 'Create a commit', argumentHint: '' },
|
|
773
|
+
]);
|
|
774
|
+
const staleCommands = await staleFetch;
|
|
775
|
+
|
|
776
|
+
expect(activeCommands).toEqual([{
|
|
777
|
+
id: 'sdk:review',
|
|
778
|
+
name: 'review',
|
|
779
|
+
description: 'Review code',
|
|
780
|
+
argumentHint: '',
|
|
781
|
+
content: '',
|
|
782
|
+
source: 'sdk',
|
|
783
|
+
}]);
|
|
784
|
+
expect(staleCommands).toEqual(activeCommands);
|
|
785
|
+
expect((service as any).cachedSdkCommands).toEqual(activeCommands);
|
|
786
|
+
});
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
describe('isPipeError', () => {
|
|
790
|
+
it('should return true for EPIPE code', () => {
|
|
791
|
+
const error = { code: 'EPIPE' };
|
|
792
|
+
expect((service as any).isPipeError(error)).toBe(true);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('should return true for EPIPE in message', () => {
|
|
796
|
+
const error = { message: 'write EPIPE to stdin' };
|
|
797
|
+
expect((service as any).isPipeError(error)).toBe(true);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('should return false for other errors', () => {
|
|
801
|
+
const error = { code: 'ENOENT', message: 'file not found' };
|
|
802
|
+
expect((service as any).isPipeError(error)).toBe(false);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
it('should return false for null', () => {
|
|
806
|
+
expect((service as any).isPipeError(null)).toBe(false);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
it('should return false for non-object', () => {
|
|
810
|
+
expect((service as any).isPipeError('string')).toBe(false);
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('should return false for undefined', () => {
|
|
814
|
+
expect((service as any).isPipeError(undefined)).toBe(false);
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
describe('buildSDKUserMessage', () => {
|
|
819
|
+
it('should build text-only message', () => {
|
|
820
|
+
const message = (service as any).buildSDKUserMessage('Hello Claude');
|
|
821
|
+
|
|
822
|
+
expect(message).toEqual({
|
|
823
|
+
type: 'user',
|
|
824
|
+
message: { role: 'user', content: 'Hello Claude' },
|
|
825
|
+
parent_tool_use_id: null,
|
|
826
|
+
session_id: '',
|
|
827
|
+
uuid: expect.any(String),
|
|
828
|
+
});
|
|
829
|
+
});
|
|
830
|
+
|
|
831
|
+
it('should include session ID when available', () => {
|
|
832
|
+
service.setSessionId('session-abc');
|
|
833
|
+
const message = (service as any).buildSDKUserMessage('Test');
|
|
834
|
+
|
|
835
|
+
expect(message.session_id).toBe('session-abc');
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('should build message with images', () => {
|
|
839
|
+
const images = [{
|
|
840
|
+
id: 'img1',
|
|
841
|
+
name: 'test.png',
|
|
842
|
+
mediaType: 'image/png',
|
|
843
|
+
data: 'base64data',
|
|
844
|
+
size: 100,
|
|
845
|
+
source: 'file',
|
|
846
|
+
}];
|
|
847
|
+
|
|
848
|
+
const message = (service as any).buildSDKUserMessage('Look at this', images);
|
|
849
|
+
|
|
850
|
+
expect(message.message.content).toEqual([
|
|
851
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } },
|
|
852
|
+
{ type: 'text', text: 'Look at this' },
|
|
853
|
+
]);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it('should omit text block when prompt is empty with images', () => {
|
|
857
|
+
const images = [{
|
|
858
|
+
id: 'img1',
|
|
859
|
+
name: 'test.png',
|
|
860
|
+
mediaType: 'image/png',
|
|
861
|
+
data: 'base64data',
|
|
862
|
+
size: 100,
|
|
863
|
+
source: 'file',
|
|
864
|
+
}];
|
|
865
|
+
|
|
866
|
+
const message = (service as any).buildSDKUserMessage(' ', images);
|
|
867
|
+
|
|
868
|
+
expect(message.message.content).toEqual([
|
|
869
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } },
|
|
870
|
+
]);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
it('should handle empty images array as text-only', () => {
|
|
874
|
+
const message = (service as any).buildSDKUserMessage('Hello', []);
|
|
875
|
+
|
|
876
|
+
expect(message.message.content).toBe('Hello');
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
describe('buildPromptWithImages', () => {
|
|
881
|
+
it('should return plain string when no images', () => {
|
|
882
|
+
const result = (service as any).buildPromptWithImages('Hello');
|
|
883
|
+
expect(result).toBe('Hello');
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it('should return plain string when images is undefined', () => {
|
|
887
|
+
const result = (service as any).buildPromptWithImages('Hello', undefined);
|
|
888
|
+
expect(result).toBe('Hello');
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it('should return plain string when images is empty', () => {
|
|
892
|
+
const result = (service as any).buildPromptWithImages('Hello', []);
|
|
893
|
+
expect(result).toBe('Hello');
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it('should return async generator when images are provided', async () => {
|
|
897
|
+
const images = [{
|
|
898
|
+
id: 'img1',
|
|
899
|
+
name: 'test.png',
|
|
900
|
+
mediaType: 'image/png',
|
|
901
|
+
data: 'base64data',
|
|
902
|
+
size: 100,
|
|
903
|
+
source: 'file',
|
|
904
|
+
}];
|
|
905
|
+
|
|
906
|
+
const result = (service as any).buildPromptWithImages('Describe', images);
|
|
907
|
+
|
|
908
|
+
// Should be an async generator
|
|
909
|
+
expect(typeof result[Symbol.asyncIterator]).toBe('function');
|
|
910
|
+
|
|
911
|
+
const messages: any[] = [];
|
|
912
|
+
for await (const msg of result) {
|
|
913
|
+
messages.push(msg);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
expect(messages).toHaveLength(1);
|
|
917
|
+
expect(messages[0].type).toBe('user');
|
|
918
|
+
expect(messages[0].message.role).toBe('user');
|
|
919
|
+
expect(messages[0].message.content).toEqual([
|
|
920
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } },
|
|
921
|
+
{ type: 'text', text: 'Describe' },
|
|
922
|
+
]);
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('should omit text when prompt is whitespace with images', async () => {
|
|
926
|
+
const images = [{
|
|
927
|
+
id: 'img1',
|
|
928
|
+
name: 'test.png',
|
|
929
|
+
mediaType: 'image/png',
|
|
930
|
+
data: 'base64data',
|
|
931
|
+
size: 100,
|
|
932
|
+
source: 'file',
|
|
933
|
+
}];
|
|
934
|
+
|
|
935
|
+
const result = (service as any).buildPromptWithImages(' ', images);
|
|
936
|
+
|
|
937
|
+
const messages: any[] = [];
|
|
938
|
+
for await (const msg of result) {
|
|
939
|
+
messages.push(msg);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
expect(messages[0].message.content).toEqual([
|
|
943
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'base64data' } },
|
|
944
|
+
]);
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
describe('consumeSessionInvalidation', () => {
|
|
949
|
+
it('should return false when no invalidation', () => {
|
|
950
|
+
expect(service.consumeSessionInvalidation()).toBe(false);
|
|
951
|
+
});
|
|
952
|
+
|
|
953
|
+
it('should delegate to sessionManager', () => {
|
|
954
|
+
const sessionManager = (service as any).sessionManager;
|
|
955
|
+
sessionManager.invalidateSession();
|
|
956
|
+
|
|
957
|
+
expect(service.consumeSessionInvalidation()).toBe(true);
|
|
958
|
+
// Should be consumed
|
|
959
|
+
expect(service.consumeSessionInvalidation()).toBe(false);
|
|
960
|
+
});
|
|
961
|
+
});
|
|
962
|
+
|
|
963
|
+
describe('Response Handler Management', () => {
|
|
964
|
+
it('should register and unregister handlers', () => {
|
|
965
|
+
const handler = createResponseHandler({
|
|
966
|
+
id: 'test-handler',
|
|
967
|
+
onChunk: jest.fn(),
|
|
968
|
+
onDone: jest.fn(),
|
|
969
|
+
onError: jest.fn(),
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
(service as any).registerResponseHandler(handler);
|
|
973
|
+
expect((service as any).responseHandlers).toHaveLength(1);
|
|
974
|
+
|
|
975
|
+
(service as any).unregisterResponseHandler('test-handler');
|
|
976
|
+
expect((service as any).responseHandlers).toHaveLength(0);
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
it('should not fail when unregistering non-existent handler', () => {
|
|
980
|
+
(service as any).unregisterResponseHandler('nonexistent');
|
|
981
|
+
expect((service as any).responseHandlers).toHaveLength(0);
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it('should register multiple handlers', () => {
|
|
985
|
+
const handler1 = createResponseHandler({
|
|
986
|
+
id: 'h1',
|
|
987
|
+
onChunk: jest.fn(),
|
|
988
|
+
onDone: jest.fn(),
|
|
989
|
+
onError: jest.fn(),
|
|
990
|
+
});
|
|
991
|
+
const handler2 = createResponseHandler({
|
|
992
|
+
id: 'h2',
|
|
993
|
+
onChunk: jest.fn(),
|
|
994
|
+
onDone: jest.fn(),
|
|
995
|
+
onError: jest.fn(),
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
(service as any).registerResponseHandler(handler1);
|
|
999
|
+
(service as any).registerResponseHandler(handler2);
|
|
1000
|
+
expect((service as any).responseHandlers).toHaveLength(2);
|
|
1001
|
+
|
|
1002
|
+
(service as any).unregisterResponseHandler('h1');
|
|
1003
|
+
expect((service as any).responseHandlers).toHaveLength(1);
|
|
1004
|
+
expect((service as any).responseHandlers[0].id).toBe('h2');
|
|
1005
|
+
});
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
describe('closePersistentQuery handler notification', () => {
|
|
1009
|
+
it('should call onDone on all handlers when not preserving', () => {
|
|
1010
|
+
const onDone1 = jest.fn();
|
|
1011
|
+
const onDone2 = jest.fn();
|
|
1012
|
+
const handler1 = createResponseHandler({ id: 'h1', onChunk: jest.fn(), onDone: onDone1, onError: jest.fn() });
|
|
1013
|
+
const handler2 = createResponseHandler({ id: 'h2', onChunk: jest.fn(), onDone: onDone2, onError: jest.fn() });
|
|
1014
|
+
|
|
1015
|
+
// Set up persistent query state
|
|
1016
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
1017
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
1018
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
1019
|
+
(service as any).responseHandlers = [handler1, handler2];
|
|
1020
|
+
|
|
1021
|
+
service.closePersistentQuery('test');
|
|
1022
|
+
|
|
1023
|
+
expect(onDone1).toHaveBeenCalled();
|
|
1024
|
+
expect(onDone2).toHaveBeenCalled();
|
|
1025
|
+
});
|
|
1026
|
+
|
|
1027
|
+
it('should NOT call onDone when preserving handlers', () => {
|
|
1028
|
+
const onDone = jest.fn();
|
|
1029
|
+
const handler = createResponseHandler({ id: 'h1', onChunk: jest.fn(), onDone, onError: jest.fn() });
|
|
1030
|
+
|
|
1031
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
1032
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
1033
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
1034
|
+
(service as any).responseHandlers = [handler];
|
|
1035
|
+
|
|
1036
|
+
service.closePersistentQuery('test', { preserveHandlers: true });
|
|
1037
|
+
|
|
1038
|
+
expect(onDone).not.toHaveBeenCalled();
|
|
1039
|
+
});
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
describe('Cancel with persistent query', () => {
|
|
1043
|
+
it('should interrupt persistent query on cancel', () => {
|
|
1044
|
+
const interruptMock = jest.fn().mockResolvedValue(undefined);
|
|
1045
|
+
(service as any).persistentQuery = { interrupt: interruptMock };
|
|
1046
|
+
(service as any).shuttingDown = false;
|
|
1047
|
+
|
|
1048
|
+
service.cancel();
|
|
1049
|
+
|
|
1050
|
+
expect(interruptMock).toHaveBeenCalled();
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('should not interrupt persistent query when shutting down', () => {
|
|
1054
|
+
const interruptMock = jest.fn().mockResolvedValue(undefined);
|
|
1055
|
+
(service as any).persistentQuery = { interrupt: interruptMock };
|
|
1056
|
+
(service as any).shuttingDown = true;
|
|
1057
|
+
|
|
1058
|
+
service.cancel();
|
|
1059
|
+
|
|
1060
|
+
expect(interruptMock).not.toHaveBeenCalled();
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
describe('createApprovalCallback - allowed tools restriction', () => {
|
|
1065
|
+
const canUseToolOptions = {
|
|
1066
|
+
signal: new AbortController().signal,
|
|
1067
|
+
toolUseID: 'test-tool-use-id',
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
it('should deny tools not in allowedTools list', async () => {
|
|
1071
|
+
(service as any).currentAllowedTools = ['Read', 'Glob'];
|
|
1072
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
1073
|
+
service.setApprovalCallback(callback);
|
|
1074
|
+
|
|
1075
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
1076
|
+
const result = await canUseTool('Bash', { command: 'ls' }, canUseToolOptions);
|
|
1077
|
+
|
|
1078
|
+
expect(result.behavior).toBe('deny');
|
|
1079
|
+
expect(result.message).toContain('not allowed');
|
|
1080
|
+
expect(result.message).toContain('Allowed tools: Read, Glob');
|
|
1081
|
+
expect(callback).not.toHaveBeenCalled();
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('should deny when allowedTools is empty', async () => {
|
|
1085
|
+
(service as any).currentAllowedTools = [];
|
|
1086
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
1087
|
+
service.setApprovalCallback(callback);
|
|
1088
|
+
|
|
1089
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
1090
|
+
const result = await canUseTool('Read', { file_path: 'test.md' }, canUseToolOptions);
|
|
1091
|
+
|
|
1092
|
+
expect(result.behavior).toBe('deny');
|
|
1093
|
+
expect(result.message).toContain('No tools are allowed');
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
it('should allow Skill tool even when not in allowedTools', async () => {
|
|
1097
|
+
(service as any).currentAllowedTools = ['Read'];
|
|
1098
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
1099
|
+
service.setApprovalCallback(callback);
|
|
1100
|
+
|
|
1101
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
1102
|
+
const result = await canUseTool('Skill', { name: 'commit' }, canUseToolOptions);
|
|
1103
|
+
|
|
1104
|
+
expect(result.behavior).toBe('allow');
|
|
1105
|
+
expect(callback).toHaveBeenCalled();
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
it('should allow tools in the allowedTools list', async () => {
|
|
1109
|
+
(service as any).currentAllowedTools = ['Read', 'Glob'];
|
|
1110
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
1111
|
+
service.setApprovalCallback(callback);
|
|
1112
|
+
|
|
1113
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
1114
|
+
const result = await canUseTool('Read', { file_path: 'test.md' }, canUseToolOptions);
|
|
1115
|
+
|
|
1116
|
+
expect(result.behavior).toBe('allow');
|
|
1117
|
+
expect(callback).toHaveBeenCalled();
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it('should not restrict when currentAllowedTools is null', async () => {
|
|
1121
|
+
(service as any).currentAllowedTools = null;
|
|
1122
|
+
const callback = jest.fn().mockResolvedValue('allow');
|
|
1123
|
+
service.setApprovalCallback(callback);
|
|
1124
|
+
|
|
1125
|
+
const canUseTool = (service as any).createApprovalCallback();
|
|
1126
|
+
const result = await canUseTool('Bash', { command: 'rm -rf /' }, canUseToolOptions);
|
|
1127
|
+
|
|
1128
|
+
expect(result.behavior).toBe('allow');
|
|
1129
|
+
expect(callback).toHaveBeenCalled();
|
|
1130
|
+
});
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
describe('routeMessage', () => {
|
|
1134
|
+
let handler: ReturnType<typeof createResponseHandler>;
|
|
1135
|
+
let onChunk: jest.Mock;
|
|
1136
|
+
let onDone: jest.Mock;
|
|
1137
|
+
|
|
1138
|
+
beforeEach(() => {
|
|
1139
|
+
onChunk = jest.fn();
|
|
1140
|
+
onDone = jest.fn();
|
|
1141
|
+
handler = createResponseHandler({
|
|
1142
|
+
id: 'route-test',
|
|
1143
|
+
onChunk,
|
|
1144
|
+
onDone,
|
|
1145
|
+
onError: jest.fn(),
|
|
1146
|
+
});
|
|
1147
|
+
(service as any).responseHandlers = [handler];
|
|
1148
|
+
(service as any).messageChannel = {
|
|
1149
|
+
onTurnComplete: jest.fn(),
|
|
1150
|
+
setSessionId: jest.fn(),
|
|
1151
|
+
};
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
it('should route session_init event and capture session', async () => {
|
|
1155
|
+
const message = { type: 'system', subtype: 'init', session_id: 'new-session-42' };
|
|
1156
|
+
|
|
1157
|
+
await (service as any).routeMessage(message);
|
|
1158
|
+
|
|
1159
|
+
expect(service.getSessionId()).toBe('new-session-42');
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it('should route stream chunks to handler', async () => {
|
|
1163
|
+
const message = {
|
|
1164
|
+
type: 'assistant',
|
|
1165
|
+
message: { content: [{ type: 'text', text: 'Hello' }] },
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
await (service as any).routeMessage(message);
|
|
1169
|
+
|
|
1170
|
+
expect(onChunk).toHaveBeenCalled();
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('should route task_notification completion to the active handler', async () => {
|
|
1174
|
+
await (service as any).routeMessage({
|
|
1175
|
+
type: 'system',
|
|
1176
|
+
subtype: 'task_notification',
|
|
1177
|
+
task_id: 'agent-123',
|
|
1178
|
+
status: 'completed',
|
|
1179
|
+
output_file: '/tmp/agent-123.output',
|
|
1180
|
+
summary: 'Agent completed successfully.',
|
|
1181
|
+
uuid: 'notification-1',
|
|
1182
|
+
session_id: 'session-1',
|
|
1183
|
+
});
|
|
1184
|
+
|
|
1185
|
+
expect(onChunk).toHaveBeenCalledWith({
|
|
1186
|
+
type: 'async_subagent_result',
|
|
1187
|
+
agentId: 'agent-123',
|
|
1188
|
+
status: 'completed',
|
|
1189
|
+
result: 'Agent completed successfully.',
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
it('should flush task_notification completion through auto-turn callback without waiting for a result message', async () => {
|
|
1194
|
+
(service as any).responseHandlers = [];
|
|
1195
|
+
const autoTurnCallback = jest.fn();
|
|
1196
|
+
service.setAutoTurnCallback(autoTurnCallback);
|
|
1197
|
+
|
|
1198
|
+
await (service as any).routeMessage({
|
|
1199
|
+
type: 'system',
|
|
1200
|
+
subtype: 'task_notification',
|
|
1201
|
+
task_id: 'agent-456',
|
|
1202
|
+
status: 'completed',
|
|
1203
|
+
output_file: '/tmp/agent-456.output',
|
|
1204
|
+
summary: 'Background agent finished.',
|
|
1205
|
+
uuid: 'notification-2',
|
|
1206
|
+
session_id: 'session-1',
|
|
1207
|
+
});
|
|
1208
|
+
|
|
1209
|
+
expect(autoTurnCallback).toHaveBeenCalledWith({
|
|
1210
|
+
chunks: [
|
|
1211
|
+
{
|
|
1212
|
+
type: 'async_subagent_result',
|
|
1213
|
+
agentId: 'agent-456',
|
|
1214
|
+
status: 'completed',
|
|
1215
|
+
result: 'Background agent finished.',
|
|
1216
|
+
},
|
|
1217
|
+
],
|
|
1218
|
+
metadata: {},
|
|
1219
|
+
});
|
|
1220
|
+
});
|
|
1221
|
+
|
|
1222
|
+
it('should route tool input deltas as tool_use updates', async () => {
|
|
1223
|
+
await (service as any).routeMessage({
|
|
1224
|
+
type: 'stream_event',
|
|
1225
|
+
event: {
|
|
1226
|
+
type: 'content_block_start',
|
|
1227
|
+
index: 0,
|
|
1228
|
+
content_block: {
|
|
1229
|
+
type: 'tool_use',
|
|
1230
|
+
id: 'stream-tool-1',
|
|
1231
|
+
name: 'Write',
|
|
1232
|
+
input: {},
|
|
1233
|
+
},
|
|
1234
|
+
},
|
|
1235
|
+
});
|
|
1236
|
+
await (service as any).routeMessage({
|
|
1237
|
+
type: 'stream_event',
|
|
1238
|
+
event: {
|
|
1239
|
+
type: 'content_block_delta',
|
|
1240
|
+
index: 0,
|
|
1241
|
+
delta: {
|
|
1242
|
+
type: 'input_json_delta',
|
|
1243
|
+
partial_json: '{"file_path":"notes.md"',
|
|
1244
|
+
},
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const toolChunks = onChunk.mock.calls
|
|
1249
|
+
.map(([chunk]) => chunk)
|
|
1250
|
+
.filter((chunk: any) => chunk.type === 'tool_use');
|
|
1251
|
+
|
|
1252
|
+
expect(toolChunks).toEqual([
|
|
1253
|
+
{
|
|
1254
|
+
type: 'tool_use',
|
|
1255
|
+
id: 'stream-tool-1',
|
|
1256
|
+
name: 'Write',
|
|
1257
|
+
input: {},
|
|
1258
|
+
},
|
|
1259
|
+
{
|
|
1260
|
+
type: 'tool_use',
|
|
1261
|
+
id: 'stream-tool-1',
|
|
1262
|
+
name: 'Write',
|
|
1263
|
+
input: { file_path: 'notes.md' },
|
|
1264
|
+
},
|
|
1265
|
+
]);
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it('should signal turn complete on result message', async () => {
|
|
1269
|
+
const message = { type: 'result', subtype: 'success', result: 'completed' };
|
|
1270
|
+
|
|
1271
|
+
await (service as any).routeMessage(message);
|
|
1272
|
+
|
|
1273
|
+
expect((service as any).messageChannel.onTurnComplete).toHaveBeenCalled();
|
|
1274
|
+
expect(onDone).toHaveBeenCalled();
|
|
1275
|
+
});
|
|
1276
|
+
|
|
1277
|
+
it('should yield error event from assistant message with error field', async () => {
|
|
1278
|
+
const message = { type: 'assistant', error: 'rate_limit', message: { content: [] } };
|
|
1279
|
+
|
|
1280
|
+
await (service as any).routeMessage(message);
|
|
1281
|
+
|
|
1282
|
+
expect(onChunk).toHaveBeenCalledWith(
|
|
1283
|
+
expect.objectContaining({ type: 'error', content: 'rate_limit' }),
|
|
1284
|
+
);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it('should add sessionId to usage chunks', async () => {
|
|
1288
|
+
service.setSessionId('usage-session');
|
|
1289
|
+
const message = {
|
|
1290
|
+
type: 'assistant',
|
|
1291
|
+
message: {
|
|
1292
|
+
content: [{ type: 'text', text: 'Response' }],
|
|
1293
|
+
usage: {
|
|
1294
|
+
input_tokens: 100,
|
|
1295
|
+
output_tokens: 50,
|
|
1296
|
+
cache_creation_input_tokens: 10,
|
|
1297
|
+
cache_read_input_tokens: 20,
|
|
1298
|
+
},
|
|
1299
|
+
},
|
|
1300
|
+
};
|
|
1301
|
+
|
|
1302
|
+
await (service as any).routeMessage(message);
|
|
1303
|
+
|
|
1304
|
+
const usageChunks = onChunk.mock.calls.filter(
|
|
1305
|
+
([chunk]: any) => chunk.type === 'usage'
|
|
1306
|
+
);
|
|
1307
|
+
expect(usageChunks.length).toBeGreaterThan(0);
|
|
1308
|
+
expect(usageChunks[0][0].sessionId).toBe('usage-session');
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('should mark stream text seen only after a visible stream text chunk', async () => {
|
|
1312
|
+
const message = {
|
|
1313
|
+
type: 'stream_event',
|
|
1314
|
+
event: { type: 'content_block_start', content_block: { type: 'text', text: 'Hello' } },
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
await (service as any).routeMessage(message);
|
|
1318
|
+
|
|
1319
|
+
expect(handler.sawStreamText).toBe(true);
|
|
1320
|
+
});
|
|
1321
|
+
|
|
1322
|
+
it('should not mark stream text seen for empty text deltas', async () => {
|
|
1323
|
+
const message = {
|
|
1324
|
+
type: 'stream_event',
|
|
1325
|
+
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: '' } },
|
|
1326
|
+
};
|
|
1327
|
+
|
|
1328
|
+
await (service as any).routeMessage(message);
|
|
1329
|
+
|
|
1330
|
+
expect(handler.sawStreamText).toBe(false);
|
|
1331
|
+
});
|
|
1332
|
+
|
|
1333
|
+
it('should mark stream thinking seen only after a visible stream thinking chunk', async () => {
|
|
1334
|
+
const message = {
|
|
1335
|
+
type: 'stream_event',
|
|
1336
|
+
event: { type: 'content_block_start', content_block: { type: 'thinking', thinking: 'Thinking...' } },
|
|
1337
|
+
};
|
|
1338
|
+
|
|
1339
|
+
await (service as any).routeMessage(message);
|
|
1340
|
+
|
|
1341
|
+
expect(handler.sawStreamThinking).toBe(true);
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it('should not mark stream thinking seen for empty thinking deltas', async () => {
|
|
1345
|
+
const message = {
|
|
1346
|
+
type: 'stream_event',
|
|
1347
|
+
event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: '' } },
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
await (service as any).routeMessage(message);
|
|
1351
|
+
|
|
1352
|
+
expect(handler.sawStreamThinking).toBe(false);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
it('should skip duplicate text from assistant messages after stream text', async () => {
|
|
1356
|
+
// First, mark stream text as seen
|
|
1357
|
+
handler.markStreamTextSeen();
|
|
1358
|
+
|
|
1359
|
+
// Now send an assistant message with text content
|
|
1360
|
+
const message = {
|
|
1361
|
+
type: 'assistant',
|
|
1362
|
+
message: { content: [{ type: 'text', text: 'Streamed text' }] },
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
await (service as any).routeMessage(message);
|
|
1366
|
+
|
|
1367
|
+
// Text chunks should be skipped
|
|
1368
|
+
const textChunks = onChunk.mock.calls.filter(
|
|
1369
|
+
([chunk]: any) => chunk.type === 'text'
|
|
1370
|
+
);
|
|
1371
|
+
expect(textChunks).toHaveLength(0);
|
|
1372
|
+
});
|
|
1373
|
+
|
|
1374
|
+
it('should skip duplicate thinking from assistant messages after visible stream thinking', async () => {
|
|
1375
|
+
await (service as any).routeMessage({
|
|
1376
|
+
type: 'stream_event',
|
|
1377
|
+
event: {
|
|
1378
|
+
type: 'content_block_delta',
|
|
1379
|
+
delta: { type: 'thinking_delta', thinking: 'Reasoning...' },
|
|
1380
|
+
},
|
|
1381
|
+
});
|
|
1382
|
+
await (service as any).routeMessage({
|
|
1383
|
+
type: 'assistant',
|
|
1384
|
+
message: { content: [{ type: 'thinking', thinking: 'Reasoning...' }] },
|
|
1385
|
+
});
|
|
1386
|
+
|
|
1387
|
+
const thinkingChunks = onChunk.mock.calls.filter(
|
|
1388
|
+
([chunk]: any) => chunk.type === 'thinking'
|
|
1389
|
+
);
|
|
1390
|
+
expect(thinkingChunks).toHaveLength(1);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it('should keep assistant text when stream delta was empty', async () => {
|
|
1394
|
+
await (service as any).routeMessage({
|
|
1395
|
+
type: 'stream_event',
|
|
1396
|
+
event: {
|
|
1397
|
+
type: 'content_block_delta',
|
|
1398
|
+
delta: { type: 'text_delta', text: '' },
|
|
1399
|
+
},
|
|
1400
|
+
});
|
|
1401
|
+
await (service as any).routeMessage({
|
|
1402
|
+
type: 'assistant',
|
|
1403
|
+
message: { content: [{ type: 'text', text: 'Final text' }] },
|
|
1404
|
+
});
|
|
1405
|
+
|
|
1406
|
+
const textChunks = onChunk.mock.calls.filter(
|
|
1407
|
+
([chunk]: any) => chunk.type === 'text'
|
|
1408
|
+
);
|
|
1409
|
+
expect(textChunks).toHaveLength(1);
|
|
1410
|
+
expect(textChunks[0][0].content).toBe('Final text');
|
|
1411
|
+
});
|
|
1412
|
+
|
|
1413
|
+
it('should keep assistant thinking when stream delta was empty', async () => {
|
|
1414
|
+
await (service as any).routeMessage({
|
|
1415
|
+
type: 'stream_event',
|
|
1416
|
+
event: {
|
|
1417
|
+
type: 'content_block_delta',
|
|
1418
|
+
delta: { type: 'thinking_delta', thinking: '' },
|
|
1419
|
+
},
|
|
1420
|
+
});
|
|
1421
|
+
await (service as any).routeMessage({
|
|
1422
|
+
type: 'assistant',
|
|
1423
|
+
message: { content: [{ type: 'thinking', thinking: 'Final thinking' }] },
|
|
1424
|
+
});
|
|
1425
|
+
|
|
1426
|
+
const thinkingChunks = onChunk.mock.calls.filter(
|
|
1427
|
+
([chunk]: any) => chunk.type === 'thinking'
|
|
1428
|
+
);
|
|
1429
|
+
expect(thinkingChunks).toHaveLength(1);
|
|
1430
|
+
expect(thinkingChunks[0][0].content).toBe('Final thinking');
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
it('should reset auto-turn stream-text dedup after a buffered turn completes', async () => {
|
|
1434
|
+
(service as any).responseHandlers = [];
|
|
1435
|
+
const autoTurnCallback = jest.fn();
|
|
1436
|
+
service.setAutoTurnCallback(autoTurnCallback);
|
|
1437
|
+
|
|
1438
|
+
await (service as any).routeMessage({
|
|
1439
|
+
type: 'stream_event',
|
|
1440
|
+
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: 'First chunk' } },
|
|
1441
|
+
});
|
|
1442
|
+
await (service as any).routeMessage({
|
|
1443
|
+
type: 'assistant',
|
|
1444
|
+
message: { content: [{ type: 'text', text: 'Deduped away' }] },
|
|
1445
|
+
});
|
|
1446
|
+
await (service as any).routeMessage({
|
|
1447
|
+
type: 'result',
|
|
1448
|
+
subtype: 'success',
|
|
1449
|
+
result: 'first turn complete',
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
await (service as any).routeMessage({
|
|
1453
|
+
type: 'assistant',
|
|
1454
|
+
message: { content: [{ type: 'text', text: 'Fresh auto-turn text' }] },
|
|
1455
|
+
});
|
|
1456
|
+
await (service as any).routeMessage({
|
|
1457
|
+
type: 'result',
|
|
1458
|
+
subtype: 'success',
|
|
1459
|
+
result: 'second turn complete',
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
expect(autoTurnCallback).toHaveBeenCalledTimes(2);
|
|
1463
|
+
expect(autoTurnCallback).toHaveBeenNthCalledWith(2, {
|
|
1464
|
+
chunks: [
|
|
1465
|
+
expect.objectContaining({ type: 'text', content: 'Fresh auto-turn text' }),
|
|
1466
|
+
],
|
|
1467
|
+
metadata: {},
|
|
1468
|
+
});
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1471
|
+
it('should notify when auto-turn callback rendering fails', async () => {
|
|
1472
|
+
(service as any).responseHandlers = [];
|
|
1473
|
+
const callbackError = new Error('renderer exploded');
|
|
1474
|
+
service.setAutoTurnCallback(() => {
|
|
1475
|
+
throw callbackError;
|
|
1476
|
+
});
|
|
1477
|
+
|
|
1478
|
+
await (service as any).routeMessage({
|
|
1479
|
+
type: 'assistant',
|
|
1480
|
+
message: { content: [{ type: 'text', text: 'Background result' }] },
|
|
1481
|
+
});
|
|
1482
|
+
await (service as any).routeMessage({
|
|
1483
|
+
type: 'result',
|
|
1484
|
+
subtype: 'success',
|
|
1485
|
+
result: 'turn complete',
|
|
1486
|
+
});
|
|
1487
|
+
|
|
1488
|
+
expect(Notice).toHaveBeenCalledWith(
|
|
1489
|
+
expect.stringContaining('Background task completed')
|
|
1490
|
+
);
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it('should suppress history rebuild and clear pendingForkSession on fork session init', async () => {
|
|
1494
|
+
// Set an existing session so captureSession detects a mismatch
|
|
1495
|
+
service.setSessionId('old-session');
|
|
1496
|
+
|
|
1497
|
+
// Apply fork state to mark as pending fork via syncConversationState
|
|
1498
|
+
service.syncConversationState({
|
|
1499
|
+
sessionId: null,
|
|
1500
|
+
providerState: { forkSource: { sessionId: 'old-session', resumeAt: 'asst-uuid-1' } },
|
|
1501
|
+
});
|
|
1502
|
+
expect((service as any).pendingForkSession).toBe(true);
|
|
1503
|
+
|
|
1504
|
+
// Simulate session_init from SDK with a NEW session ID (fork creates a new session)
|
|
1505
|
+
const message = { type: 'system', subtype: 'init', session_id: 'forked-session-new' };
|
|
1506
|
+
await (service as any).routeMessage(message);
|
|
1507
|
+
|
|
1508
|
+
// Session should be captured
|
|
1509
|
+
expect(service.getSessionId()).toBe('forked-session-new');
|
|
1510
|
+
// Fork path should suppress the history rebuild that captureSession would normally trigger
|
|
1511
|
+
expect((service as any).sessionManager.needsHistoryRebuild()).toBe(false);
|
|
1512
|
+
// pendingForkSession should be consumed (one-shot)
|
|
1513
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
it('should NOT suppress history rebuild for non-fork session mismatch', async () => {
|
|
1517
|
+
// Set an existing session so captureSession detects a mismatch
|
|
1518
|
+
service.setSessionId('old-session');
|
|
1519
|
+
// No fork state applied — this is a normal session mismatch
|
|
1520
|
+
|
|
1521
|
+
const message = { type: 'system', subtype: 'init', session_id: 'different-session' };
|
|
1522
|
+
await (service as any).routeMessage(message);
|
|
1523
|
+
|
|
1524
|
+
expect(service.getSessionId()).toBe('different-session');
|
|
1525
|
+
// Normal mismatch should trigger history rebuild
|
|
1526
|
+
expect((service as any).sessionManager.needsHistoryRebuild()).toBe(true);
|
|
1527
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
1528
|
+
});
|
|
1529
|
+
});
|
|
1530
|
+
|
|
1531
|
+
describe('applyDynamicUpdates', () => {
|
|
1532
|
+
let mockPersistentQuery: any;
|
|
1533
|
+
|
|
1534
|
+
beforeEach(async () => {
|
|
1535
|
+
sdkMock.resetMockMessages();
|
|
1536
|
+
|
|
1537
|
+
// Start a persistent query via ensureReady
|
|
1538
|
+
const startSpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
1539
|
+
startSpy.mockImplementation(async (...args: unknown[]) => {
|
|
1540
|
+
const [vaultPath, cliPath, , externalContextPaths] = args as [string, string, string?, string[]?];
|
|
1541
|
+
mockPersistentQuery = {
|
|
1542
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
1543
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
1544
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
1545
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
1546
|
+
applyFlagSettings: jest.fn().mockResolvedValue(undefined),
|
|
1547
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
1548
|
+
};
|
|
1549
|
+
(service as any).persistentQuery = mockPersistentQuery;
|
|
1550
|
+
(service as any).vaultPath = vaultPath;
|
|
1551
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, externalContextPaths);
|
|
1552
|
+
});
|
|
1553
|
+
|
|
1554
|
+
await service.ensureReady({ externalContextPaths: [] });
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it('should update model when changed', async () => {
|
|
1558
|
+
(mockPlugin as any).settings.model = 'claude-3-opus';
|
|
1559
|
+
|
|
1560
|
+
await (service as any).applyDynamicUpdates({ model: 'claude-3-opus' });
|
|
1561
|
+
|
|
1562
|
+
expect(mockPersistentQuery.setModel).toHaveBeenCalled();
|
|
1563
|
+
});
|
|
1564
|
+
|
|
1565
|
+
it('should not update model when unchanged', async () => {
|
|
1566
|
+
await (service as any).applyDynamicUpdates({ model: 'claude-3-5-sonnet' });
|
|
1567
|
+
|
|
1568
|
+
expect(mockPersistentQuery.setModel).not.toHaveBeenCalled();
|
|
1569
|
+
});
|
|
1570
|
+
|
|
1571
|
+
it('should ignore legacy thinking budget changes', async () => {
|
|
1572
|
+
(mockPlugin as any).settings.model = 'custom-model';
|
|
1573
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1574
|
+
'/mock/vault/path',
|
|
1575
|
+
'/usr/local/bin/claude',
|
|
1576
|
+
[],
|
|
1577
|
+
);
|
|
1578
|
+
(mockPlugin as any).settings.thinkingBudget = 'high';
|
|
1579
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
1580
|
+
|
|
1581
|
+
await (service as any).applyDynamicUpdates({});
|
|
1582
|
+
|
|
1583
|
+
expect(mockPersistentQuery.setMaxThinkingTokens).not.toHaveBeenCalled();
|
|
1584
|
+
expect(ensureReadySpy).not.toHaveBeenCalled();
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
it('should update effort level when changed for adaptive models', async () => {
|
|
1588
|
+
(mockPlugin as any).settings.model = 'sonnet';
|
|
1589
|
+
(mockPlugin as any).settings.effortLevel = 'max';
|
|
1590
|
+
|
|
1591
|
+
await (service as any).applyDynamicUpdates({});
|
|
1592
|
+
|
|
1593
|
+
expect(mockPersistentQuery.applyFlagSettings).toHaveBeenCalledWith({ effortLevel: 'max' });
|
|
1594
|
+
expect((service as any).currentConfig.effortLevel).toBe('max');
|
|
1595
|
+
});
|
|
1596
|
+
|
|
1597
|
+
it('should update effort level for custom model ids', async () => {
|
|
1598
|
+
(mockPlugin as any).settings.model = 'custom-model';
|
|
1599
|
+
(mockPlugin as any).settings.effortLevel = 'max';
|
|
1600
|
+
|
|
1601
|
+
await (service as any).applyDynamicUpdates({});
|
|
1602
|
+
|
|
1603
|
+
expect(mockPersistentQuery.applyFlagSettings).toHaveBeenCalledWith({ effortLevel: 'max' });
|
|
1604
|
+
});
|
|
1605
|
+
|
|
1606
|
+
it('should keep effort active when switching from custom to built-in model ids', async () => {
|
|
1607
|
+
(mockPlugin as any).settings.model = 'custom-model';
|
|
1608
|
+
(mockPlugin as any).settings.thinkingBudget = 'high';
|
|
1609
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1610
|
+
'/mock/vault/path',
|
|
1611
|
+
'/usr/local/bin/claude',
|
|
1612
|
+
[],
|
|
1613
|
+
);
|
|
1614
|
+
|
|
1615
|
+
mockPersistentQuery.setModel.mockClear();
|
|
1616
|
+
mockPersistentQuery.setMaxThinkingTokens.mockClear();
|
|
1617
|
+
mockPersistentQuery.applyFlagSettings.mockClear();
|
|
1618
|
+
|
|
1619
|
+
(mockPlugin as any).settings.model = 'sonnet';
|
|
1620
|
+
(mockPlugin as any).settings.effortLevel = 'max';
|
|
1621
|
+
|
|
1622
|
+
const previousQuery = mockPersistentQuery;
|
|
1623
|
+
await (service as any).applyDynamicUpdates({});
|
|
1624
|
+
|
|
1625
|
+
expect(previousQuery.setMaxThinkingTokens).not.toHaveBeenCalled();
|
|
1626
|
+
expect((service as any).currentConfig.effortLevel).toBe('max');
|
|
1627
|
+
});
|
|
1628
|
+
|
|
1629
|
+
it('should keep effort active when switching from built-in to custom model ids', async () => {
|
|
1630
|
+
(mockPlugin as any).settings.model = 'sonnet';
|
|
1631
|
+
(mockPlugin as any).settings.thinkingBudget = 'high';
|
|
1632
|
+
(mockPlugin as any).settings.effortLevel = 'max';
|
|
1633
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1634
|
+
'/mock/vault/path',
|
|
1635
|
+
'/usr/local/bin/claude',
|
|
1636
|
+
[],
|
|
1637
|
+
);
|
|
1638
|
+
|
|
1639
|
+
mockPersistentQuery.setModel.mockClear();
|
|
1640
|
+
mockPersistentQuery.setMaxThinkingTokens.mockClear();
|
|
1641
|
+
mockPersistentQuery.applyFlagSettings.mockClear();
|
|
1642
|
+
|
|
1643
|
+
(mockPlugin as any).settings.model = 'custom-model';
|
|
1644
|
+
|
|
1645
|
+
const previousQuery = mockPersistentQuery;
|
|
1646
|
+
await (service as any).applyDynamicUpdates({});
|
|
1647
|
+
|
|
1648
|
+
expect(previousQuery.setMaxThinkingTokens).not.toHaveBeenCalled();
|
|
1649
|
+
expect((service as any).currentConfig.effortLevel).toBe('max');
|
|
1650
|
+
});
|
|
1651
|
+
|
|
1652
|
+
it('should update permission mode when changed', async () => {
|
|
1653
|
+
(mockPlugin as any).settings.permissionMode = 'yolo';
|
|
1654
|
+
|
|
1655
|
+
await (service as any).applyDynamicUpdates({});
|
|
1656
|
+
|
|
1657
|
+
expect(mockPersistentQuery.setPermissionMode).toHaveBeenCalledWith('bypassPermissions');
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it('should update permission mode when claudeSafeMode changes within normal mode', async () => {
|
|
1661
|
+
(mockPlugin as any).settings.permissionMode = 'normal';
|
|
1662
|
+
(mockPlugin as any).settings.claudeSafeMode = 'acceptEdits';
|
|
1663
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1664
|
+
'/mock/vault/path',
|
|
1665
|
+
'/usr/local/bin/claude',
|
|
1666
|
+
[],
|
|
1667
|
+
);
|
|
1668
|
+
|
|
1669
|
+
mockPersistentQuery.setPermissionMode.mockClear();
|
|
1670
|
+
(mockPlugin as any).settings.claudeSafeMode = 'default';
|
|
1671
|
+
|
|
1672
|
+
await (service as any).applyDynamicUpdates({});
|
|
1673
|
+
|
|
1674
|
+
expect(mockPersistentQuery.setPermissionMode).toHaveBeenCalledWith('default');
|
|
1675
|
+
expect((service as any).currentConfig.permissionMode).toBe('normal');
|
|
1676
|
+
expect((service as any).currentConfig.sdkPermissionMode).toBe('default');
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
it('should update permission mode when claudeSafeMode switches back to acceptEdits', async () => {
|
|
1680
|
+
(mockPlugin as any).settings.permissionMode = 'normal';
|
|
1681
|
+
(mockPlugin as any).settings.claudeSafeMode = 'default';
|
|
1682
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1683
|
+
'/mock/vault/path',
|
|
1684
|
+
'/usr/local/bin/claude',
|
|
1685
|
+
[],
|
|
1686
|
+
);
|
|
1687
|
+
|
|
1688
|
+
mockPersistentQuery.setPermissionMode.mockClear();
|
|
1689
|
+
(mockPlugin as any).settings.claudeSafeMode = 'acceptEdits';
|
|
1690
|
+
|
|
1691
|
+
await (service as any).applyDynamicUpdates({});
|
|
1692
|
+
|
|
1693
|
+
expect(mockPersistentQuery.setPermissionMode).toHaveBeenCalledWith('acceptEdits');
|
|
1694
|
+
expect((service as any).currentConfig.permissionMode).toBe('normal');
|
|
1695
|
+
expect((service as any).currentConfig.sdkPermissionMode).toBe('acceptEdits');
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it('should restart before applying auto mode when auto opt-in was not enabled', async () => {
|
|
1699
|
+
(mockPlugin as any).settings.permissionMode = 'normal';
|
|
1700
|
+
(mockPlugin as any).settings.claudeSafeMode = 'acceptEdits';
|
|
1701
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1702
|
+
'/mock/vault/path',
|
|
1703
|
+
'/usr/local/bin/claude',
|
|
1704
|
+
[],
|
|
1705
|
+
);
|
|
1706
|
+
|
|
1707
|
+
mockPersistentQuery.setPermissionMode.mockClear();
|
|
1708
|
+
(service as any).startPersistentQuery.mockClear();
|
|
1709
|
+
(mockPlugin as any).settings.claudeSafeMode = 'auto';
|
|
1710
|
+
|
|
1711
|
+
await (service as any).applyDynamicUpdates({});
|
|
1712
|
+
|
|
1713
|
+
expect(mockPersistentQuery.setPermissionMode).not.toHaveBeenCalled();
|
|
1714
|
+
expect((service as any).startPersistentQuery).toHaveBeenCalledTimes(1);
|
|
1715
|
+
expect((service as any).currentConfig.permissionMode).toBe('normal');
|
|
1716
|
+
expect((service as any).currentConfig.sdkPermissionMode).toBe('auto');
|
|
1717
|
+
expect((service as any).currentConfig.enableAutoMode).toBe(true);
|
|
1718
|
+
});
|
|
1719
|
+
|
|
1720
|
+
it('should update permission mode to auto dynamically when auto opt-in is already enabled', async () => {
|
|
1721
|
+
(mockPlugin as any).settings.permissionMode = 'yolo';
|
|
1722
|
+
(mockPlugin as any).settings.claudeSafeMode = 'auto';
|
|
1723
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1724
|
+
'/mock/vault/path',
|
|
1725
|
+
'/usr/local/bin/claude',
|
|
1726
|
+
[],
|
|
1727
|
+
);
|
|
1728
|
+
|
|
1729
|
+
mockPersistentQuery.setPermissionMode.mockClear();
|
|
1730
|
+
(service as any).startPersistentQuery.mockClear();
|
|
1731
|
+
(mockPlugin as any).settings.permissionMode = 'normal';
|
|
1732
|
+
|
|
1733
|
+
await (service as any).applyDynamicUpdates({});
|
|
1734
|
+
|
|
1735
|
+
expect(mockPersistentQuery.setPermissionMode).toHaveBeenCalledWith('auto');
|
|
1736
|
+
expect((service as any).startPersistentQuery).not.toHaveBeenCalled();
|
|
1737
|
+
expect((service as any).currentConfig.permissionMode).toBe('normal');
|
|
1738
|
+
expect((service as any).currentConfig.sdkPermissionMode).toBe('auto');
|
|
1739
|
+
expect((service as any).currentConfig.enableAutoMode).toBe(true);
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
it('should update MCP servers when changed', async () => {
|
|
1743
|
+
mockMcpManager.getActiveServers.mockReturnValue({
|
|
1744
|
+
'test-server': { command: 'test', args: [] },
|
|
1745
|
+
});
|
|
1746
|
+
|
|
1747
|
+
await (service as any).applyDynamicUpdates({
|
|
1748
|
+
mcpMentions: new Set(['test-server']),
|
|
1749
|
+
});
|
|
1750
|
+
|
|
1751
|
+
expect(mockPersistentQuery.setMcpServers).toHaveBeenCalled();
|
|
1752
|
+
});
|
|
1753
|
+
|
|
1754
|
+
it('should not restart when allowRestart is false', async () => {
|
|
1755
|
+
// Change something that would trigger restart
|
|
1756
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockReturnValue('/new/path/to/claude');
|
|
1757
|
+
|
|
1758
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady');
|
|
1759
|
+
|
|
1760
|
+
await (service as any).applyDynamicUpdates({}, undefined, false);
|
|
1761
|
+
|
|
1762
|
+
// ensureReady should NOT be called for restart when allowRestart is false
|
|
1763
|
+
expect(ensureReadySpy).not.toHaveBeenCalled();
|
|
1764
|
+
});
|
|
1765
|
+
|
|
1766
|
+
it('should return early when no persistent query', async () => {
|
|
1767
|
+
(service as any).persistentQuery = null;
|
|
1768
|
+
|
|
1769
|
+
// Should not throw
|
|
1770
|
+
await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined();
|
|
1771
|
+
});
|
|
1772
|
+
|
|
1773
|
+
it('should return early when no vault path', async () => {
|
|
1774
|
+
(service as any).vaultPath = null;
|
|
1775
|
+
|
|
1776
|
+
await (service as any).applyDynamicUpdates({});
|
|
1777
|
+
|
|
1778
|
+
expect(mockPersistentQuery.setModel).not.toHaveBeenCalled();
|
|
1779
|
+
});
|
|
1780
|
+
|
|
1781
|
+
it('should silently handle model update error', async () => {
|
|
1782
|
+
(mockPlugin as any).settings.model = 'claude-3-opus';
|
|
1783
|
+
mockPersistentQuery.setModel.mockRejectedValueOnce(new Error('Model error'));
|
|
1784
|
+
|
|
1785
|
+
await expect((service as any).applyDynamicUpdates({ model: 'claude-3-opus' })).resolves.toBeUndefined();
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
it('should not dynamically update legacy thinking budget', async () => {
|
|
1789
|
+
(mockPlugin as any).settings.model = 'custom-model';
|
|
1790
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(
|
|
1791
|
+
'/mock/vault/path',
|
|
1792
|
+
'/usr/local/bin/claude',
|
|
1793
|
+
[],
|
|
1794
|
+
);
|
|
1795
|
+
(mockPlugin as any).settings.thinkingBudget = 'high';
|
|
1796
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
1797
|
+
|
|
1798
|
+
await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined();
|
|
1799
|
+
expect(mockPersistentQuery.setMaxThinkingTokens).not.toHaveBeenCalled();
|
|
1800
|
+
expect(ensureReadySpy).not.toHaveBeenCalled();
|
|
1801
|
+
});
|
|
1802
|
+
|
|
1803
|
+
it('should silently handle permission mode update error', async () => {
|
|
1804
|
+
(mockPlugin as any).settings.permissionMode = 'yolo';
|
|
1805
|
+
mockPersistentQuery.setPermissionMode.mockRejectedValueOnce(new Error('Permission error'));
|
|
1806
|
+
|
|
1807
|
+
await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined();
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it('should silently handle effort level update error', async () => {
|
|
1811
|
+
(mockPlugin as any).settings.model = 'sonnet';
|
|
1812
|
+
(mockPlugin as any).settings.effortLevel = 'max';
|
|
1813
|
+
mockPersistentQuery.applyFlagSettings.mockRejectedValueOnce(new Error('Effort error'));
|
|
1814
|
+
|
|
1815
|
+
await expect((service as any).applyDynamicUpdates({})).resolves.toBeUndefined();
|
|
1816
|
+
});
|
|
1817
|
+
|
|
1818
|
+
it('should silently handle MCP servers update error', async () => {
|
|
1819
|
+
mockMcpManager.getActiveServers.mockReturnValue({ 'server-1': { command: 'cmd' } });
|
|
1820
|
+
mockPersistentQuery.setMcpServers.mockRejectedValueOnce(new Error('MCP error'));
|
|
1821
|
+
|
|
1822
|
+
await expect((service as any).applyDynamicUpdates({ mcpMentions: new Set(['server-1']) })).resolves.toBeUndefined();
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
describe('query() method', () => {
|
|
1827
|
+
beforeEach(() => {
|
|
1828
|
+
sdkMock.resetMockMessages();
|
|
1829
|
+
});
|
|
1830
|
+
|
|
1831
|
+
afterEach(() => {
|
|
1832
|
+
sdkMock.resetMockMessages();
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
it('should yield error when vault path is not available', async () => {
|
|
1836
|
+
(mockPlugin as any).app.vault.adapter.basePath = undefined;
|
|
1837
|
+
|
|
1838
|
+
const chunks = await collectChunks(service.query('hello'));
|
|
1839
|
+
|
|
1840
|
+
expect(chunks).toEqual([{ type: 'error', content: 'Could not determine vault path' }]);
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1843
|
+
it('should yield error when CLI path is not available', async () => {
|
|
1844
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockReturnValue(null);
|
|
1845
|
+
|
|
1846
|
+
const chunks = await collectChunks(service.query('hello'));
|
|
1847
|
+
|
|
1848
|
+
expect(chunks).toEqual([{ type: 'error', content: expect.stringContaining('Claude CLI not found') }]);
|
|
1849
|
+
});
|
|
1850
|
+
|
|
1851
|
+
it('should yield chunks from cold-start query', async () => {
|
|
1852
|
+
sdkMock.setMockMessages([
|
|
1853
|
+
{ type: 'system', subtype: 'init', session_id: 'cold-session' },
|
|
1854
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hi!' }] } },
|
|
1855
|
+
]);
|
|
1856
|
+
|
|
1857
|
+
const chunks = await collectChunks(service.query('hello'));
|
|
1858
|
+
|
|
1859
|
+
expect(chunks.length).toBeGreaterThan(0);
|
|
1860
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
1861
|
+
expect(doneChunks).toHaveLength(1);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it('should capture session ID from cold-start response', async () => {
|
|
1865
|
+
sdkMock.setMockMessages([
|
|
1866
|
+
{ type: 'system', subtype: 'init', session_id: 'captured-session' },
|
|
1867
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
|
|
1868
|
+
]);
|
|
1869
|
+
|
|
1870
|
+
await collectChunks(service.query('hello'));
|
|
1871
|
+
|
|
1872
|
+
expect(service.getSessionId()).toBe('captured-session');
|
|
1873
|
+
});
|
|
1874
|
+
|
|
1875
|
+
it('should use persistent query when available', async () => {
|
|
1876
|
+
sdkMock.setMockMessages([
|
|
1877
|
+
{ type: 'system', subtype: 'init', session_id: 'persistent-session' },
|
|
1878
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } },
|
|
1879
|
+
]);
|
|
1880
|
+
|
|
1881
|
+
// Start a real persistent query via ensureReady mocking
|
|
1882
|
+
const startSpy = jest.spyOn(service as any, 'startPersistentQuery');
|
|
1883
|
+
startSpy.mockImplementation(async (...args: unknown[]) => {
|
|
1884
|
+
const [vaultPath, cliPath] = args as [string, string];
|
|
1885
|
+
const messageChannel = new MessageChannel();
|
|
1886
|
+
(service as any).messageChannel = messageChannel;
|
|
1887
|
+
(service as any).persistentQuery = sdkMock.query({ prompt: messageChannel, options: { cwd: vaultPath, pathToClaudeCodeExecutable: cliPath } as any });
|
|
1888
|
+
(service as any).currentConfig = (service as any).buildPersistentQueryConfig(vaultPath, cliPath, []);
|
|
1889
|
+
(service as any).startResponseConsumer();
|
|
1890
|
+
});
|
|
1891
|
+
|
|
1892
|
+
await service.ensureReady();
|
|
1893
|
+
|
|
1894
|
+
const chunks = await collectChunks(service.query('hello'));
|
|
1895
|
+
|
|
1896
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
1897
|
+
expect(doneChunks).toHaveLength(1);
|
|
1898
|
+
});
|
|
1899
|
+
|
|
1900
|
+
it('should rebuild history context when no session but has history', async () => {
|
|
1901
|
+
sdkMock.setMockMessages([
|
|
1902
|
+
{ type: 'system', subtype: 'init', session_id: 'new-session' },
|
|
1903
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
1904
|
+
]);
|
|
1905
|
+
|
|
1906
|
+
const history: any[] = [
|
|
1907
|
+
{ id: '1', role: 'user', content: 'First question', timestamp: 1000 },
|
|
1908
|
+
{ id: '2', role: 'assistant', content: 'First answer', timestamp: 1001 },
|
|
1909
|
+
];
|
|
1910
|
+
|
|
1911
|
+
// No session set, but has history → should force cold start
|
|
1912
|
+
const chunks = await collectChunks(service.query('follow up', undefined, history));
|
|
1913
|
+
|
|
1914
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
1915
|
+
expect(doneChunks).toHaveLength(1);
|
|
1916
|
+
});
|
|
1917
|
+
|
|
1918
|
+
it('should handle errors in cold-start query', async () => {
|
|
1919
|
+
// Provide at least one message so the iterator runs and crash triggers
|
|
1920
|
+
sdkMock.setMockMessages([
|
|
1921
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } },
|
|
1922
|
+
]);
|
|
1923
|
+
|
|
1924
|
+
// Crash on first iteration (before emitting any message)
|
|
1925
|
+
sdkMock.simulateCrash(0);
|
|
1926
|
+
|
|
1927
|
+
// Force cold-start to test the cold-start error handling path
|
|
1928
|
+
const chunks = await collectChunks(
|
|
1929
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
1930
|
+
);
|
|
1931
|
+
|
|
1932
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
1933
|
+
expect(errorChunks).toHaveLength(1);
|
|
1934
|
+
expect(errorChunks[0].content).toContain('Simulated consumer crash');
|
|
1935
|
+
});
|
|
1936
|
+
});
|
|
1937
|
+
|
|
1938
|
+
describe('buildHistoryRebuildRequest', () => {
|
|
1939
|
+
it('should build request with history context', () => {
|
|
1940
|
+
const history: any[] = [
|
|
1941
|
+
{ id: '1', role: 'user', content: 'Tell me about X', timestamp: 1000 },
|
|
1942
|
+
{ id: '2', role: 'assistant', content: 'X is great', timestamp: 1001 },
|
|
1943
|
+
];
|
|
1944
|
+
|
|
1945
|
+
const result = (service as any).buildHistoryRebuildRequest('New question', history);
|
|
1946
|
+
|
|
1947
|
+
expect(result.prompt).toContain('Tell me about X');
|
|
1948
|
+
expect(result.prompt).toContain('X is great');
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
it('should include images from last user message', () => {
|
|
1952
|
+
const images = [{ id: 'img1', mediaType: 'image/png', data: 'abc', name: 'test.png', size: 100, source: 'file' }];
|
|
1953
|
+
const history: any[] = [
|
|
1954
|
+
{ id: '1', role: 'user', content: 'Look', timestamp: 1000, images },
|
|
1955
|
+
];
|
|
1956
|
+
|
|
1957
|
+
const result = (service as any).buildHistoryRebuildRequest('Follow up', history);
|
|
1958
|
+
|
|
1959
|
+
expect(result.images).toEqual(images);
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1962
|
+
it('should return undefined images when last user message has no images', () => {
|
|
1963
|
+
const history: any[] = [
|
|
1964
|
+
{ id: '1', role: 'user', content: 'No images', timestamp: 1000 },
|
|
1965
|
+
];
|
|
1966
|
+
|
|
1967
|
+
const result = (service as any).buildHistoryRebuildRequest('Follow up', history);
|
|
1968
|
+
|
|
1969
|
+
expect(result.images).toBeUndefined();
|
|
1970
|
+
});
|
|
1971
|
+
});
|
|
1972
|
+
|
|
1973
|
+
describe('startPersistentQuery guard', () => {
|
|
1974
|
+
it('should not start if already running', async () => {
|
|
1975
|
+
(service as any).persistentQuery = { interrupt: jest.fn() };
|
|
1976
|
+
const buildOptsSpy = jest.spyOn(service as any, 'buildPersistentQueryOptions');
|
|
1977
|
+
|
|
1978
|
+
await (service as any).startPersistentQuery('/vault', '/cli', 'session');
|
|
1979
|
+
|
|
1980
|
+
expect(buildOptsSpy).not.toHaveBeenCalled();
|
|
1981
|
+
});
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
describe('attachPersistentQueryStdinErrorHandler', () => {
|
|
1985
|
+
it('should attach error handler to stdin', () => {
|
|
1986
|
+
const onMock = jest.fn();
|
|
1987
|
+
const onceMock = jest.fn();
|
|
1988
|
+
const mockQuery = {
|
|
1989
|
+
transport: {
|
|
1990
|
+
processStdin: {
|
|
1991
|
+
on: onMock,
|
|
1992
|
+
once: onceMock,
|
|
1993
|
+
},
|
|
1994
|
+
},
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
(service as any).attachPersistentQueryStdinErrorHandler(mockQuery);
|
|
1998
|
+
|
|
1999
|
+
expect(onMock).toHaveBeenCalledWith('error', expect.any(Function));
|
|
2000
|
+
expect(onceMock).toHaveBeenCalledWith('close', expect.any(Function));
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it('should handle query without transport', () => {
|
|
2004
|
+
const mockQuery = {};
|
|
2005
|
+
|
|
2006
|
+
// Should not throw
|
|
2007
|
+
expect(() => (service as any).attachPersistentQueryStdinErrorHandler(mockQuery)).not.toThrow();
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it('should handle query with transport but no processStdin', () => {
|
|
2011
|
+
const mockQuery = { transport: {} };
|
|
2012
|
+
|
|
2013
|
+
expect(() => (service as any).attachPersistentQueryStdinErrorHandler(mockQuery)).not.toThrow();
|
|
2014
|
+
});
|
|
2015
|
+
|
|
2016
|
+
it('should close persistent query on non-pipe error when not shutting down', () => {
|
|
2017
|
+
const closeSpy = jest.spyOn(service, 'closePersistentQuery');
|
|
2018
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
2019
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
2020
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
2021
|
+
(service as any).shuttingDown = false;
|
|
2022
|
+
|
|
2023
|
+
let errorHandler: (error: any) => void;
|
|
2024
|
+
const mockQuery = {
|
|
2025
|
+
transport: {
|
|
2026
|
+
processStdin: {
|
|
2027
|
+
on: jest.fn((event: string, handler: any) => {
|
|
2028
|
+
if (event === 'error') errorHandler = handler;
|
|
2029
|
+
}),
|
|
2030
|
+
once: jest.fn(),
|
|
2031
|
+
removeListener: jest.fn(),
|
|
2032
|
+
},
|
|
2033
|
+
},
|
|
2034
|
+
};
|
|
2035
|
+
|
|
2036
|
+
(service as any).attachPersistentQueryStdinErrorHandler(mockQuery);
|
|
2037
|
+
|
|
2038
|
+
// Trigger non-pipe error
|
|
2039
|
+
errorHandler!({ code: 'ECONNRESET', message: 'Connection reset' });
|
|
2040
|
+
|
|
2041
|
+
expect(closeSpy).toHaveBeenCalledWith('stdin error');
|
|
2042
|
+
});
|
|
2043
|
+
|
|
2044
|
+
it('should NOT close persistent query on EPIPE error', () => {
|
|
2045
|
+
const closeSpy = jest.spyOn(service, 'closePersistentQuery');
|
|
2046
|
+
(service as any).shuttingDown = false;
|
|
2047
|
+
|
|
2048
|
+
let errorHandler: (error: any) => void;
|
|
2049
|
+
const mockQuery = {
|
|
2050
|
+
transport: {
|
|
2051
|
+
processStdin: {
|
|
2052
|
+
on: jest.fn((event: string, handler: any) => {
|
|
2053
|
+
if (event === 'error') errorHandler = handler;
|
|
2054
|
+
}),
|
|
2055
|
+
once: jest.fn(),
|
|
2056
|
+
removeListener: jest.fn(),
|
|
2057
|
+
},
|
|
2058
|
+
},
|
|
2059
|
+
};
|
|
2060
|
+
|
|
2061
|
+
(service as any).attachPersistentQueryStdinErrorHandler(mockQuery);
|
|
2062
|
+
|
|
2063
|
+
// Trigger EPIPE error
|
|
2064
|
+
errorHandler!({ code: 'EPIPE' });
|
|
2065
|
+
|
|
2066
|
+
expect(closeSpy).not.toHaveBeenCalled();
|
|
2067
|
+
});
|
|
2068
|
+
|
|
2069
|
+
it('should NOT close persistent query when shutting down', () => {
|
|
2070
|
+
const closeSpy = jest.spyOn(service, 'closePersistentQuery');
|
|
2071
|
+
(service as any).shuttingDown = true;
|
|
2072
|
+
|
|
2073
|
+
let errorHandler: (error: any) => void;
|
|
2074
|
+
const mockQuery = {
|
|
2075
|
+
transport: {
|
|
2076
|
+
processStdin: {
|
|
2077
|
+
on: jest.fn((event: string, handler: any) => {
|
|
2078
|
+
if (event === 'error') errorHandler = handler;
|
|
2079
|
+
}),
|
|
2080
|
+
once: jest.fn(),
|
|
2081
|
+
removeListener: jest.fn(),
|
|
2082
|
+
},
|
|
2083
|
+
},
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
(service as any).attachPersistentQueryStdinErrorHandler(mockQuery);
|
|
2087
|
+
|
|
2088
|
+
errorHandler!({ code: 'ECONNRESET' });
|
|
2089
|
+
|
|
2090
|
+
expect(closeSpy).not.toHaveBeenCalled();
|
|
2091
|
+
});
|
|
2092
|
+
|
|
2093
|
+
it('should remove error handler on close', () => {
|
|
2094
|
+
const removeListenerMock = jest.fn();
|
|
2095
|
+
let closeHandler: () => void;
|
|
2096
|
+
|
|
2097
|
+
const mockQuery = {
|
|
2098
|
+
transport: {
|
|
2099
|
+
processStdin: {
|
|
2100
|
+
on: jest.fn(),
|
|
2101
|
+
once: jest.fn((_event: string, handler: any) => {
|
|
2102
|
+
closeHandler = handler;
|
|
2103
|
+
}),
|
|
2104
|
+
removeListener: removeListenerMock,
|
|
2105
|
+
},
|
|
2106
|
+
},
|
|
2107
|
+
};
|
|
2108
|
+
|
|
2109
|
+
(service as any).attachPersistentQueryStdinErrorHandler(mockQuery);
|
|
2110
|
+
|
|
2111
|
+
// Trigger close
|
|
2112
|
+
closeHandler!();
|
|
2113
|
+
|
|
2114
|
+
expect(removeListenerMock).toHaveBeenCalledWith('error', expect.any(Function));
|
|
2115
|
+
});
|
|
2116
|
+
});
|
|
2117
|
+
|
|
2118
|
+
describe('query() - missing node error', () => {
|
|
2119
|
+
beforeEach(() => {
|
|
2120
|
+
sdkMock.resetMockMessages();
|
|
2121
|
+
});
|
|
2122
|
+
|
|
2123
|
+
afterEach(() => {
|
|
2124
|
+
sdkMock.resetMockMessages();
|
|
2125
|
+
jest.restoreAllMocks();
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
|
|
2129
|
+
it('should yield error when Node.js is missing', async () => {
|
|
2130
|
+
jest.spyOn(envUtils, 'getMissingNodeError').mockReturnValueOnce(
|
|
2131
|
+
'Claude Code CLI requires Node.js, but Node was not found'
|
|
2132
|
+
);
|
|
2133
|
+
|
|
2134
|
+
const chunks = await collectChunks(service.query('hello'));
|
|
2135
|
+
|
|
2136
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
2137
|
+
expect(errorChunks).toHaveLength(1);
|
|
2138
|
+
expect(errorChunks[0].content).toContain('Node.js');
|
|
2139
|
+
});
|
|
2140
|
+
});
|
|
2141
|
+
|
|
2142
|
+
describe('query() - interrupted flag and history rebuild', () => {
|
|
2143
|
+
beforeEach(() => {
|
|
2144
|
+
sdkMock.resetMockMessages();
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
afterEach(() => {
|
|
2148
|
+
sdkMock.resetMockMessages();
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
|
|
2152
|
+
it('should clear interrupted flag before query', async () => {
|
|
2153
|
+
sdkMock.setMockMessages([
|
|
2154
|
+
{ type: 'system', subtype: 'init', session_id: 'session-1' },
|
|
2155
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
2156
|
+
]);
|
|
2157
|
+
|
|
2158
|
+
// Set interrupted state
|
|
2159
|
+
(service as any).sessionManager.markInterrupted();
|
|
2160
|
+
expect((service as any).sessionManager.wasInterrupted()).toBe(true);
|
|
2161
|
+
|
|
2162
|
+
await collectChunks(service.query('hello'));
|
|
2163
|
+
|
|
2164
|
+
expect((service as any).sessionManager.wasInterrupted()).toBe(false);
|
|
2165
|
+
});
|
|
2166
|
+
|
|
2167
|
+
it('should rebuild history on session mismatch', async () => {
|
|
2168
|
+
// Use same session_id as the one we set to avoid captureSession re-setting the flag
|
|
2169
|
+
sdkMock.setMockMessages([
|
|
2170
|
+
{ type: 'system', subtype: 'init', session_id: 'old-session' },
|
|
2171
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
2172
|
+
]);
|
|
2173
|
+
|
|
2174
|
+
// Set up session mismatch state: capture a session, then directly set the flag
|
|
2175
|
+
service.setSessionId('old-session');
|
|
2176
|
+
(service as any).sessionManager.state.needsHistoryRebuild = true;
|
|
2177
|
+
|
|
2178
|
+
const history: any[] = [
|
|
2179
|
+
{ id: '1', role: 'user', content: 'Previous question', timestamp: 1000 },
|
|
2180
|
+
{ id: '2', role: 'assistant', content: 'Previous answer', timestamp: 1001 },
|
|
2181
|
+
];
|
|
2182
|
+
|
|
2183
|
+
// Spy on buildPromptWithHistoryContext to verify it's called
|
|
2184
|
+
const buildSpy = jest.spyOn(sessionUtils, 'buildPromptWithHistoryContext');
|
|
2185
|
+
|
|
2186
|
+
const chunks = await collectChunks(service.query('follow up', undefined, history));
|
|
2187
|
+
|
|
2188
|
+
// Should complete successfully
|
|
2189
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2190
|
+
expect(doneChunks).toHaveLength(1);
|
|
2191
|
+
// History rebuild function should have been called
|
|
2192
|
+
expect(buildSpy).toHaveBeenCalled();
|
|
2193
|
+
});
|
|
2194
|
+
});
|
|
2195
|
+
|
|
2196
|
+
describe('query() - session expired retry (cold-start path)', () => {
|
|
2197
|
+
beforeEach(() => {
|
|
2198
|
+
sdkMock.resetMockMessages();
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
afterEach(() => {
|
|
2202
|
+
sdkMock.resetMockMessages();
|
|
2203
|
+
jest.restoreAllMocks();
|
|
2204
|
+
});
|
|
2205
|
+
|
|
2206
|
+
|
|
2207
|
+
it('should retry with history on session expired error in cold-start', async () => {
|
|
2208
|
+
// First call throws session expired, second succeeds
|
|
2209
|
+
let callCount = 0;
|
|
2210
|
+
const originalQuery = sdkMock.query;
|
|
2211
|
+
jest.spyOn(sdkModule, 'query' as any).mockImplementation((...args: unknown[]) => {
|
|
2212
|
+
callCount++;
|
|
2213
|
+
if (callCount === 1) {
|
|
2214
|
+
// First call: throw session expired error
|
|
2215
|
+
// eslint-disable-next-line require-yield
|
|
2216
|
+
const gen = (async function* () {
|
|
2217
|
+
throw new Error('session expired');
|
|
2218
|
+
})() as any;
|
|
2219
|
+
gen.interrupt = jest.fn();
|
|
2220
|
+
gen.setModel = jest.fn();
|
|
2221
|
+
gen.setMaxThinkingTokens = jest.fn();
|
|
2222
|
+
gen.setPermissionMode = jest.fn();
|
|
2223
|
+
gen.setMcpServers = jest.fn();
|
|
2224
|
+
return gen;
|
|
2225
|
+
}
|
|
2226
|
+
// Second call: succeed with retry
|
|
2227
|
+
const [params] = args as Parameters<typeof sdkModule.query>;
|
|
2228
|
+
return originalQuery.call(null, params);
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
service.setSessionId('old-session');
|
|
2232
|
+
const history: any[] = [
|
|
2233
|
+
{ id: '1', role: 'user', content: 'Previous', timestamp: 1000 },
|
|
2234
|
+
{ id: '2', role: 'assistant', content: 'Answer', timestamp: 1001 },
|
|
2235
|
+
];
|
|
2236
|
+
|
|
2237
|
+
sdkMock.setMockMessages([
|
|
2238
|
+
{ type: 'system', subtype: 'init', session_id: 'retry-session' },
|
|
2239
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Retried OK' }] } },
|
|
2240
|
+
]);
|
|
2241
|
+
|
|
2242
|
+
const chunks = await collectChunks(
|
|
2243
|
+
service.query('follow up', undefined, history, { forceColdStart: true })
|
|
2244
|
+
);
|
|
2245
|
+
|
|
2246
|
+
// Should have retried and yielded chunks
|
|
2247
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2248
|
+
expect(doneChunks).toHaveLength(1);
|
|
2249
|
+
expect(callCount).toBeGreaterThanOrEqual(2);
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
it('should yield error when session expired retry also fails', async () => {
|
|
2253
|
+
jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => {
|
|
2254
|
+
// eslint-disable-next-line require-yield
|
|
2255
|
+
const gen = (async function* () {
|
|
2256
|
+
throw new Error('session expired');
|
|
2257
|
+
})() as any;
|
|
2258
|
+
gen.interrupt = jest.fn();
|
|
2259
|
+
gen.setModel = jest.fn();
|
|
2260
|
+
gen.setMaxThinkingTokens = jest.fn();
|
|
2261
|
+
gen.setPermissionMode = jest.fn();
|
|
2262
|
+
gen.setMcpServers = jest.fn();
|
|
2263
|
+
return gen;
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
service.setSessionId('old-session');
|
|
2267
|
+
const history: any[] = [
|
|
2268
|
+
{ id: '1', role: 'user', content: 'Previous', timestamp: 1000 },
|
|
2269
|
+
];
|
|
2270
|
+
|
|
2271
|
+
const chunks = await collectChunks(
|
|
2272
|
+
service.query('follow up', undefined, history, { forceColdStart: true })
|
|
2273
|
+
);
|
|
2274
|
+
|
|
2275
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
2276
|
+
expect(errorChunks).toHaveLength(1);
|
|
2277
|
+
expect(errorChunks[0].content).toContain('session expired');
|
|
2278
|
+
});
|
|
2279
|
+
});
|
|
2280
|
+
|
|
2281
|
+
describe('applyDynamicUpdates - cliPath null', () => {
|
|
2282
|
+
it('should return early when cliPath is null', async () => {
|
|
2283
|
+
(service as any).persistentQuery = { setModel: jest.fn() };
|
|
2284
|
+
(service as any).vaultPath = '/vault';
|
|
2285
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockReturnValue(null);
|
|
2286
|
+
|
|
2287
|
+
const setModelSpy = (service as any).persistentQuery.setModel;
|
|
2288
|
+
|
|
2289
|
+
await (service as any).applyDynamicUpdates({});
|
|
2290
|
+
|
|
2291
|
+
expect(setModelSpy).not.toHaveBeenCalled();
|
|
2292
|
+
});
|
|
2293
|
+
});
|
|
2294
|
+
|
|
2295
|
+
describe('applyDynamicUpdates - restart needed', () => {
|
|
2296
|
+
it('should restart and re-apply when config changes require restart', async () => {
|
|
2297
|
+
sdkMock.resetMockMessages();
|
|
2298
|
+
sdkMock.setMockMessages([
|
|
2299
|
+
{ type: 'system', subtype: 'init', session_id: 'restart-session' },
|
|
2300
|
+
]);
|
|
2301
|
+
|
|
2302
|
+
// Set up mock persistent query
|
|
2303
|
+
const mockPQ = {
|
|
2304
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2305
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2306
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2307
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2308
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2309
|
+
};
|
|
2310
|
+
(service as any).persistentQuery = mockPQ;
|
|
2311
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2312
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
2313
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
2314
|
+
(service as any).currentConfig = {
|
|
2315
|
+
model: 'claude-3-5-sonnet',
|
|
2316
|
+
effortLevel: 'high',
|
|
2317
|
+
permissionMode: 'ask',
|
|
2318
|
+
systemPromptKey: '',
|
|
2319
|
+
disallowedToolsKey: '',
|
|
2320
|
+
mcpServersKey: '{}',
|
|
2321
|
+
pluginsKey: '',
|
|
2322
|
+
externalContextPaths: [],
|
|
2323
|
+
settingSources: '',
|
|
2324
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2325
|
+
enableChrome: false,
|
|
2326
|
+
enableAutoMode: false,
|
|
2327
|
+
};
|
|
2328
|
+
|
|
2329
|
+
// Change CLI path to trigger restart
|
|
2330
|
+
(mockPlugin.getResolvedProviderCliPath as jest.Mock).mockReturnValue('/new/path/to/claude');
|
|
2331
|
+
|
|
2332
|
+
// Mock ensureReady to return true (restarted)
|
|
2333
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
2334
|
+
|
|
2335
|
+
await (service as any).applyDynamicUpdates({});
|
|
2336
|
+
|
|
2337
|
+
expect(ensureReadySpy).toHaveBeenCalledWith(
|
|
2338
|
+
expect.objectContaining({ force: true })
|
|
2339
|
+
);
|
|
2340
|
+
});
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
describe('routeMessage - agents event', () => {
|
|
2344
|
+
it('should set builtin agent names from init event', async () => {
|
|
2345
|
+
const mockAgentManager = { setBuiltinAgentNames: jest.fn() };
|
|
2346
|
+
(mockPlugin as any).agentManager = mockAgentManager;
|
|
2347
|
+
|
|
2348
|
+
const onChunk = jest.fn();
|
|
2349
|
+
const handler = createResponseHandler({
|
|
2350
|
+
id: 'agents-test',
|
|
2351
|
+
onChunk,
|
|
2352
|
+
onDone: jest.fn(),
|
|
2353
|
+
onError: jest.fn(),
|
|
2354
|
+
});
|
|
2355
|
+
(service as any).responseHandlers = [handler];
|
|
2356
|
+
(service as any).messageChannel = {
|
|
2357
|
+
onTurnComplete: jest.fn(),
|
|
2358
|
+
setSessionId: jest.fn(),
|
|
2359
|
+
};
|
|
2360
|
+
|
|
2361
|
+
// Send a system init message with agents
|
|
2362
|
+
const message = {
|
|
2363
|
+
type: 'system',
|
|
2364
|
+
subtype: 'init',
|
|
2365
|
+
session_id: 'test-session',
|
|
2366
|
+
agents: ['agent1', 'agent2'],
|
|
2367
|
+
};
|
|
2368
|
+
|
|
2369
|
+
await (service as any).routeMessage(message);
|
|
2370
|
+
|
|
2371
|
+
expect(mockAgentManager.setBuiltinAgentNames).toHaveBeenCalledWith(['agent1', 'agent2']);
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
it('should not throw when agentManager.setBuiltinAgentNames fails', async () => {
|
|
2375
|
+
const mockAgentManager = {
|
|
2376
|
+
setBuiltinAgentNames: jest.fn().mockImplementation(() => {
|
|
2377
|
+
throw new Error('agent error');
|
|
2378
|
+
}),
|
|
2379
|
+
};
|
|
2380
|
+
(mockPlugin as any).agentManager = mockAgentManager;
|
|
2381
|
+
|
|
2382
|
+
const handler = createResponseHandler({
|
|
2383
|
+
id: 'agents-error-test',
|
|
2384
|
+
onChunk: jest.fn(),
|
|
2385
|
+
onDone: jest.fn(),
|
|
2386
|
+
onError: jest.fn(),
|
|
2387
|
+
});
|
|
2388
|
+
(service as any).responseHandlers = [handler];
|
|
2389
|
+
(service as any).messageChannel = {
|
|
2390
|
+
onTurnComplete: jest.fn(),
|
|
2391
|
+
setSessionId: jest.fn(),
|
|
2392
|
+
};
|
|
2393
|
+
|
|
2394
|
+
const message = {
|
|
2395
|
+
type: 'system',
|
|
2396
|
+
subtype: 'init',
|
|
2397
|
+
session_id: 'test-session',
|
|
2398
|
+
agents: ['agent1'],
|
|
2399
|
+
};
|
|
2400
|
+
|
|
2401
|
+
// Should not throw
|
|
2402
|
+
await expect((service as any).routeMessage(message)).resolves.toBeUndefined();
|
|
2403
|
+
});
|
|
2404
|
+
});
|
|
2405
|
+
|
|
2406
|
+
describe('routeMessage - usage chunk with sessionId', () => {
|
|
2407
|
+
it('should attach sessionId to usage chunks from assistant messages', async () => {
|
|
2408
|
+
service.setSessionId('usage-session-id');
|
|
2409
|
+
|
|
2410
|
+
const onChunk = jest.fn();
|
|
2411
|
+
const handler = createResponseHandler({
|
|
2412
|
+
id: 'usage-test',
|
|
2413
|
+
onChunk,
|
|
2414
|
+
onDone: jest.fn(),
|
|
2415
|
+
onError: jest.fn(),
|
|
2416
|
+
});
|
|
2417
|
+
(service as any).responseHandlers = [handler];
|
|
2418
|
+
(service as any).messageChannel = {
|
|
2419
|
+
onTurnComplete: jest.fn(),
|
|
2420
|
+
setSessionId: jest.fn(),
|
|
2421
|
+
};
|
|
2422
|
+
|
|
2423
|
+
// Usage is extracted from assistant messages (not result messages)
|
|
2424
|
+
const message = {
|
|
2425
|
+
type: 'assistant',
|
|
2426
|
+
message: {
|
|
2427
|
+
content: [{ type: 'text', text: 'Response' }],
|
|
2428
|
+
usage: {
|
|
2429
|
+
input_tokens: 100,
|
|
2430
|
+
output_tokens: 50,
|
|
2431
|
+
cache_creation_input_tokens: 10,
|
|
2432
|
+
cache_read_input_tokens: 20,
|
|
2433
|
+
},
|
|
2434
|
+
},
|
|
2435
|
+
};
|
|
2436
|
+
|
|
2437
|
+
await (service as any).routeMessage(message);
|
|
2438
|
+
|
|
2439
|
+
const usageChunks = onChunk.mock.calls
|
|
2440
|
+
.map(([chunk]: any) => chunk)
|
|
2441
|
+
.filter((c: any) => c.type === 'usage');
|
|
2442
|
+
|
|
2443
|
+
expect(usageChunks.length).toBeGreaterThan(0);
|
|
2444
|
+
expect(usageChunks[0].sessionId).toBe('usage-session-id');
|
|
2445
|
+
});
|
|
2446
|
+
});
|
|
2447
|
+
|
|
2448
|
+
describe('queryViaPersistent - edge cases', () => {
|
|
2449
|
+
it('should fall back to cold-start when persistent query is null', async () => {
|
|
2450
|
+
sdkMock.resetMockMessages();
|
|
2451
|
+
sdkMock.setMockMessages([
|
|
2452
|
+
{ type: 'system', subtype: 'init', session_id: 'fallback-session' },
|
|
2453
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Fallback' }] } },
|
|
2454
|
+
]);
|
|
2455
|
+
|
|
2456
|
+
// No persistent query set
|
|
2457
|
+
(service as any).persistentQuery = null;
|
|
2458
|
+
(service as any).messageChannel = null;
|
|
2459
|
+
|
|
2460
|
+
const chunks: any[] = [];
|
|
2461
|
+
for await (const chunk of (service as any).queryViaPersistent(
|
|
2462
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2463
|
+
)) {
|
|
2464
|
+
chunks.push(chunk);
|
|
2465
|
+
}
|
|
2466
|
+
|
|
2467
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2468
|
+
expect(doneChunks).toHaveLength(1);
|
|
2469
|
+
});
|
|
2470
|
+
|
|
2471
|
+
it('should set allowedTools from query options', async () => {
|
|
2472
|
+
sdkMock.resetMockMessages();
|
|
2473
|
+
sdkMock.setMockMessages([
|
|
2474
|
+
{ type: 'system', subtype: 'init', session_id: 'allowed-tools-session' },
|
|
2475
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
2476
|
+
]);
|
|
2477
|
+
|
|
2478
|
+
// Set up persistent query
|
|
2479
|
+
const mockPQ = {
|
|
2480
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2481
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2482
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2483
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2484
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2485
|
+
};
|
|
2486
|
+
(service as any).persistentQuery = mockPQ;
|
|
2487
|
+
const mockChannel = new MessageChannel();
|
|
2488
|
+
(service as any).messageChannel = mockChannel;
|
|
2489
|
+
(service as any).responseConsumerRunning = true;
|
|
2490
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2491
|
+
(service as any).currentConfig = {
|
|
2492
|
+
model: 'claude-3-5-sonnet',
|
|
2493
|
+
effortLevel: 'high',
|
|
2494
|
+
permissionMode: 'ask',
|
|
2495
|
+
systemPromptKey: '',
|
|
2496
|
+
disallowedToolsKey: '',
|
|
2497
|
+
mcpServersKey: '{}',
|
|
2498
|
+
pluginsKey: '',
|
|
2499
|
+
externalContextPaths: [],
|
|
2500
|
+
settingSources: '',
|
|
2501
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2502
|
+
enableChrome: false,
|
|
2503
|
+
enableAutoMode: false,
|
|
2504
|
+
};
|
|
2505
|
+
|
|
2506
|
+
// Set up handler to resolve immediately
|
|
2507
|
+
const gen = (service as any).queryViaPersistent(
|
|
2508
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude',
|
|
2509
|
+
{ allowedTools: ['Read', 'Glob'] }
|
|
2510
|
+
);
|
|
2511
|
+
|
|
2512
|
+
// The generator will hang waiting for handler.onDone, so we need to
|
|
2513
|
+
// trigger it via the response handler
|
|
2514
|
+
const iterPromise = gen.next();
|
|
2515
|
+
|
|
2516
|
+
// Wait a tick for the handler to be registered
|
|
2517
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
2518
|
+
|
|
2519
|
+
// Find and trigger the handler
|
|
2520
|
+
const handlers = (service as any).responseHandlers;
|
|
2521
|
+
if (handlers.length > 0) {
|
|
2522
|
+
handlers[0].onChunk({ type: 'text', content: 'Hi' });
|
|
2523
|
+
handlers[0].onDone();
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2526
|
+
await iterPromise;
|
|
2527
|
+
|
|
2528
|
+
// allowedTools should include the specified tools + Skill
|
|
2529
|
+
expect((service as any).currentAllowedTools).toEqual(['Read', 'Glob', 'Skill']);
|
|
2530
|
+
|
|
2531
|
+
// Drain the generator
|
|
2532
|
+
let next = await gen.next();
|
|
2533
|
+
while (!next.done) {
|
|
2534
|
+
next = await gen.next();
|
|
2535
|
+
}
|
|
2536
|
+
});
|
|
2537
|
+
|
|
2538
|
+
it('should fall back to cold-start when consumer is not running', async () => {
|
|
2539
|
+
sdkMock.resetMockMessages();
|
|
2540
|
+
sdkMock.setMockMessages([
|
|
2541
|
+
{ type: 'system', subtype: 'init', session_id: 'consumer-fallback' },
|
|
2542
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Fallback' }] } },
|
|
2543
|
+
]);
|
|
2544
|
+
|
|
2545
|
+
// Persistent query exists but consumer is not running
|
|
2546
|
+
(service as any).persistentQuery = {
|
|
2547
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2548
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2549
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2550
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2551
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2552
|
+
};
|
|
2553
|
+
(service as any).messageChannel = new MessageChannel();
|
|
2554
|
+
(service as any).responseConsumerRunning = false;
|
|
2555
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2556
|
+
(service as any).currentConfig = {
|
|
2557
|
+
model: 'claude-3-5-sonnet',
|
|
2558
|
+
effortLevel: 'high',
|
|
2559
|
+
permissionMode: 'ask',
|
|
2560
|
+
systemPromptKey: '',
|
|
2561
|
+
disallowedToolsKey: '',
|
|
2562
|
+
mcpServersKey: '{}',
|
|
2563
|
+
pluginsKey: '',
|
|
2564
|
+
externalContextPaths: [],
|
|
2565
|
+
settingSources: '',
|
|
2566
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2567
|
+
enableChrome: false,
|
|
2568
|
+
enableAutoMode: false,
|
|
2569
|
+
};
|
|
2570
|
+
|
|
2571
|
+
const chunks: any[] = [];
|
|
2572
|
+
for await (const chunk of (service as any).queryViaPersistent(
|
|
2573
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2574
|
+
)) {
|
|
2575
|
+
chunks.push(chunk);
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2579
|
+
expect(doneChunks).toHaveLength(1);
|
|
2580
|
+
});
|
|
2581
|
+
|
|
2582
|
+
it('should fall back when persistent query lost after applyDynamicUpdates', async () => {
|
|
2583
|
+
sdkMock.resetMockMessages();
|
|
2584
|
+
sdkMock.setMockMessages([
|
|
2585
|
+
{ type: 'system', subtype: 'init', session_id: 'lost-pq' },
|
|
2586
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
2587
|
+
]);
|
|
2588
|
+
|
|
2589
|
+
// Set up persistent query that will be cleared by applyDynamicUpdates mock
|
|
2590
|
+
(service as any).persistentQuery = {
|
|
2591
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2592
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2593
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2594
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2595
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2596
|
+
};
|
|
2597
|
+
(service as any).messageChannel = new MessageChannel();
|
|
2598
|
+
(service as any).responseConsumerRunning = true;
|
|
2599
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2600
|
+
(service as any).currentConfig = {
|
|
2601
|
+
model: 'claude-3-5-sonnet',
|
|
2602
|
+
effortLevel: 'high',
|
|
2603
|
+
permissionMode: 'ask',
|
|
2604
|
+
systemPromptKey: '',
|
|
2605
|
+
disallowedToolsKey: '',
|
|
2606
|
+
mcpServersKey: '{}',
|
|
2607
|
+
pluginsKey: '',
|
|
2608
|
+
externalContextPaths: [],
|
|
2609
|
+
settingSources: '',
|
|
2610
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2611
|
+
enableChrome: false,
|
|
2612
|
+
enableAutoMode: false,
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2615
|
+
// Mock applyDynamicUpdates to clear persistent query (simulating restart failure)
|
|
2616
|
+
jest.spyOn(service as any, 'applyDynamicUpdates').mockImplementation(async () => {
|
|
2617
|
+
(service as any).persistentQuery = null;
|
|
2618
|
+
(service as any).messageChannel = null;
|
|
2619
|
+
});
|
|
2620
|
+
|
|
2621
|
+
const chunks: any[] = [];
|
|
2622
|
+
for await (const chunk of (service as any).queryViaPersistent(
|
|
2623
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2624
|
+
)) {
|
|
2625
|
+
chunks.push(chunk);
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2629
|
+
expect(doneChunks).toHaveLength(1);
|
|
2630
|
+
});
|
|
2631
|
+
|
|
2632
|
+
it('should fall back when channel is closed during enqueue', async () => {
|
|
2633
|
+
sdkMock.resetMockMessages();
|
|
2634
|
+
sdkMock.setMockMessages([
|
|
2635
|
+
{ type: 'system', subtype: 'init', session_id: 'closed-channel' },
|
|
2636
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'OK' }] } },
|
|
2637
|
+
]);
|
|
2638
|
+
|
|
2639
|
+
const closedChannel = new MessageChannel();
|
|
2640
|
+
closedChannel.close();
|
|
2641
|
+
|
|
2642
|
+
(service as any).persistentQuery = {
|
|
2643
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2644
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2645
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2646
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2647
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2648
|
+
};
|
|
2649
|
+
(service as any).messageChannel = closedChannel;
|
|
2650
|
+
(service as any).responseConsumerRunning = true;
|
|
2651
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2652
|
+
(service as any).currentConfig = {
|
|
2653
|
+
model: 'claude-3-5-sonnet',
|
|
2654
|
+
effortLevel: 'high',
|
|
2655
|
+
permissionMode: 'ask',
|
|
2656
|
+
systemPromptKey: '',
|
|
2657
|
+
disallowedToolsKey: '',
|
|
2658
|
+
mcpServersKey: '{}',
|
|
2659
|
+
pluginsKey: '',
|
|
2660
|
+
externalContextPaths: [],
|
|
2661
|
+
settingSources: '',
|
|
2662
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2663
|
+
enableChrome: false,
|
|
2664
|
+
enableAutoMode: false,
|
|
2665
|
+
};
|
|
2666
|
+
|
|
2667
|
+
const chunks: any[] = [];
|
|
2668
|
+
for await (const chunk of (service as any).queryViaPersistent(
|
|
2669
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2670
|
+
)) {
|
|
2671
|
+
chunks.push(chunk);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
// Should fall back to cold-start and complete
|
|
2675
|
+
const doneChunks = chunks.filter(c => c.type === 'done');
|
|
2676
|
+
expect(doneChunks).toHaveLength(1);
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
it('should handle onError in handler and re-throw session expired', async () => {
|
|
2680
|
+
const mockPQ = {
|
|
2681
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2682
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2683
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2684
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2685
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2686
|
+
};
|
|
2687
|
+
(service as any).persistentQuery = mockPQ;
|
|
2688
|
+
const mockChannel = new MessageChannel();
|
|
2689
|
+
(service as any).messageChannel = mockChannel;
|
|
2690
|
+
(service as any).responseConsumerRunning = true;
|
|
2691
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2692
|
+
(service as any).currentConfig = {
|
|
2693
|
+
model: 'claude-3-5-sonnet',
|
|
2694
|
+
effortLevel: 'high',
|
|
2695
|
+
permissionMode: 'ask',
|
|
2696
|
+
systemPromptKey: '',
|
|
2697
|
+
disallowedToolsKey: '',
|
|
2698
|
+
mcpServersKey: '{}',
|
|
2699
|
+
pluginsKey: '',
|
|
2700
|
+
externalContextPaths: [],
|
|
2701
|
+
settingSources: '',
|
|
2702
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2703
|
+
enableChrome: false,
|
|
2704
|
+
enableAutoMode: false,
|
|
2705
|
+
};
|
|
2706
|
+
|
|
2707
|
+
// Mock applyDynamicUpdates to avoid side effects
|
|
2708
|
+
jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined);
|
|
2709
|
+
|
|
2710
|
+
const gen = (service as any).queryViaPersistent(
|
|
2711
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2712
|
+
);
|
|
2713
|
+
|
|
2714
|
+
const iterPromise = gen.next();
|
|
2715
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
2716
|
+
|
|
2717
|
+
// Trigger onError with session expired
|
|
2718
|
+
const handlers = (service as any).responseHandlers;
|
|
2719
|
+
expect(handlers.length).toBeGreaterThan(0);
|
|
2720
|
+
handlers[0].onError(new Error('session expired'));
|
|
2721
|
+
|
|
2722
|
+
// Session expired should be re-thrown by the generator
|
|
2723
|
+
// gen.next() will resolve with the error propagating through the generator
|
|
2724
|
+
await expect(iterPromise).rejects.toThrow('session expired');
|
|
2725
|
+
});
|
|
2726
|
+
|
|
2727
|
+
it('should handle onError with non-session error', async () => {
|
|
2728
|
+
const mockPQ = {
|
|
2729
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2730
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2731
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2732
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2733
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2734
|
+
};
|
|
2735
|
+
(service as any).persistentQuery = mockPQ;
|
|
2736
|
+
const mockChannel = new MessageChannel();
|
|
2737
|
+
(service as any).messageChannel = mockChannel;
|
|
2738
|
+
(service as any).responseConsumerRunning = true;
|
|
2739
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2740
|
+
(service as any).currentConfig = {
|
|
2741
|
+
model: 'claude-3-5-sonnet',
|
|
2742
|
+
effortLevel: 'high',
|
|
2743
|
+
permissionMode: 'ask',
|
|
2744
|
+
systemPromptKey: '',
|
|
2745
|
+
disallowedToolsKey: '',
|
|
2746
|
+
mcpServersKey: '{}',
|
|
2747
|
+
pluginsKey: '',
|
|
2748
|
+
externalContextPaths: [],
|
|
2749
|
+
settingSources: '',
|
|
2750
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2751
|
+
enableChrome: false,
|
|
2752
|
+
enableAutoMode: false,
|
|
2753
|
+
};
|
|
2754
|
+
|
|
2755
|
+
// Mock applyDynamicUpdates to avoid side effects
|
|
2756
|
+
jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined);
|
|
2757
|
+
|
|
2758
|
+
const gen = (service as any).queryViaPersistent(
|
|
2759
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2760
|
+
);
|
|
2761
|
+
|
|
2762
|
+
const chunks: any[] = [];
|
|
2763
|
+
const iterPromise = gen.next();
|
|
2764
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
2765
|
+
|
|
2766
|
+
// Trigger onError with regular error
|
|
2767
|
+
const handlers = (service as any).responseHandlers;
|
|
2768
|
+
expect(handlers.length).toBeGreaterThan(0);
|
|
2769
|
+
handlers[0].onError(new Error('Some internal error'));
|
|
2770
|
+
|
|
2771
|
+
const first = await iterPromise;
|
|
2772
|
+
if (!first.done) {
|
|
2773
|
+
chunks.push(first.value);
|
|
2774
|
+
let next = await gen.next();
|
|
2775
|
+
while (!next.done) {
|
|
2776
|
+
chunks.push(next.value);
|
|
2777
|
+
next = await gen.next();
|
|
2778
|
+
}
|
|
2779
|
+
}
|
|
2780
|
+
|
|
2781
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
2782
|
+
expect(errorChunks).toHaveLength(1);
|
|
2783
|
+
expect(errorChunks[0].content).toContain('Some internal error');
|
|
2784
|
+
});
|
|
2785
|
+
|
|
2786
|
+
it('should yield buffered chunks from state.chunks', async () => {
|
|
2787
|
+
const mockPQ = {
|
|
2788
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
2789
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
2790
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
2791
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
2792
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
2793
|
+
};
|
|
2794
|
+
(service as any).persistentQuery = mockPQ;
|
|
2795
|
+
const mockChannel = new MessageChannel();
|
|
2796
|
+
(service as any).messageChannel = mockChannel;
|
|
2797
|
+
(service as any).responseConsumerRunning = true;
|
|
2798
|
+
(service as any).vaultPath = '/mock/vault/path';
|
|
2799
|
+
(service as any).currentConfig = {
|
|
2800
|
+
model: 'claude-3-5-sonnet',
|
|
2801
|
+
effortLevel: 'high',
|
|
2802
|
+
permissionMode: 'ask',
|
|
2803
|
+
systemPromptKey: '',
|
|
2804
|
+
disallowedToolsKey: '',
|
|
2805
|
+
mcpServersKey: '{}',
|
|
2806
|
+
pluginsKey: '',
|
|
2807
|
+
externalContextPaths: [],
|
|
2808
|
+
settingSources: '',
|
|
2809
|
+
claudeCliPath: '/usr/local/bin/claude',
|
|
2810
|
+
enableChrome: false,
|
|
2811
|
+
enableAutoMode: false,
|
|
2812
|
+
};
|
|
2813
|
+
|
|
2814
|
+
// Mock applyDynamicUpdates to avoid side effects
|
|
2815
|
+
jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined);
|
|
2816
|
+
|
|
2817
|
+
const gen = (service as any).queryViaPersistent(
|
|
2818
|
+
'test', undefined, '/mock/vault/path', '/usr/local/bin/claude'
|
|
2819
|
+
);
|
|
2820
|
+
|
|
2821
|
+
const iterPromise = gen.next();
|
|
2822
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
2823
|
+
|
|
2824
|
+
// Rapidly send multiple chunks then done
|
|
2825
|
+
const handlers = (service as any).responseHandlers;
|
|
2826
|
+
expect(handlers.length).toBeGreaterThan(0);
|
|
2827
|
+
handlers[0].onChunk({ type: 'text', content: 'First' });
|
|
2828
|
+
handlers[0].onChunk({ type: 'text', content: 'Second' });
|
|
2829
|
+
handlers[0].onDone();
|
|
2830
|
+
|
|
2831
|
+
const chunks: any[] = [];
|
|
2832
|
+
const first = await iterPromise;
|
|
2833
|
+
if (!first.done) {
|
|
2834
|
+
chunks.push(first.value);
|
|
2835
|
+
let next = await gen.next();
|
|
2836
|
+
while (!next.done) {
|
|
2837
|
+
chunks.push(next.value);
|
|
2838
|
+
next = await gen.next();
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
const textChunks = chunks.filter(c => c.type === 'text');
|
|
2843
|
+
expect(textChunks.length).toBe(2);
|
|
2844
|
+
expect(textChunks[0].content).toBe('First');
|
|
2845
|
+
expect(textChunks[1].content).toBe('Second');
|
|
2846
|
+
});
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
describe('query() - session expired retry from persistent path', () => {
|
|
2850
|
+
beforeEach(() => {
|
|
2851
|
+
sdkMock.resetMockMessages();
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
afterEach(() => {
|
|
2855
|
+
sdkMock.resetMockMessages();
|
|
2856
|
+
jest.restoreAllMocks();
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
|
|
2860
|
+
it('should retry via cold-start when persistent query yields session expired error', async () => {
|
|
2861
|
+
// Set up a session and history so retry can happen
|
|
2862
|
+
service.setSessionId('old-persistent-session');
|
|
2863
|
+
const history: any[] = [
|
|
2864
|
+
{ id: '1', role: 'user', content: 'Previous question', timestamp: 1000 },
|
|
2865
|
+
{ id: '2', role: 'assistant', content: 'Previous answer', timestamp: 1001 },
|
|
2866
|
+
];
|
|
2867
|
+
|
|
2868
|
+
// Mock queryViaPersistent to throw session expired
|
|
2869
|
+
jest.spyOn(service as any, 'queryViaPersistent').mockImplementation(
|
|
2870
|
+
// eslint-disable-next-line require-yield
|
|
2871
|
+
async function* () {
|
|
2872
|
+
throw new Error('session expired');
|
|
2873
|
+
}
|
|
2874
|
+
);
|
|
2875
|
+
|
|
2876
|
+
// Mock queryViaSDK to succeed on retry
|
|
2877
|
+
const queryViaSDKSpy = jest.spyOn(service as any, 'queryViaSDK').mockImplementation(
|
|
2878
|
+
async function* () {
|
|
2879
|
+
yield { type: 'text', content: 'Retried OK' };
|
|
2880
|
+
yield { type: 'done' };
|
|
2881
|
+
}
|
|
2882
|
+
);
|
|
2883
|
+
|
|
2884
|
+
// Need a persistent query to be "active" for shouldUsePersistent
|
|
2885
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
2886
|
+
(service as any).shuttingDown = false;
|
|
2887
|
+
|
|
2888
|
+
const chunks = await collectChunks(service.query('follow up', undefined, history));
|
|
2889
|
+
|
|
2890
|
+
// Should have retried via SDK
|
|
2891
|
+
expect(queryViaSDKSpy).toHaveBeenCalled();
|
|
2892
|
+
const textChunks = chunks.filter(c => c.type === 'text');
|
|
2893
|
+
expect(textChunks[0].content).toBe('Retried OK');
|
|
2894
|
+
});
|
|
2895
|
+
|
|
2896
|
+
it('should yield error when persistent session expired retry also fails', async () => {
|
|
2897
|
+
service.setSessionId('old-persistent-session');
|
|
2898
|
+
const history: any[] = [
|
|
2899
|
+
{ id: '1', role: 'user', content: 'Previous question', timestamp: 1000 },
|
|
2900
|
+
];
|
|
2901
|
+
|
|
2902
|
+
jest.spyOn(service as any, 'queryViaPersistent').mockImplementation(
|
|
2903
|
+
// eslint-disable-next-line require-yield
|
|
2904
|
+
async function* () {
|
|
2905
|
+
throw new Error('session expired');
|
|
2906
|
+
}
|
|
2907
|
+
);
|
|
2908
|
+
|
|
2909
|
+
jest.spyOn(service as any, 'queryViaSDK').mockImplementation(
|
|
2910
|
+
// eslint-disable-next-line require-yield
|
|
2911
|
+
async function* () {
|
|
2912
|
+
throw new Error('retry also failed');
|
|
2913
|
+
}
|
|
2914
|
+
);
|
|
2915
|
+
|
|
2916
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
2917
|
+
(service as any).shuttingDown = false;
|
|
2918
|
+
|
|
2919
|
+
const chunks = await collectChunks(service.query('follow up', undefined, history));
|
|
2920
|
+
|
|
2921
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
2922
|
+
expect(errorChunks).toHaveLength(1);
|
|
2923
|
+
expect(errorChunks[0].content).toContain('retry also failed');
|
|
2924
|
+
});
|
|
2925
|
+
|
|
2926
|
+
it('should re-throw non-session-expired errors from persistent path', async () => {
|
|
2927
|
+
jest.spyOn(service as any, 'queryViaPersistent').mockImplementation(
|
|
2928
|
+
// eslint-disable-next-line require-yield
|
|
2929
|
+
async function* () {
|
|
2930
|
+
throw new Error('unexpected failure');
|
|
2931
|
+
}
|
|
2932
|
+
);
|
|
2933
|
+
|
|
2934
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
2935
|
+
(service as any).shuttingDown = false;
|
|
2936
|
+
|
|
2937
|
+
// query() should propagate the error (not catch it)
|
|
2938
|
+
await expect(async () => {
|
|
2939
|
+
await collectChunks(service.query('hello'));
|
|
2940
|
+
}).rejects.toThrow('unexpected failure');
|
|
2941
|
+
});
|
|
2942
|
+
|
|
2943
|
+
it('should not retry session expired without conversation history', async () => {
|
|
2944
|
+
jest.spyOn(service as any, 'queryViaPersistent').mockImplementation(
|
|
2945
|
+
// eslint-disable-next-line require-yield
|
|
2946
|
+
async function* () {
|
|
2947
|
+
throw new Error('session expired');
|
|
2948
|
+
}
|
|
2949
|
+
);
|
|
2950
|
+
|
|
2951
|
+
(service as any).persistentQuery = { interrupt: jest.fn().mockResolvedValue(undefined) };
|
|
2952
|
+
(service as any).shuttingDown = false;
|
|
2953
|
+
|
|
2954
|
+
// No history → should re-throw, not retry
|
|
2955
|
+
await expect(async () => {
|
|
2956
|
+
await collectChunks(service.query('hello'));
|
|
2957
|
+
}).rejects.toThrow('session expired');
|
|
2958
|
+
});
|
|
2959
|
+
});
|
|
2960
|
+
|
|
2961
|
+
describe('query() - non-session-expired cold-start error', () => {
|
|
2962
|
+
beforeEach(() => {
|
|
2963
|
+
sdkMock.resetMockMessages();
|
|
2964
|
+
});
|
|
2965
|
+
|
|
2966
|
+
afterEach(() => {
|
|
2967
|
+
sdkMock.resetMockMessages();
|
|
2968
|
+
jest.restoreAllMocks();
|
|
2969
|
+
});
|
|
2970
|
+
|
|
2971
|
+
|
|
2972
|
+
it('should yield error chunk for non-session-expired errors in cold-start path', async () => {
|
|
2973
|
+
jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => {
|
|
2974
|
+
// eslint-disable-next-line require-yield
|
|
2975
|
+
const gen = (async function* () {
|
|
2976
|
+
throw new Error('connection timeout');
|
|
2977
|
+
})() as any;
|
|
2978
|
+
gen.interrupt = jest.fn();
|
|
2979
|
+
gen.setModel = jest.fn();
|
|
2980
|
+
gen.setMaxThinkingTokens = jest.fn();
|
|
2981
|
+
gen.setPermissionMode = jest.fn();
|
|
2982
|
+
gen.setMcpServers = jest.fn();
|
|
2983
|
+
return gen;
|
|
2984
|
+
});
|
|
2985
|
+
|
|
2986
|
+
const chunks = await collectChunks(
|
|
2987
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
2988
|
+
);
|
|
2989
|
+
|
|
2990
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
2991
|
+
expect(errorChunks).toHaveLength(1);
|
|
2992
|
+
expect(errorChunks[0].content).toBe('connection timeout');
|
|
2993
|
+
});
|
|
2994
|
+
|
|
2995
|
+
it('should handle non-Error thrown values in cold-start path', async () => {
|
|
2996
|
+
jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => {
|
|
2997
|
+
// eslint-disable-next-line require-yield
|
|
2998
|
+
const gen = (async function* () {
|
|
2999
|
+
throw 'string error';
|
|
3000
|
+
})() as any;
|
|
3001
|
+
gen.interrupt = jest.fn();
|
|
3002
|
+
gen.setModel = jest.fn();
|
|
3003
|
+
gen.setMaxThinkingTokens = jest.fn();
|
|
3004
|
+
gen.setPermissionMode = jest.fn();
|
|
3005
|
+
gen.setMcpServers = jest.fn();
|
|
3006
|
+
return gen;
|
|
3007
|
+
});
|
|
3008
|
+
|
|
3009
|
+
const chunks = await collectChunks(
|
|
3010
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3011
|
+
);
|
|
3012
|
+
|
|
3013
|
+
const errorChunks = chunks.filter(c => c.type === 'error');
|
|
3014
|
+
expect(errorChunks).toHaveLength(1);
|
|
3015
|
+
expect(errorChunks[0].content).toBe('Unknown error');
|
|
3016
|
+
});
|
|
3017
|
+
});
|
|
3018
|
+
|
|
3019
|
+
describe('queryViaSDK - abort signal handling', () => {
|
|
3020
|
+
beforeEach(() => {
|
|
3021
|
+
sdkMock.resetMockMessages();
|
|
3022
|
+
});
|
|
3023
|
+
|
|
3024
|
+
afterEach(() => {
|
|
3025
|
+
sdkMock.resetMockMessages();
|
|
3026
|
+
jest.restoreAllMocks();
|
|
3027
|
+
});
|
|
3028
|
+
|
|
3029
|
+
it('should interrupt response when abort signal is triggered during iteration', async () => {
|
|
3030
|
+
const abortController = new AbortController();
|
|
3031
|
+
(service as any).abortController = abortController;
|
|
3032
|
+
|
|
3033
|
+
let interruptCalled = false;
|
|
3034
|
+
// Set up messages that allow us to abort mid-stream
|
|
3035
|
+
jest.spyOn(sdkModule, 'query' as any).mockImplementation(() => {
|
|
3036
|
+
const messages = [
|
|
3037
|
+
{ type: 'system', subtype: 'init', session_id: 'abort-session' },
|
|
3038
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
|
|
3039
|
+
// Third message won't be yielded because we abort after the second
|
|
3040
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'World' }] } },
|
|
3041
|
+
];
|
|
3042
|
+
|
|
3043
|
+
let index = 0;
|
|
3044
|
+
const gen = {
|
|
3045
|
+
[Symbol.asyncIterator]() { return this; },
|
|
3046
|
+
async next() {
|
|
3047
|
+
if (index >= messages.length) return { done: true, value: undefined };
|
|
3048
|
+
const msg = messages[index++];
|
|
3049
|
+
// Abort after yielding the second message
|
|
3050
|
+
if (index === 2) {
|
|
3051
|
+
abortController.abort();
|
|
3052
|
+
}
|
|
3053
|
+
return { done: false, value: msg };
|
|
3054
|
+
},
|
|
3055
|
+
async return() { return { done: true, value: undefined }; },
|
|
3056
|
+
interrupt: jest.fn().mockImplementation(async () => { interruptCalled = true; }),
|
|
3057
|
+
setModel: jest.fn(),
|
|
3058
|
+
setMaxThinkingTokens: jest.fn(),
|
|
3059
|
+
setPermissionMode: jest.fn(),
|
|
3060
|
+
setMcpServers: jest.fn(),
|
|
3061
|
+
};
|
|
3062
|
+
return gen;
|
|
3063
|
+
});
|
|
3064
|
+
|
|
3065
|
+
const chunks: any[] = [];
|
|
3066
|
+
for await (const chunk of (service as any).queryViaSDK(
|
|
3067
|
+
'hello', '/mock/vault/path', '/usr/local/bin/claude', undefined, { forceColdStart: true }
|
|
3068
|
+
)) {
|
|
3069
|
+
chunks.push(chunk);
|
|
3070
|
+
}
|
|
3071
|
+
|
|
3072
|
+
// interrupt should have been called
|
|
3073
|
+
expect(interruptCalled).toBe(true);
|
|
3074
|
+
});
|
|
3075
|
+
});
|
|
3076
|
+
|
|
3077
|
+
describe('startResponseConsumer - crash recovery', () => {
|
|
3078
|
+
it('should attempt crash recovery when error occurs before any chunks', async () => {
|
|
3079
|
+
// Set up persistent query that will throw on iteration
|
|
3080
|
+
const crashError = new Error('process crashed');
|
|
3081
|
+
let iterationCount = 0;
|
|
3082
|
+
const mockPQ = {
|
|
3083
|
+
[Symbol.asyncIterator]() { return this; },
|
|
3084
|
+
async next() {
|
|
3085
|
+
iterationCount++;
|
|
3086
|
+
if (iterationCount === 1) {
|
|
3087
|
+
throw crashError;
|
|
3088
|
+
}
|
|
3089
|
+
return { done: true, value: undefined };
|
|
3090
|
+
},
|
|
3091
|
+
async return() { return { done: true, value: undefined }; },
|
|
3092
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
3093
|
+
setModel: jest.fn().mockResolvedValue(undefined),
|
|
3094
|
+
setMaxThinkingTokens: jest.fn().mockResolvedValue(undefined),
|
|
3095
|
+
setPermissionMode: jest.fn().mockResolvedValue(undefined),
|
|
3096
|
+
setMcpServers: jest.fn().mockResolvedValue({ added: [], removed: [], errors: {} }),
|
|
3097
|
+
};
|
|
3098
|
+
|
|
3099
|
+
(service as any).persistentQuery = mockPQ;
|
|
3100
|
+
(service as any).messageChannel = { close: jest.fn(), enqueue: jest.fn(), onTurnComplete: jest.fn() };
|
|
3101
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3102
|
+
(service as any).shuttingDown = false;
|
|
3103
|
+
(service as any).coldStartInProgress = false;
|
|
3104
|
+
(service as any).crashRecoveryAttempted = false;
|
|
3105
|
+
(service as any).responseConsumerRunning = false;
|
|
3106
|
+
|
|
3107
|
+
// Set up a handler that hasn't seen any chunks (sawAnyChunk = false)
|
|
3108
|
+
const onError = jest.fn();
|
|
3109
|
+
const handler = createResponseHandler({
|
|
3110
|
+
id: 'crash-test',
|
|
3111
|
+
onChunk: jest.fn(),
|
|
3112
|
+
onDone: jest.fn(),
|
|
3113
|
+
onError,
|
|
3114
|
+
});
|
|
3115
|
+
(service as any).responseHandlers = [handler];
|
|
3116
|
+
|
|
3117
|
+
// Set lastSentMessage for replay
|
|
3118
|
+
(service as any).lastSentMessage = {
|
|
3119
|
+
type: 'user',
|
|
3120
|
+
message: { role: 'user', content: 'test' },
|
|
3121
|
+
parent_tool_use_id: null,
|
|
3122
|
+
session_id: 'test-session',
|
|
3123
|
+
};
|
|
3124
|
+
|
|
3125
|
+
// Mock ensureReady to succeed
|
|
3126
|
+
const ensureReadySpy = jest.spyOn(service, 'ensureReady').mockResolvedValue(true);
|
|
3127
|
+
// After ensureReady, messageChannel needs to exist
|
|
3128
|
+
jest.spyOn(service as any, 'applyDynamicUpdates').mockResolvedValue(undefined);
|
|
3129
|
+
|
|
3130
|
+
(service as any).startResponseConsumer();
|
|
3131
|
+
|
|
3132
|
+
// Wait for async consumer to process
|
|
3133
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
3134
|
+
|
|
3135
|
+
expect(ensureReadySpy).toHaveBeenCalledWith(
|
|
3136
|
+
expect.objectContaining({ force: true, preserveHandlers: true })
|
|
3137
|
+
);
|
|
3138
|
+
});
|
|
3139
|
+
|
|
3140
|
+
it('should notify handler and restart when crash recovery already attempted', async () => {
|
|
3141
|
+
const crashError = new Error('process crashed again');
|
|
3142
|
+
let iterationCount = 0;
|
|
3143
|
+
const mockPQ = {
|
|
3144
|
+
[Symbol.asyncIterator]() { return this; },
|
|
3145
|
+
async next() {
|
|
3146
|
+
iterationCount++;
|
|
3147
|
+
if (iterationCount === 1) throw crashError;
|
|
3148
|
+
return { done: true, value: undefined };
|
|
3149
|
+
},
|
|
3150
|
+
async return() { return { done: true, value: undefined }; },
|
|
3151
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
3152
|
+
};
|
|
3153
|
+
|
|
3154
|
+
(service as any).persistentQuery = mockPQ;
|
|
3155
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3156
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3157
|
+
(service as any).shuttingDown = false;
|
|
3158
|
+
(service as any).coldStartInProgress = false;
|
|
3159
|
+
(service as any).crashRecoveryAttempted = true; // Already attempted
|
|
3160
|
+
(service as any).responseConsumerRunning = false;
|
|
3161
|
+
|
|
3162
|
+
const onError = jest.fn();
|
|
3163
|
+
const handler = createResponseHandler({
|
|
3164
|
+
id: 'crash-test-2',
|
|
3165
|
+
onChunk: jest.fn(),
|
|
3166
|
+
onDone: jest.fn(),
|
|
3167
|
+
onError,
|
|
3168
|
+
});
|
|
3169
|
+
// handler hasn't seen chunks
|
|
3170
|
+
(service as any).responseHandlers = [handler];
|
|
3171
|
+
|
|
3172
|
+
(service as any).lastSentMessage = {
|
|
3173
|
+
type: 'user',
|
|
3174
|
+
message: { role: 'user', content: 'test' },
|
|
3175
|
+
parent_tool_use_id: null,
|
|
3176
|
+
session_id: 'test-session',
|
|
3177
|
+
};
|
|
3178
|
+
|
|
3179
|
+
// ensureReady should NOT be called for recovery (already attempted),
|
|
3180
|
+
// but should be called for restart-for-next-message
|
|
3181
|
+
jest.spyOn(service, 'ensureReady').mockResolvedValue(false);
|
|
3182
|
+
|
|
3183
|
+
(service as any).startResponseConsumer();
|
|
3184
|
+
|
|
3185
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
3186
|
+
|
|
3187
|
+
// Handler should have been notified of error
|
|
3188
|
+
expect(onError).toHaveBeenCalledWith(crashError);
|
|
3189
|
+
});
|
|
3190
|
+
|
|
3191
|
+
it('should invalidate session when crash recovery restart fails with session expired', async () => {
|
|
3192
|
+
const crashError = new Error('process crashed');
|
|
3193
|
+
let iterationCount = 0;
|
|
3194
|
+
const mockPQ = {
|
|
3195
|
+
[Symbol.asyncIterator]() { return this; },
|
|
3196
|
+
async next() {
|
|
3197
|
+
iterationCount++;
|
|
3198
|
+
if (iterationCount === 1) throw crashError;
|
|
3199
|
+
return { done: true, value: undefined };
|
|
3200
|
+
},
|
|
3201
|
+
async return() { return { done: true, value: undefined }; },
|
|
3202
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
3203
|
+
};
|
|
3204
|
+
|
|
3205
|
+
(service as any).persistentQuery = mockPQ;
|
|
3206
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3207
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3208
|
+
(service as any).shuttingDown = false;
|
|
3209
|
+
(service as any).coldStartInProgress = false;
|
|
3210
|
+
(service as any).crashRecoveryAttempted = false;
|
|
3211
|
+
(service as any).responseConsumerRunning = false;
|
|
3212
|
+
|
|
3213
|
+
const onError = jest.fn();
|
|
3214
|
+
const handler = createResponseHandler({
|
|
3215
|
+
id: 'session-expire-test',
|
|
3216
|
+
onChunk: jest.fn(),
|
|
3217
|
+
onDone: jest.fn(),
|
|
3218
|
+
onError,
|
|
3219
|
+
});
|
|
3220
|
+
(service as any).responseHandlers = [handler];
|
|
3221
|
+
(service as any).lastSentMessage = {
|
|
3222
|
+
type: 'user',
|
|
3223
|
+
message: { role: 'user', content: 'test' },
|
|
3224
|
+
parent_tool_use_id: null,
|
|
3225
|
+
session_id: 'test-session',
|
|
3226
|
+
};
|
|
3227
|
+
|
|
3228
|
+
// Set session directly to avoid ensureReady side effects
|
|
3229
|
+
(service as any).sessionManager.setSessionId('my-session', 'claude-3-5-sonnet');
|
|
3230
|
+
|
|
3231
|
+
// ensureReady fails with session expired during crash recovery
|
|
3232
|
+
jest.spyOn(service, 'ensureReady').mockRejectedValue(new Error('session expired'));
|
|
3233
|
+
|
|
3234
|
+
(service as any).startResponseConsumer();
|
|
3235
|
+
|
|
3236
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
3237
|
+
|
|
3238
|
+
// Session should be invalidated
|
|
3239
|
+
expect(service.consumeSessionInvalidation()).toBe(true);
|
|
3240
|
+
// Handler should be notified of the original error
|
|
3241
|
+
expect(onError).toHaveBeenCalledWith(crashError);
|
|
3242
|
+
});
|
|
3243
|
+
|
|
3244
|
+
it('should skip error handling when consumer is orphaned (replaced)', async () => {
|
|
3245
|
+
const crashError = new Error('old consumer error');
|
|
3246
|
+
let resolveDelay: () => void;
|
|
3247
|
+
const delayPromise = new Promise<void>(resolve => { resolveDelay = resolve; });
|
|
3248
|
+
|
|
3249
|
+
const oldMockPQ = {
|
|
3250
|
+
[Symbol.asyncIterator]() { return this; },
|
|
3251
|
+
async next() {
|
|
3252
|
+
// Wait for the swap to happen before throwing
|
|
3253
|
+
await delayPromise;
|
|
3254
|
+
throw crashError;
|
|
3255
|
+
},
|
|
3256
|
+
async return() { return { done: true, value: undefined }; },
|
|
3257
|
+
interrupt: jest.fn().mockResolvedValue(undefined),
|
|
3258
|
+
};
|
|
3259
|
+
|
|
3260
|
+
// This PQ is the "old" one that the consumer will iterate
|
|
3261
|
+
(service as any).persistentQuery = oldMockPQ;
|
|
3262
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3263
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3264
|
+
(service as any).shuttingDown = false;
|
|
3265
|
+
(service as any).coldStartInProgress = false;
|
|
3266
|
+
(service as any).responseConsumerRunning = false;
|
|
3267
|
+
|
|
3268
|
+
const onError = jest.fn();
|
|
3269
|
+
const handler = createResponseHandler({
|
|
3270
|
+
id: 'orphan-test',
|
|
3271
|
+
onChunk: jest.fn(),
|
|
3272
|
+
onDone: jest.fn(),
|
|
3273
|
+
onError,
|
|
3274
|
+
});
|
|
3275
|
+
(service as any).responseHandlers = [handler];
|
|
3276
|
+
|
|
3277
|
+
(service as any).startResponseConsumer();
|
|
3278
|
+
|
|
3279
|
+
// Wait for consumer to start its iteration (awaiting the delay)
|
|
3280
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
3281
|
+
|
|
3282
|
+
// Swap to a new PQ before the error fires
|
|
3283
|
+
(service as any).persistentQuery = { interrupt: jest.fn() };
|
|
3284
|
+
|
|
3285
|
+
// Now let the old PQ throw
|
|
3286
|
+
resolveDelay!();
|
|
3287
|
+
|
|
3288
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
3289
|
+
|
|
3290
|
+
// The orphaned consumer should NOT call onError
|
|
3291
|
+
expect(onError).not.toHaveBeenCalled();
|
|
3292
|
+
});
|
|
3293
|
+
});
|
|
3294
|
+
|
|
3295
|
+
describe('queryViaSDK - stream content dedup and allowedTools', () => {
|
|
3296
|
+
beforeEach(() => {
|
|
3297
|
+
sdkMock.resetMockMessages();
|
|
3298
|
+
});
|
|
3299
|
+
|
|
3300
|
+
afterEach(() => {
|
|
3301
|
+
sdkMock.resetMockMessages();
|
|
3302
|
+
jest.restoreAllMocks();
|
|
3303
|
+
});
|
|
3304
|
+
|
|
3305
|
+
|
|
3306
|
+
it('should set allowedTools in cold-start query', async () => {
|
|
3307
|
+
sdkMock.setMockMessages([
|
|
3308
|
+
{ type: 'system', subtype: 'init', session_id: 'allowed-cs' },
|
|
3309
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hi' }] } },
|
|
3310
|
+
]);
|
|
3311
|
+
|
|
3312
|
+
const chunks = await collectChunks(
|
|
3313
|
+
service.query('hello', undefined, undefined, {
|
|
3314
|
+
forceColdStart: true,
|
|
3315
|
+
allowedTools: ['Read', 'Write'],
|
|
3316
|
+
})
|
|
3317
|
+
);
|
|
3318
|
+
|
|
3319
|
+
expect(chunks.some(c => c.type === 'done')).toBe(true);
|
|
3320
|
+
});
|
|
3321
|
+
|
|
3322
|
+
it('should handle visible stream text events and skip duplicate assistant text', async () => {
|
|
3323
|
+
sdkMock.setMockMessages([
|
|
3324
|
+
{ type: 'system', subtype: 'init', session_id: 'stream-dedup' },
|
|
3325
|
+
{ type: 'stream_event', event: { type: 'content_block_start', content_block: { type: 'text', text: 'Hello' } } },
|
|
3326
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
|
|
3327
|
+
], { appendResult: true });
|
|
3328
|
+
|
|
3329
|
+
const chunks = await collectChunks(
|
|
3330
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3331
|
+
);
|
|
3332
|
+
|
|
3333
|
+
const textChunks = chunks.filter(c => c.type === 'text');
|
|
3334
|
+
expect(textChunks).toHaveLength(1);
|
|
3335
|
+
expect(textChunks[0].content).toBe('Hello');
|
|
3336
|
+
expect(chunks.some(c => c.type === 'done')).toBe(true);
|
|
3337
|
+
});
|
|
3338
|
+
|
|
3339
|
+
it('should keep assistant text when text deltas were empty', async () => {
|
|
3340
|
+
sdkMock.setMockMessages([
|
|
3341
|
+
{ type: 'system', subtype: 'init', session_id: 'empty-text-delta' },
|
|
3342
|
+
{
|
|
3343
|
+
type: 'stream_event',
|
|
3344
|
+
event: { type: 'content_block_delta', delta: { type: 'text_delta', text: '' } },
|
|
3345
|
+
},
|
|
3346
|
+
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Hello' }] } },
|
|
3347
|
+
], { appendResult: true });
|
|
3348
|
+
|
|
3349
|
+
const chunks = await collectChunks(
|
|
3350
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3351
|
+
);
|
|
3352
|
+
|
|
3353
|
+
const textChunks = chunks.filter(c => c.type === 'text');
|
|
3354
|
+
expect(textChunks).toHaveLength(1);
|
|
3355
|
+
expect(textChunks[0].content).toBe('Hello');
|
|
3356
|
+
});
|
|
3357
|
+
|
|
3358
|
+
it('should skip duplicate assistant thinking after visible stream thinking', async () => {
|
|
3359
|
+
sdkMock.setMockMessages([
|
|
3360
|
+
{ type: 'system', subtype: 'init', session_id: 'thinking-dedup' },
|
|
3361
|
+
{
|
|
3362
|
+
type: 'stream_event',
|
|
3363
|
+
event: {
|
|
3364
|
+
type: 'content_block_start',
|
|
3365
|
+
content_block: { type: 'thinking', thinking: 'Reasoning...' },
|
|
3366
|
+
},
|
|
3367
|
+
},
|
|
3368
|
+
{
|
|
3369
|
+
type: 'assistant',
|
|
3370
|
+
message: { content: [{ type: 'thinking', thinking: 'Reasoning...' }] },
|
|
3371
|
+
},
|
|
3372
|
+
], { appendResult: true });
|
|
3373
|
+
|
|
3374
|
+
const chunks = await collectChunks(
|
|
3375
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3376
|
+
);
|
|
3377
|
+
|
|
3378
|
+
const thinkingChunks = chunks.filter(c => c.type === 'thinking');
|
|
3379
|
+
expect(thinkingChunks).toHaveLength(1);
|
|
3380
|
+
expect(thinkingChunks[0].content).toBe('Reasoning...');
|
|
3381
|
+
});
|
|
3382
|
+
|
|
3383
|
+
it('should keep assistant thinking when thinking deltas were empty', async () => {
|
|
3384
|
+
sdkMock.setMockMessages([
|
|
3385
|
+
{ type: 'system', subtype: 'init', session_id: 'empty-thinking-delta' },
|
|
3386
|
+
{
|
|
3387
|
+
type: 'stream_event',
|
|
3388
|
+
event: { type: 'content_block_delta', delta: { type: 'thinking_delta', thinking: '' } },
|
|
3389
|
+
},
|
|
3390
|
+
{
|
|
3391
|
+
type: 'assistant',
|
|
3392
|
+
message: { content: [{ type: 'thinking', thinking: 'Reasoning...' }] },
|
|
3393
|
+
},
|
|
3394
|
+
], { appendResult: true });
|
|
3395
|
+
|
|
3396
|
+
const chunks = await collectChunks(
|
|
3397
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3398
|
+
);
|
|
3399
|
+
|
|
3400
|
+
const thinkingChunks = chunks.filter(c => c.type === 'thinking');
|
|
3401
|
+
expect(thinkingChunks).toHaveLength(1);
|
|
3402
|
+
expect(thinkingChunks[0].content).toBe('Reasoning...');
|
|
3403
|
+
});
|
|
3404
|
+
|
|
3405
|
+
it('should stream cumulative tool input updates during cold-start query', async () => {
|
|
3406
|
+
sdkMock.setMockMessages([
|
|
3407
|
+
{ type: 'system', subtype: 'init', session_id: 'stream-tool-input' },
|
|
3408
|
+
{
|
|
3409
|
+
type: 'stream_event',
|
|
3410
|
+
event: {
|
|
3411
|
+
type: 'content_block_start',
|
|
3412
|
+
index: 0,
|
|
3413
|
+
content_block: {
|
|
3414
|
+
type: 'tool_use',
|
|
3415
|
+
id: 'stream-tool-1',
|
|
3416
|
+
name: 'Write',
|
|
3417
|
+
input: {},
|
|
3418
|
+
},
|
|
3419
|
+
},
|
|
3420
|
+
},
|
|
3421
|
+
{
|
|
3422
|
+
type: 'stream_event',
|
|
3423
|
+
event: {
|
|
3424
|
+
type: 'content_block_delta',
|
|
3425
|
+
index: 0,
|
|
3426
|
+
delta: {
|
|
3427
|
+
type: 'input_json_delta',
|
|
3428
|
+
partial_json: '{"file_path":"notes.md"',
|
|
3429
|
+
},
|
|
3430
|
+
},
|
|
3431
|
+
},
|
|
3432
|
+
{
|
|
3433
|
+
type: 'stream_event',
|
|
3434
|
+
event: {
|
|
3435
|
+
type: 'content_block_delta',
|
|
3436
|
+
index: 0,
|
|
3437
|
+
delta: {
|
|
3438
|
+
type: 'input_json_delta',
|
|
3439
|
+
partial_json: ',"content":"Hello"',
|
|
3440
|
+
},
|
|
3441
|
+
},
|
|
3442
|
+
},
|
|
3443
|
+
], { appendResult: true });
|
|
3444
|
+
|
|
3445
|
+
const chunks = await collectChunks(
|
|
3446
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3447
|
+
);
|
|
3448
|
+
|
|
3449
|
+
const toolChunks = chunks.filter(c => c.type === 'tool_use');
|
|
3450
|
+
expect(toolChunks).toEqual([
|
|
3451
|
+
{
|
|
3452
|
+
type: 'tool_use',
|
|
3453
|
+
id: 'stream-tool-1',
|
|
3454
|
+
name: 'Write',
|
|
3455
|
+
input: {},
|
|
3456
|
+
},
|
|
3457
|
+
{
|
|
3458
|
+
type: 'tool_use',
|
|
3459
|
+
id: 'stream-tool-1',
|
|
3460
|
+
name: 'Write',
|
|
3461
|
+
input: { file_path: 'notes.md' },
|
|
3462
|
+
},
|
|
3463
|
+
{
|
|
3464
|
+
type: 'tool_use',
|
|
3465
|
+
id: 'stream-tool-1',
|
|
3466
|
+
name: 'Write',
|
|
3467
|
+
input: { file_path: 'notes.md', content: 'Hello' },
|
|
3468
|
+
},
|
|
3469
|
+
]);
|
|
3470
|
+
expect(chunks.some(c => c.type === 'done')).toBe(true);
|
|
3471
|
+
});
|
|
3472
|
+
|
|
3473
|
+
it('should yield usage chunks with sessionId', async () => {
|
|
3474
|
+
service.setSessionId('usage-cold-session');
|
|
3475
|
+
sdkMock.setMockMessages([
|
|
3476
|
+
{ type: 'system', subtype: 'init', session_id: 'usage-cold-session' },
|
|
3477
|
+
{
|
|
3478
|
+
type: 'assistant',
|
|
3479
|
+
message: {
|
|
3480
|
+
content: [{ type: 'text', text: 'Hi' }],
|
|
3481
|
+
usage: {
|
|
3482
|
+
input_tokens: 100,
|
|
3483
|
+
output_tokens: 50,
|
|
3484
|
+
cache_creation_input_tokens: 10,
|
|
3485
|
+
cache_read_input_tokens: 20,
|
|
3486
|
+
},
|
|
3487
|
+
},
|
|
3488
|
+
},
|
|
3489
|
+
], { appendResult: true });
|
|
3490
|
+
|
|
3491
|
+
const chunks = await collectChunks(
|
|
3492
|
+
service.query('hello', undefined, undefined, { forceColdStart: true })
|
|
3493
|
+
);
|
|
3494
|
+
|
|
3495
|
+
const usageChunks = chunks.filter(c => c.type === 'usage');
|
|
3496
|
+
expect(usageChunks.length).toBeGreaterThan(0);
|
|
3497
|
+
expect(usageChunks[0].sessionId).toBe('usage-cold-session');
|
|
3498
|
+
expect(chunks.some(c => c.type === 'done')).toBe(true);
|
|
3499
|
+
});
|
|
3500
|
+
});
|
|
3501
|
+
|
|
3502
|
+
describe('rewindFiles', () => {
|
|
3503
|
+
it('throws when no persistentQuery', async () => {
|
|
3504
|
+
(service as any).persistentQuery = null;
|
|
3505
|
+
await expect(service.rewindFiles('uuid')).rejects.toThrow('No active query');
|
|
3506
|
+
});
|
|
3507
|
+
|
|
3508
|
+
it('throws when shuttingDown', async () => {
|
|
3509
|
+
(service as any).persistentQuery = { rewindFiles: jest.fn() };
|
|
3510
|
+
(service as any).shuttingDown = true;
|
|
3511
|
+
await expect(service.rewindFiles('uuid')).rejects.toThrow('Service is shutting down');
|
|
3512
|
+
(service as any).shuttingDown = false;
|
|
3513
|
+
});
|
|
3514
|
+
|
|
3515
|
+
it('calls persistentQuery.rewindFiles with correct args', async () => {
|
|
3516
|
+
const mockRewindFiles = jest.fn().mockResolvedValue({ canRewind: true, filesChanged: ['a.txt'] });
|
|
3517
|
+
(service as any).persistentQuery = { rewindFiles: mockRewindFiles };
|
|
3518
|
+
(service as any).shuttingDown = false;
|
|
3519
|
+
|
|
3520
|
+
const result = await service.rewindFiles('test-uuid', true);
|
|
3521
|
+
|
|
3522
|
+
expect(mockRewindFiles).toHaveBeenCalledWith('test-uuid', { dryRun: true });
|
|
3523
|
+
expect(result).toEqual({ canRewind: true, filesChanged: ['a.txt'] });
|
|
3524
|
+
});
|
|
3525
|
+
});
|
|
3526
|
+
|
|
3527
|
+
describe('rewind', () => {
|
|
3528
|
+
it('conversation-only mode skips SDK file rewind and prepares resume checkpoint', async () => {
|
|
3529
|
+
const mockRewindFiles = jest.fn();
|
|
3530
|
+
const mockInterrupt = jest.fn().mockResolvedValue(undefined);
|
|
3531
|
+
(service as any).persistentQuery = { rewindFiles: mockRewindFiles, interrupt: mockInterrupt };
|
|
3532
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3533
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3534
|
+
(service as any).shuttingDown = false;
|
|
3535
|
+
|
|
3536
|
+
const result = await service.rewind('user-uuid', 'assistant-uuid', 'conversation');
|
|
3537
|
+
|
|
3538
|
+
expect(mockRewindFiles).not.toHaveBeenCalled();
|
|
3539
|
+
expect(result).toEqual({ canRewind: true, filesChanged: [] });
|
|
3540
|
+
expect((service as any).pendingResumeAt).toBe('assistant-uuid');
|
|
3541
|
+
expect((service as any).persistentQuery).toBeNull();
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
it('dry-runs first to capture filesChanged, then performs actual rewind', async () => {
|
|
3545
|
+
// SDK only returns filesChanged on dry run, not on actual rewind
|
|
3546
|
+
const mockRewindFiles = jest.fn()
|
|
3547
|
+
.mockResolvedValueOnce({ canRewind: true, filesChanged: ['a.txt'], insertions: 5, deletions: 3 })
|
|
3548
|
+
.mockResolvedValueOnce({ canRewind: true });
|
|
3549
|
+
const mockInterrupt = jest.fn().mockResolvedValue(undefined);
|
|
3550
|
+
(service as any).persistentQuery = { rewindFiles: mockRewindFiles, interrupt: mockInterrupt };
|
|
3551
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3552
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3553
|
+
(service as any).shuttingDown = false;
|
|
3554
|
+
|
|
3555
|
+
const result = await service.rewind('user-uuid', 'assistant-uuid');
|
|
3556
|
+
|
|
3557
|
+
expect(mockRewindFiles).toHaveBeenCalledTimes(2);
|
|
3558
|
+
expect(mockRewindFiles).toHaveBeenNthCalledWith(1, 'user-uuid', { dryRun: true });
|
|
3559
|
+
expect(mockRewindFiles).toHaveBeenNthCalledWith(2, 'user-uuid', { dryRun: undefined });
|
|
3560
|
+
expect(result.canRewind).toBe(true);
|
|
3561
|
+
expect(result.filesChanged).toEqual(['a.txt']);
|
|
3562
|
+
expect(result.insertions).toBe(5);
|
|
3563
|
+
expect(result.deletions).toBe(3);
|
|
3564
|
+
expect((service as any).pendingResumeAt).toBe('assistant-uuid');
|
|
3565
|
+
expect((service as any).persistentQuery).toBeNull();
|
|
3566
|
+
});
|
|
3567
|
+
|
|
3568
|
+
it('returns error without closing query when dry-run canRewind is false', async () => {
|
|
3569
|
+
const mockRewindFiles = jest.fn().mockResolvedValue({ canRewind: false, error: 'No checkpoint' });
|
|
3570
|
+
(service as any).persistentQuery = { rewindFiles: mockRewindFiles };
|
|
3571
|
+
(service as any).shuttingDown = false;
|
|
3572
|
+
|
|
3573
|
+
const result = await service.rewind('user-uuid', 'assistant-uuid');
|
|
3574
|
+
|
|
3575
|
+
expect(result.canRewind).toBe(false);
|
|
3576
|
+
expect(result.error).toBe('No checkpoint');
|
|
3577
|
+
// Only dry run should have been called
|
|
3578
|
+
expect(mockRewindFiles).toHaveBeenCalledTimes(1);
|
|
3579
|
+
// Query should NOT be closed
|
|
3580
|
+
expect((service as any).persistentQuery).not.toBeNull();
|
|
3581
|
+
});
|
|
3582
|
+
|
|
3583
|
+
it('closes the query when actual rewind canRewind is false', async () => {
|
|
3584
|
+
const mockRewindFiles = jest.fn()
|
|
3585
|
+
.mockResolvedValueOnce({ canRewind: true, filesChanged: ['a.txt'] })
|
|
3586
|
+
.mockResolvedValueOnce({ canRewind: false, error: 'Unexpected error' });
|
|
3587
|
+
const mockInterrupt = jest.fn().mockResolvedValue(undefined);
|
|
3588
|
+
(service as any).persistentQuery = { rewindFiles: mockRewindFiles, interrupt: mockInterrupt };
|
|
3589
|
+
(service as any).messageChannel = { close: jest.fn() };
|
|
3590
|
+
(service as any).queryAbortController = { abort: jest.fn() };
|
|
3591
|
+
(service as any).shuttingDown = false;
|
|
3592
|
+
|
|
3593
|
+
const result = await service.rewind('user-uuid', 'assistant-uuid');
|
|
3594
|
+
|
|
3595
|
+
expect(result.canRewind).toBe(false);
|
|
3596
|
+
expect(result.error).toBe('Unexpected error');
|
|
3597
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3598
|
+
expect((service as any).persistentQuery).toBeNull();
|
|
3599
|
+
});
|
|
3600
|
+
});
|
|
3601
|
+
|
|
3602
|
+
describe('buildSDKUserMessage uuid', () => {
|
|
3603
|
+
it('assigns a uuid to text-only messages', () => {
|
|
3604
|
+
const message = (service as any).buildSDKUserMessage('Hello');
|
|
3605
|
+
expect(message.uuid).toBeDefined();
|
|
3606
|
+
expect(typeof message.uuid).toBe('string');
|
|
3607
|
+
expect(message.uuid.length).toBeGreaterThan(0);
|
|
3608
|
+
});
|
|
3609
|
+
|
|
3610
|
+
it('assigns a uuid to image messages', () => {
|
|
3611
|
+
const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'b64', size: 10, source: 'file' }];
|
|
3612
|
+
const message = (service as any).buildSDKUserMessage('Look', images);
|
|
3613
|
+
expect(message.uuid).toBeDefined();
|
|
3614
|
+
expect(typeof message.uuid).toBe('string');
|
|
3615
|
+
});
|
|
3616
|
+
|
|
3617
|
+
it('assigns unique uuids to different messages', () => {
|
|
3618
|
+
const msg1 = (service as any).buildSDKUserMessage('Hello');
|
|
3619
|
+
const msg2 = (service as any).buildSDKUserMessage('World');
|
|
3620
|
+
expect(msg1.uuid).not.toBe(msg2.uuid);
|
|
3621
|
+
});
|
|
3622
|
+
});
|
|
3623
|
+
|
|
3624
|
+
describe('applyForkState (via syncConversationState)', () => {
|
|
3625
|
+
it('sets pendingForkSession and pendingResumeAt when conversation has forkSource but no sessionId', () => {
|
|
3626
|
+
service.syncConversationState({
|
|
3627
|
+
sessionId: null,
|
|
3628
|
+
providerState: { forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' } },
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3631
|
+
expect(service.getSessionId()).toBe('source-session');
|
|
3632
|
+
expect((service as any).pendingForkSession).toBe(true);
|
|
3633
|
+
expect((service as any).pendingResumeAt).toBe('asst-uuid-123');
|
|
3634
|
+
});
|
|
3635
|
+
|
|
3636
|
+
it('does not set pendingForkSession when conversation has its own sessionId', () => {
|
|
3637
|
+
service.syncConversationState({
|
|
3638
|
+
sessionId: 'own-session',
|
|
3639
|
+
providerState: { forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' } },
|
|
3640
|
+
});
|
|
3641
|
+
|
|
3642
|
+
expect(service.getSessionId()).toBe('own-session');
|
|
3643
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3644
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3645
|
+
});
|
|
3646
|
+
|
|
3647
|
+
it('resolves to null when no sessionId and no forkSource', () => {
|
|
3648
|
+
service.syncConversationState({
|
|
3649
|
+
sessionId: null,
|
|
3650
|
+
});
|
|
3651
|
+
|
|
3652
|
+
expect(service.getSessionId()).toBeNull();
|
|
3653
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3654
|
+
});
|
|
3655
|
+
|
|
3656
|
+
it('resolves to sessionId when only sessionId is present (no forkSource)', () => {
|
|
3657
|
+
service.syncConversationState({
|
|
3658
|
+
sessionId: 'existing-session',
|
|
3659
|
+
});
|
|
3660
|
+
|
|
3661
|
+
expect(service.getSessionId()).toBe('existing-session');
|
|
3662
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3663
|
+
});
|
|
3664
|
+
|
|
3665
|
+
it('clears pendingForkSession and pendingResumeAt from previous call', () => {
|
|
3666
|
+
// First call: set fork state
|
|
3667
|
+
service.syncConversationState({
|
|
3668
|
+
sessionId: null,
|
|
3669
|
+
providerState: { forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' } },
|
|
3670
|
+
});
|
|
3671
|
+
expect((service as any).pendingForkSession).toBe(true);
|
|
3672
|
+
expect((service as any).pendingResumeAt).toBe('asst-1');
|
|
3673
|
+
|
|
3674
|
+
// Second call: conversation has own sessionId, should clear fork state
|
|
3675
|
+
service.syncConversationState({
|
|
3676
|
+
sessionId: 'own-session',
|
|
3677
|
+
providerState: { forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' } },
|
|
3678
|
+
});
|
|
3679
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3680
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3681
|
+
});
|
|
3682
|
+
|
|
3683
|
+
it('clears pendingResumeAt when switching to non-fork conversation', () => {
|
|
3684
|
+
// Set fork state
|
|
3685
|
+
service.syncConversationState({
|
|
3686
|
+
sessionId: null,
|
|
3687
|
+
providerState: { forkSource: { sessionId: 'source-1', resumeAt: 'asst-1' } },
|
|
3688
|
+
});
|
|
3689
|
+
expect((service as any).pendingResumeAt).toBe('asst-1');
|
|
3690
|
+
|
|
3691
|
+
// Switch to a normal conversation (no forkSource)
|
|
3692
|
+
service.syncConversationState({ sessionId: 'normal-session' });
|
|
3693
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3694
|
+
});
|
|
3695
|
+
|
|
3696
|
+
it('treats conversation as not pending when providerSessionId is set', () => {
|
|
3697
|
+
service.syncConversationState({
|
|
3698
|
+
sessionId: null,
|
|
3699
|
+
providerState: {
|
|
3700
|
+
providerSessionId: 'sdk-session-xyz',
|
|
3701
|
+
forkSource: { sessionId: 'source-session', resumeAt: 'asst-uuid-123' },
|
|
3702
|
+
},
|
|
3703
|
+
});
|
|
3704
|
+
|
|
3705
|
+
// Resolves to forkSource.sessionId via the ?? chain, but does NOT set pending fork state
|
|
3706
|
+
expect(service.getSessionId()).toBe('source-session');
|
|
3707
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3708
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3709
|
+
});
|
|
3710
|
+
});
|
|
3711
|
+
|
|
3712
|
+
describe('normalizeTurnInvocation', () => {
|
|
3713
|
+
it('should route PreparedChatTurn with chatMessages as conversationHistory', () => {
|
|
3714
|
+
const turn = service.prepareTurn({ text: 'hello' });
|
|
3715
|
+
const chatMessages = [
|
|
3716
|
+
{ id: 'u1', role: 'user' as const, content: 'first', timestamp: 1 },
|
|
3717
|
+
{ id: 'a1', role: 'assistant' as const, content: 'reply', timestamp: 2 },
|
|
3718
|
+
];
|
|
3719
|
+
|
|
3720
|
+
const result = (service as any).normalizeTurnInvocation(turn, chatMessages);
|
|
3721
|
+
|
|
3722
|
+
expect(result.encodedTurn).toBe(turn);
|
|
3723
|
+
expect(result.request).toBe(turn.request);
|
|
3724
|
+
expect(result.conversationHistory).toBe(chatMessages);
|
|
3725
|
+
});
|
|
3726
|
+
|
|
3727
|
+
it('should route PreparedChatTurn with chatMessages and queryOptions', () => {
|
|
3728
|
+
const turn = service.prepareTurn({ text: 'hello' });
|
|
3729
|
+
const chatMessages = [
|
|
3730
|
+
{ id: 'u1', role: 'user' as const, content: 'first', timestamp: 1 },
|
|
3731
|
+
{ id: 'a1', role: 'assistant' as const, content: 'reply', timestamp: 2 },
|
|
3732
|
+
];
|
|
3733
|
+
const queryOptions = { model: 'claude-3-opus' };
|
|
3734
|
+
|
|
3735
|
+
const result = (service as any).normalizeTurnInvocation(turn, chatMessages, queryOptions);
|
|
3736
|
+
|
|
3737
|
+
expect(result.encodedTurn).toBe(turn);
|
|
3738
|
+
expect(result.conversationHistory).toBe(chatMessages);
|
|
3739
|
+
expect(result.queryOptions?.model).toBe('claude-3-opus');
|
|
3740
|
+
});
|
|
3741
|
+
|
|
3742
|
+
it('should route string with images, chatMessages, and queryOptions', () => {
|
|
3743
|
+
const images = [{ id: 'img1', name: 'test.png', mediaType: 'image/png', data: 'b64', size: 10, source: 'file' }];
|
|
3744
|
+
const chatMessages = [
|
|
3745
|
+
{ id: 'u1', role: 'user' as const, content: 'first', timestamp: 1 },
|
|
3746
|
+
{ id: 'a1', role: 'assistant' as const, content: 'reply', timestamp: 2 },
|
|
3747
|
+
];
|
|
3748
|
+
const queryOptions = { forceColdStart: true };
|
|
3749
|
+
|
|
3750
|
+
const result = (service as any).normalizeTurnInvocation('describe', images, chatMessages, queryOptions);
|
|
3751
|
+
|
|
3752
|
+
expect(result.request.text).toBe('describe');
|
|
3753
|
+
expect(result.request.images).toBe(images);
|
|
3754
|
+
expect(result.conversationHistory).toBe(chatMessages);
|
|
3755
|
+
expect(result.queryOptions?.forceColdStart).toBe(true);
|
|
3756
|
+
});
|
|
3757
|
+
|
|
3758
|
+
it('should route empty array as undefined conversationHistory', () => {
|
|
3759
|
+
const turn = service.prepareTurn({ text: 'hello' });
|
|
3760
|
+
|
|
3761
|
+
const result = (service as any).normalizeTurnInvocation(turn, []);
|
|
3762
|
+
|
|
3763
|
+
expect(result.conversationHistory).toBeUndefined();
|
|
3764
|
+
});
|
|
3765
|
+
});
|
|
3766
|
+
|
|
3767
|
+
describe('syncConversationState', () => {
|
|
3768
|
+
it('resolves fork state before updating the session', () => {
|
|
3769
|
+
const setSessionIdSpy = jest.spyOn(service, 'setSessionId').mockImplementation(() => {});
|
|
3770
|
+
|
|
3771
|
+
service.syncConversationState({
|
|
3772
|
+
sessionId: null,
|
|
3773
|
+
providerState: { forkSource: { sessionId: 'source-session', resumeAt: 'assistant-uuid' } },
|
|
3774
|
+
}, ['/external/path']);
|
|
3775
|
+
|
|
3776
|
+
expect(setSessionIdSpy).toHaveBeenCalledWith('source-session', ['/external/path']);
|
|
3777
|
+
expect((service as any).pendingForkSession).toBe(true);
|
|
3778
|
+
expect((service as any).pendingResumeAt).toBe('assistant-uuid');
|
|
3779
|
+
});
|
|
3780
|
+
|
|
3781
|
+
it('clears pending fork metadata when resetting conversation state', () => {
|
|
3782
|
+
const setSessionIdSpy = jest.spyOn(service, 'setSessionId').mockImplementation(() => {});
|
|
3783
|
+
|
|
3784
|
+
service.syncConversationState({
|
|
3785
|
+
sessionId: null,
|
|
3786
|
+
providerState: { forkSource: { sessionId: 'source-session', resumeAt: 'assistant-uuid' } },
|
|
3787
|
+
});
|
|
3788
|
+
|
|
3789
|
+
service.syncConversationState(null, ['/external/path']);
|
|
3790
|
+
|
|
3791
|
+
expect(setSessionIdSpy).toHaveBeenCalledWith(null, ['/external/path']);
|
|
3792
|
+
expect((service as any).pendingForkSession).toBe(false);
|
|
3793
|
+
expect((service as any).pendingResumeAt).toBeUndefined();
|
|
3794
|
+
});
|
|
3795
|
+
});
|
|
3796
|
+
});
|