@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,2359 @@
|
|
|
1
|
+
import { existsSync } from 'fs';
|
|
2
|
+
import * as fsPromises from 'fs/promises';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
collectAsyncSubagentResults,
|
|
7
|
+
deleteSDKSession,
|
|
8
|
+
encodeVaultPathForSDK,
|
|
9
|
+
filterActiveBranch,
|
|
10
|
+
getSDKProjectsPath,
|
|
11
|
+
getSDKSessionPath,
|
|
12
|
+
isValidSessionId,
|
|
13
|
+
loadSDKSessionMessages,
|
|
14
|
+
loadSubagentFinalResult,
|
|
15
|
+
loadSubagentToolCalls,
|
|
16
|
+
parseSDKMessageToChat,
|
|
17
|
+
readSDKSession,
|
|
18
|
+
resolveToolUseResultStatus,
|
|
19
|
+
type SDKNativeMessage,
|
|
20
|
+
sdkSessionExists,
|
|
21
|
+
} from '@/providers/claude/history/ClaudeHistoryStore';
|
|
22
|
+
import { extractToolResultContent } from '@/providers/claude/sdk/toolResultContent';
|
|
23
|
+
|
|
24
|
+
// Mock fs, fs/promises, and os modules
|
|
25
|
+
jest.mock('fs', () => ({
|
|
26
|
+
existsSync: jest.fn(),
|
|
27
|
+
}));
|
|
28
|
+
jest.mock('fs/promises');
|
|
29
|
+
jest.mock('os');
|
|
30
|
+
|
|
31
|
+
const mockExistsSync = existsSync as jest.MockedFunction<typeof existsSync>;
|
|
32
|
+
const mockFsPromises = fsPromises as jest.Mocked<typeof fsPromises>;
|
|
33
|
+
const mockOs = os as jest.Mocked<typeof os>;
|
|
34
|
+
|
|
35
|
+
describe('sdkSession', () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.clearAllMocks();
|
|
38
|
+
mockOs.homedir.mockReturnValue('/Users/test');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('encodeVaultPathForSDK', () => {
|
|
42
|
+
it('encodes vault path by replacing all non-alphanumeric chars with dash', () => {
|
|
43
|
+
const encoded = encodeVaultPathForSDK('/Users/test/vault');
|
|
44
|
+
// SDK replaces ALL non-alphanumeric characters with `-`
|
|
45
|
+
expect(encoded).toBe('-Users-test-vault');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('handles paths with spaces and special characters', () => {
|
|
49
|
+
const encoded = encodeVaultPathForSDK("/Users/test/My Vault's~Data");
|
|
50
|
+
expect(encoded).toBe('-Users-test-My-Vault-s-Data');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('handles Unicode characters (Chinese, Japanese, etc.)', () => {
|
|
54
|
+
// Unicode characters should be replaced with `-` to match SDK behavior
|
|
55
|
+
const encoded = encodeVaultPathForSDK('/Volumes/[Work]弘毅之鹰/学习/东京大学/2025年 秋');
|
|
56
|
+
// All non-alphanumeric (including Chinese, brackets) become `-`
|
|
57
|
+
expect(encoded).toBe('-Volumes--Work--------------2025---');
|
|
58
|
+
// Verify only ASCII alphanumeric and dash remain
|
|
59
|
+
expect(encoded).toMatch(/^[a-zA-Z0-9-]+$/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('handles brackets and other special characters', () => {
|
|
63
|
+
const encoded = encodeVaultPathForSDK('/Users/test/[my-vault](notes)');
|
|
64
|
+
expect(encoded).toBe('-Users-test--my-vault--notes-');
|
|
65
|
+
expect(encoded).not.toContain('[');
|
|
66
|
+
expect(encoded).not.toContain(']');
|
|
67
|
+
expect(encoded).not.toContain('(');
|
|
68
|
+
expect(encoded).not.toContain(')');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('produces consistent encoding', () => {
|
|
72
|
+
const path1 = '/Users/test/my-vault';
|
|
73
|
+
const encoded1 = encodeVaultPathForSDK(path1);
|
|
74
|
+
const encoded2 = encodeVaultPathForSDK(path1);
|
|
75
|
+
expect(encoded1).toBe(encoded2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('produces different encodings for different paths', () => {
|
|
79
|
+
const encoded1 = encodeVaultPathForSDK('/Users/test/vault1');
|
|
80
|
+
const encoded2 = encodeVaultPathForSDK('/Users/test/vault2');
|
|
81
|
+
expect(encoded1).not.toBe(encoded2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('handles backslashes for Windows compatibility', () => {
|
|
85
|
+
// Test that backslashes are replaced (Windows path separators)
|
|
86
|
+
// Note: path.resolve may modify the input, so we check the output contains no backslashes
|
|
87
|
+
const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault');
|
|
88
|
+
expect(encoded).not.toContain('\\');
|
|
89
|
+
expect(encoded).toContain('-Users-test-vault');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('replaces colons for Windows drive letters', () => {
|
|
93
|
+
// Windows paths have colons after drive letter
|
|
94
|
+
const encoded = encodeVaultPathForSDK('C:\\Users\\test\\vault');
|
|
95
|
+
expect(encoded).not.toContain(':');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('getSDKProjectsPath', () => {
|
|
100
|
+
it('returns path under home directory', () => {
|
|
101
|
+
const projectsPath = getSDKProjectsPath();
|
|
102
|
+
expect(projectsPath).toBe('/Users/test/.claude/projects');
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe('isValidSessionId', () => {
|
|
107
|
+
it('accepts valid UUID-style session IDs', () => {
|
|
108
|
+
expect(isValidSessionId('abc123')).toBe(true);
|
|
109
|
+
expect(isValidSessionId('session-123')).toBe(true);
|
|
110
|
+
expect(isValidSessionId('a1b2c3d4-e5f6-7890-abcd-ef1234567890')).toBe(true);
|
|
111
|
+
expect(isValidSessionId('test_session_id')).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('rejects empty or too long session IDs', () => {
|
|
115
|
+
expect(isValidSessionId('')).toBe(false);
|
|
116
|
+
expect(isValidSessionId('a'.repeat(129))).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('rejects path traversal attempts', () => {
|
|
120
|
+
expect(isValidSessionId('../etc/passwd')).toBe(false);
|
|
121
|
+
expect(isValidSessionId('..\\windows\\system32')).toBe(false);
|
|
122
|
+
expect(isValidSessionId('foo/../bar')).toBe(false);
|
|
123
|
+
expect(isValidSessionId('session/subdir')).toBe(false);
|
|
124
|
+
expect(isValidSessionId('session\\subdir')).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects special characters', () => {
|
|
128
|
+
expect(isValidSessionId('session.jsonl')).toBe(false);
|
|
129
|
+
expect(isValidSessionId('session:123')).toBe(false);
|
|
130
|
+
expect(isValidSessionId('session@host')).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe('getSDKSessionPath', () => {
|
|
135
|
+
it('constructs correct session file path', () => {
|
|
136
|
+
const sessionPath = getSDKSessionPath('/Users/test/vault', 'session-123');
|
|
137
|
+
expect(sessionPath).toContain('.claude/projects');
|
|
138
|
+
expect(sessionPath).toContain('session-123.jsonl');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('throws error for path traversal attempts', () => {
|
|
142
|
+
expect(() => getSDKSessionPath('/Users/test/vault', '../etc/passwd')).toThrow('Invalid session ID');
|
|
143
|
+
expect(() => getSDKSessionPath('/Users/test/vault', 'foo/../bar')).toThrow('Invalid session ID');
|
|
144
|
+
expect(() => getSDKSessionPath('/Users/test/vault', 'session/subdir')).toThrow('Invalid session ID');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('throws error for empty session ID', () => {
|
|
148
|
+
expect(() => getSDKSessionPath('/Users/test/vault', '')).toThrow('Invalid session ID');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('sdkSessionExists', () => {
|
|
153
|
+
it('returns true when session file exists', () => {
|
|
154
|
+
mockExistsSync.mockReturnValue(true);
|
|
155
|
+
|
|
156
|
+
const exists = sdkSessionExists('/Users/test/vault', 'session-abc');
|
|
157
|
+
|
|
158
|
+
expect(exists).toBe(true);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('returns false when session file does not exist', () => {
|
|
162
|
+
mockExistsSync.mockReturnValue(false);
|
|
163
|
+
|
|
164
|
+
const exists = sdkSessionExists('/Users/test/vault', 'session-xyz');
|
|
165
|
+
|
|
166
|
+
expect(exists).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('returns false on error', () => {
|
|
170
|
+
mockExistsSync.mockImplementation(() => {
|
|
171
|
+
throw new Error('Permission denied');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const exists = sdkSessionExists('/Users/test/vault', 'session-err');
|
|
175
|
+
|
|
176
|
+
expect(exists).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('deleteSDKSession', () => {
|
|
181
|
+
it('deletes session file when it exists', async () => {
|
|
182
|
+
mockExistsSync.mockReturnValue(true);
|
|
183
|
+
mockFsPromises.unlink.mockResolvedValue(undefined);
|
|
184
|
+
|
|
185
|
+
await deleteSDKSession('/Users/test/vault', 'session-abc');
|
|
186
|
+
|
|
187
|
+
expect(mockFsPromises.unlink).toHaveBeenCalledWith(
|
|
188
|
+
'/Users/test/.claude/projects/-Users-test-vault/session-abc.jsonl'
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('does nothing when session file does not exist', async () => {
|
|
193
|
+
mockExistsSync.mockReturnValue(false);
|
|
194
|
+
|
|
195
|
+
await deleteSDKSession('/Users/test/vault', 'nonexistent');
|
|
196
|
+
|
|
197
|
+
expect(mockFsPromises.unlink).not.toHaveBeenCalled();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('fails silently when unlink throws', async () => {
|
|
201
|
+
mockExistsSync.mockReturnValue(true);
|
|
202
|
+
mockFsPromises.unlink.mockRejectedValue(new Error('Permission denied'));
|
|
203
|
+
|
|
204
|
+
// Should not throw
|
|
205
|
+
await expect(deleteSDKSession('/Users/test/vault', 'session-err')).resolves.toBeUndefined();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('does nothing for invalid session ID', async () => {
|
|
209
|
+
await deleteSDKSession('/Users/test/vault', '../invalid');
|
|
210
|
+
|
|
211
|
+
expect(mockFsPromises.unlink).not.toHaveBeenCalled();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('readSDKSession', () => {
|
|
216
|
+
it('returns empty result when file does not exist', async () => {
|
|
217
|
+
mockExistsSync.mockReturnValue(false);
|
|
218
|
+
|
|
219
|
+
const result = await readSDKSession('/Users/test/vault', 'nonexistent');
|
|
220
|
+
|
|
221
|
+
expect(result.messages).toEqual([]);
|
|
222
|
+
expect(result.skippedLines).toBe(0);
|
|
223
|
+
expect(result.error).toBeUndefined();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('parses valid JSONL file', async () => {
|
|
227
|
+
mockExistsSync.mockReturnValue(true);
|
|
228
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
229
|
+
'{"type":"user","uuid":"u1","message":{"content":"Hello"}}',
|
|
230
|
+
'{"type":"assistant","uuid":"a1","message":{"content":"Hi there"}}',
|
|
231
|
+
].join('\n'));
|
|
232
|
+
|
|
233
|
+
const result = await readSDKSession('/Users/test/vault', 'session-1');
|
|
234
|
+
|
|
235
|
+
expect(result.messages).toHaveLength(2);
|
|
236
|
+
expect(result.messages[0].type).toBe('user');
|
|
237
|
+
expect(result.messages[1].type).toBe('assistant');
|
|
238
|
+
expect(result.skippedLines).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('skips invalid JSON lines and reports count', async () => {
|
|
242
|
+
mockExistsSync.mockReturnValue(true);
|
|
243
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
244
|
+
'{"type":"user","uuid":"u1","message":{"content":"Hello"}}',
|
|
245
|
+
'invalid json line',
|
|
246
|
+
'{"type":"assistant","uuid":"a1","message":{"content":"Hi"}}',
|
|
247
|
+
].join('\n'));
|
|
248
|
+
|
|
249
|
+
const result = await readSDKSession('/Users/test/vault', 'session-2');
|
|
250
|
+
|
|
251
|
+
expect(result.messages).toHaveLength(2);
|
|
252
|
+
expect(result.skippedLines).toBe(1);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('handles empty lines', async () => {
|
|
256
|
+
mockExistsSync.mockReturnValue(true);
|
|
257
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
258
|
+
'{"type":"user","uuid":"u1","message":{"content":"Test"}}',
|
|
259
|
+
'',
|
|
260
|
+
' ',
|
|
261
|
+
'{"type":"assistant","uuid":"a1","message":{"content":"Response"}}',
|
|
262
|
+
].join('\n'));
|
|
263
|
+
|
|
264
|
+
const result = await readSDKSession('/Users/test/vault', 'session-3');
|
|
265
|
+
|
|
266
|
+
expect(result.messages).toHaveLength(2);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it('returns error on read failure', async () => {
|
|
270
|
+
mockExistsSync.mockReturnValue(true);
|
|
271
|
+
mockFsPromises.readFile.mockRejectedValue(new Error('Read error'));
|
|
272
|
+
|
|
273
|
+
const result = await readSDKSession('/Users/test/vault', 'session-err');
|
|
274
|
+
|
|
275
|
+
expect(result.messages).toEqual([]);
|
|
276
|
+
expect(result.error).toBe('Read error');
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe('loadSubagentToolCalls', () => {
|
|
281
|
+
it('loads tool calls from subagent sidechain JSONL', async () => {
|
|
282
|
+
mockExistsSync.mockReturnValue(true);
|
|
283
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
284
|
+
'{"type":"assistant","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Bash","input":{"command":"ls"}}]}}',
|
|
285
|
+
'{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"ok","is_error":false}]}}',
|
|
286
|
+
].join('\n'));
|
|
287
|
+
|
|
288
|
+
const toolCalls = await loadSubagentToolCalls(
|
|
289
|
+
'/Users/test/vault',
|
|
290
|
+
'session-abc',
|
|
291
|
+
'a123'
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(mockFsPromises.readFile).toHaveBeenCalledWith(
|
|
295
|
+
'/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl',
|
|
296
|
+
'utf-8'
|
|
297
|
+
);
|
|
298
|
+
expect(toolCalls).toHaveLength(1);
|
|
299
|
+
expect(toolCalls[0]).toEqual(
|
|
300
|
+
expect.objectContaining({
|
|
301
|
+
id: 'sub-tool-1',
|
|
302
|
+
name: 'Bash',
|
|
303
|
+
input: { command: 'ls' },
|
|
304
|
+
status: 'completed',
|
|
305
|
+
result: 'ok',
|
|
306
|
+
})
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it('filters out entries that only have tool_result but no tool_use', async () => {
|
|
311
|
+
mockExistsSync.mockReturnValue(true);
|
|
312
|
+
mockFsPromises.readFile.mockResolvedValue(
|
|
313
|
+
'{"type":"user","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"missing","content":"done"}]}}'
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const toolCalls = await loadSubagentToolCalls(
|
|
317
|
+
'/Users/test/vault',
|
|
318
|
+
'session-abc',
|
|
319
|
+
'a123'
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
expect(toolCalls).toEqual([]);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('returns empty when agent id is invalid', async () => {
|
|
326
|
+
const toolCalls = await loadSubagentToolCalls(
|
|
327
|
+
'/Users/test/vault',
|
|
328
|
+
'session-abc',
|
|
329
|
+
'../bad-agent'
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
expect(toolCalls).toEqual([]);
|
|
333
|
+
expect(mockFsPromises.readFile).not.toHaveBeenCalled();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
describe('loadSubagentFinalResult', () => {
|
|
338
|
+
it('returns the latest assistant text from sidecar JSONL', async () => {
|
|
339
|
+
mockExistsSync.mockReturnValue(true);
|
|
340
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
341
|
+
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"First"}]}}',
|
|
342
|
+
'{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Final answer"}]}}',
|
|
343
|
+
].join('\n'));
|
|
344
|
+
|
|
345
|
+
const result = await loadSubagentFinalResult(
|
|
346
|
+
'/Users/test/vault',
|
|
347
|
+
'session-abc',
|
|
348
|
+
'a123'
|
|
349
|
+
);
|
|
350
|
+
|
|
351
|
+
expect(result).toBe('Final answer');
|
|
352
|
+
expect(mockFsPromises.readFile).toHaveBeenCalledWith(
|
|
353
|
+
'/Users/test/.claude/projects/-Users-test-vault/session-abc/subagents/agent-a123.jsonl',
|
|
354
|
+
'utf-8'
|
|
355
|
+
);
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('falls back to top-level result when assistant text is absent', async () => {
|
|
359
|
+
mockExistsSync.mockReturnValue(true);
|
|
360
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
361
|
+
'{"type":"progress","result":"Intermediate result"}',
|
|
362
|
+
'{"type":"result","result":"Final result text"}',
|
|
363
|
+
].join('\n'));
|
|
364
|
+
|
|
365
|
+
const result = await loadSubagentFinalResult(
|
|
366
|
+
'/Users/test/vault',
|
|
367
|
+
'session-abc',
|
|
368
|
+
'a123'
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(result).toBe('Final result text');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('returns null when sidecar file is missing or agent id is invalid', async () => {
|
|
375
|
+
mockExistsSync.mockReturnValue(false);
|
|
376
|
+
|
|
377
|
+
const missing = await loadSubagentFinalResult(
|
|
378
|
+
'/Users/test/vault',
|
|
379
|
+
'session-abc',
|
|
380
|
+
'a123'
|
|
381
|
+
);
|
|
382
|
+
expect(missing).toBeNull();
|
|
383
|
+
|
|
384
|
+
const invalid = await loadSubagentFinalResult(
|
|
385
|
+
'/Users/test/vault',
|
|
386
|
+
'session-abc',
|
|
387
|
+
'../bad-agent'
|
|
388
|
+
);
|
|
389
|
+
expect(invalid).toBeNull();
|
|
390
|
+
expect(mockFsPromises.readFile).not.toHaveBeenCalled();
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('parseSDKMessageToChat', () => {
|
|
395
|
+
it('converts user message with string content', () => {
|
|
396
|
+
const sdkMsg: SDKNativeMessage = {
|
|
397
|
+
type: 'user',
|
|
398
|
+
uuid: 'user-123',
|
|
399
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
400
|
+
message: {
|
|
401
|
+
content: 'What is the weather?',
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
406
|
+
|
|
407
|
+
expect(chatMsg).not.toBeNull();
|
|
408
|
+
expect(chatMsg!.id).toBe('user-123');
|
|
409
|
+
expect(chatMsg!.role).toBe('user');
|
|
410
|
+
expect(chatMsg!.content).toBe('What is the weather?');
|
|
411
|
+
expect(chatMsg!.timestamp).toBe(new Date('2024-01-15T10:30:00Z').getTime());
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('sets userMessageId on user messages with uuid', () => {
|
|
415
|
+
const sdkMsg: SDKNativeMessage = {
|
|
416
|
+
type: 'user',
|
|
417
|
+
uuid: 'user-rewind-123',
|
|
418
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
419
|
+
message: { content: 'Hello' },
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
423
|
+
|
|
424
|
+
expect(chatMsg!.userMessageId).toBe('user-rewind-123');
|
|
425
|
+
expect(chatMsg!.assistantMessageId).toBeUndefined();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('sets assistantMessageId on assistant messages with uuid', () => {
|
|
429
|
+
const sdkMsg: SDKNativeMessage = {
|
|
430
|
+
type: 'assistant',
|
|
431
|
+
uuid: 'asst-rewind-456',
|
|
432
|
+
timestamp: '2024-01-15T10:31:00Z',
|
|
433
|
+
message: {
|
|
434
|
+
content: [{ type: 'text', text: 'Hello back' }],
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
439
|
+
|
|
440
|
+
expect(chatMsg!.assistantMessageId).toBe('asst-rewind-456');
|
|
441
|
+
expect(chatMsg!.userMessageId).toBeUndefined();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it('does not set SDK UUIDs when uuid is absent', () => {
|
|
445
|
+
const sdkMsg: SDKNativeMessage = {
|
|
446
|
+
type: 'user',
|
|
447
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
448
|
+
message: { content: 'No uuid' },
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
452
|
+
|
|
453
|
+
expect(chatMsg!.userMessageId).toBeUndefined();
|
|
454
|
+
expect(chatMsg!.assistantMessageId).toBeUndefined();
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('converts assistant message with text content blocks', () => {
|
|
458
|
+
const sdkMsg: SDKNativeMessage = {
|
|
459
|
+
type: 'assistant',
|
|
460
|
+
uuid: 'asst-456',
|
|
461
|
+
timestamp: '2024-01-15T10:31:00Z',
|
|
462
|
+
message: {
|
|
463
|
+
content: [
|
|
464
|
+
{ type: 'text', text: 'The weather is sunny.' },
|
|
465
|
+
{ type: 'text', text: 'Temperature is 72°F.' },
|
|
466
|
+
],
|
|
467
|
+
},
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
471
|
+
|
|
472
|
+
expect(chatMsg).not.toBeNull();
|
|
473
|
+
expect(chatMsg!.id).toBe('asst-456');
|
|
474
|
+
expect(chatMsg!.role).toBe('assistant');
|
|
475
|
+
expect(chatMsg!.content).toBe('The weather is sunny.\nTemperature is 72°F.');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('extracts tool calls from content blocks', () => {
|
|
479
|
+
const sdkMsg: SDKNativeMessage = {
|
|
480
|
+
type: 'assistant',
|
|
481
|
+
uuid: 'asst-tool',
|
|
482
|
+
timestamp: '2024-01-15T10:32:00Z',
|
|
483
|
+
message: {
|
|
484
|
+
content: [
|
|
485
|
+
{ type: 'text', text: 'Let me search for that.' },
|
|
486
|
+
{
|
|
487
|
+
type: 'tool_use',
|
|
488
|
+
id: 'tool-1',
|
|
489
|
+
name: 'WebSearch',
|
|
490
|
+
input: { query: 'weather today' },
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
type: 'tool_result',
|
|
494
|
+
tool_use_id: 'tool-1',
|
|
495
|
+
content: 'Sunny, 72°F',
|
|
496
|
+
},
|
|
497
|
+
],
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
502
|
+
|
|
503
|
+
expect(chatMsg).not.toBeNull();
|
|
504
|
+
expect(chatMsg!.toolCalls).toHaveLength(1);
|
|
505
|
+
expect(chatMsg!.toolCalls![0].id).toBe('tool-1');
|
|
506
|
+
expect(chatMsg!.toolCalls![0].name).toBe('WebSearch');
|
|
507
|
+
expect(chatMsg!.toolCalls![0].input).toEqual({ query: 'weather today' });
|
|
508
|
+
expect(chatMsg!.toolCalls![0].status).toBe('completed');
|
|
509
|
+
expect(chatMsg!.toolCalls![0].result).toBe('Sunny, 72°F');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('marks tool call as error when is_error is true', () => {
|
|
513
|
+
const sdkMsg: SDKNativeMessage = {
|
|
514
|
+
type: 'assistant',
|
|
515
|
+
uuid: 'asst-err',
|
|
516
|
+
timestamp: '2024-01-15T10:33:00Z',
|
|
517
|
+
message: {
|
|
518
|
+
content: [
|
|
519
|
+
{
|
|
520
|
+
type: 'tool_use',
|
|
521
|
+
id: 'tool-err',
|
|
522
|
+
name: 'Bash',
|
|
523
|
+
input: { command: 'invalid' },
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
type: 'tool_result',
|
|
527
|
+
tool_use_id: 'tool-err',
|
|
528
|
+
content: 'Command not found',
|
|
529
|
+
is_error: true,
|
|
530
|
+
},
|
|
531
|
+
],
|
|
532
|
+
},
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
536
|
+
|
|
537
|
+
expect(chatMsg!.toolCalls![0].status).toBe('error');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
it('keeps tool calls running when no matching tool_result exists yet', () => {
|
|
541
|
+
const sdkMsg: SDKNativeMessage = {
|
|
542
|
+
type: 'assistant',
|
|
543
|
+
uuid: 'asst-running',
|
|
544
|
+
timestamp: '2024-01-15T10:33:30Z',
|
|
545
|
+
message: {
|
|
546
|
+
content: [
|
|
547
|
+
{
|
|
548
|
+
type: 'tool_use',
|
|
549
|
+
id: 'tool-running',
|
|
550
|
+
name: 'Read',
|
|
551
|
+
input: { file_path: 'notes/todo.md' },
|
|
552
|
+
},
|
|
553
|
+
],
|
|
554
|
+
},
|
|
555
|
+
};
|
|
556
|
+
|
|
557
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
558
|
+
|
|
559
|
+
expect(chatMsg!.toolCalls![0].status).toBe('running');
|
|
560
|
+
expect(chatMsg!.toolCalls![0].result).toBeUndefined();
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
it('extracts thinking content blocks', () => {
|
|
564
|
+
const sdkMsg: SDKNativeMessage = {
|
|
565
|
+
type: 'assistant',
|
|
566
|
+
uuid: 'asst-think',
|
|
567
|
+
timestamp: '2024-01-15T10:34:00Z',
|
|
568
|
+
message: {
|
|
569
|
+
content: [
|
|
570
|
+
{ type: 'thinking', thinking: 'Let me consider this...' },
|
|
571
|
+
{ type: 'text', text: 'Here is my answer.' },
|
|
572
|
+
],
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
577
|
+
|
|
578
|
+
expect(chatMsg!.contentBlocks).toHaveLength(2);
|
|
579
|
+
|
|
580
|
+
const thinkingBlock = chatMsg!.contentBlocks![0];
|
|
581
|
+
expect(thinkingBlock.type).toBe('thinking');
|
|
582
|
+
// Type narrowing for thinking block content check
|
|
583
|
+
expect(thinkingBlock.type === 'thinking' && thinkingBlock.content).toBe('Let me consider this...');
|
|
584
|
+
|
|
585
|
+
expect(chatMsg!.contentBlocks![1].type).toBe('text');
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
it('preserves text block whitespace in contentBlocks', () => {
|
|
589
|
+
const sdkMsg: SDKNativeMessage = {
|
|
590
|
+
type: 'assistant',
|
|
591
|
+
uuid: 'asst-whitespace',
|
|
592
|
+
timestamp: '2024-01-15T10:34:30Z',
|
|
593
|
+
message: {
|
|
594
|
+
content: [
|
|
595
|
+
{ type: 'text', text: ' Preserve leading and trailing space ' },
|
|
596
|
+
],
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
601
|
+
|
|
602
|
+
expect(chatMsg!.content).toBe(' Preserve leading and trailing space ');
|
|
603
|
+
expect(chatMsg!.contentBlocks).toEqual([
|
|
604
|
+
{ type: 'text', content: ' Preserve leading and trailing space ' },
|
|
605
|
+
]);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
it('returns null for system messages', () => {
|
|
609
|
+
const sdkMsg: SDKNativeMessage = {
|
|
610
|
+
type: 'system',
|
|
611
|
+
uuid: 'sys-1',
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
615
|
+
|
|
616
|
+
expect(chatMsg).toBeNull();
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('returns synthetic assistant message for compact_boundary system messages', () => {
|
|
620
|
+
const sdkMsg: SDKNativeMessage = {
|
|
621
|
+
type: 'system',
|
|
622
|
+
subtype: 'compact_boundary',
|
|
623
|
+
uuid: 'compact-1',
|
|
624
|
+
timestamp: '2024-06-15T12:00:00Z',
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
628
|
+
|
|
629
|
+
expect(chatMsg).not.toBeNull();
|
|
630
|
+
expect(chatMsg!.id).toBe('compact-1');
|
|
631
|
+
expect(chatMsg!.role).toBe('assistant');
|
|
632
|
+
expect(chatMsg!.content).toBe('');
|
|
633
|
+
expect(chatMsg!.timestamp).toBe(new Date('2024-06-15T12:00:00Z').getTime());
|
|
634
|
+
expect(chatMsg!.contentBlocks).toEqual([{ type: 'context_compacted' }]);
|
|
635
|
+
});
|
|
636
|
+
|
|
637
|
+
it('generates ID for compact_boundary without uuid', () => {
|
|
638
|
+
const sdkMsg: SDKNativeMessage = {
|
|
639
|
+
type: 'system',
|
|
640
|
+
subtype: 'compact_boundary',
|
|
641
|
+
};
|
|
642
|
+
|
|
643
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
644
|
+
|
|
645
|
+
expect(chatMsg).not.toBeNull();
|
|
646
|
+
expect(chatMsg!.id).toMatch(/^compact-/);
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it('returns null for result messages', () => {
|
|
650
|
+
const sdkMsg: SDKNativeMessage = {
|
|
651
|
+
type: 'result',
|
|
652
|
+
uuid: 'res-1',
|
|
653
|
+
};
|
|
654
|
+
|
|
655
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
656
|
+
|
|
657
|
+
expect(chatMsg).toBeNull();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it('returns null for file-history-snapshot messages', () => {
|
|
661
|
+
const sdkMsg: SDKNativeMessage = {
|
|
662
|
+
type: 'file-history-snapshot',
|
|
663
|
+
uuid: 'fhs-1',
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
667
|
+
|
|
668
|
+
expect(chatMsg).toBeNull();
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('generates ID when uuid is missing', () => {
|
|
672
|
+
const sdkMsg: SDKNativeMessage = {
|
|
673
|
+
type: 'user',
|
|
674
|
+
timestamp: '2024-01-15T10:35:00Z',
|
|
675
|
+
message: {
|
|
676
|
+
content: 'No UUID message',
|
|
677
|
+
},
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
681
|
+
|
|
682
|
+
expect(chatMsg).not.toBeNull();
|
|
683
|
+
expect(chatMsg!.id).toMatch(/^sdk-/);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('uses current time when timestamp is missing', () => {
|
|
687
|
+
const before = Date.now();
|
|
688
|
+
const sdkMsg: SDKNativeMessage = {
|
|
689
|
+
type: 'user',
|
|
690
|
+
uuid: 'no-time',
|
|
691
|
+
message: {
|
|
692
|
+
content: 'No timestamp',
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
|
|
696
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
697
|
+
const after = Date.now();
|
|
698
|
+
|
|
699
|
+
expect(chatMsg!.timestamp).toBeGreaterThanOrEqual(before);
|
|
700
|
+
expect(chatMsg!.timestamp).toBeLessThanOrEqual(after);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('marks interrupt messages with isInterrupt flag', () => {
|
|
704
|
+
const sdkMsg: SDKNativeMessage = {
|
|
705
|
+
type: 'user',
|
|
706
|
+
uuid: 'interrupt-1',
|
|
707
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
708
|
+
message: {
|
|
709
|
+
content: [{ type: 'text', text: '[Request interrupted by user]' }],
|
|
710
|
+
},
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
714
|
+
|
|
715
|
+
expect(chatMsg).not.toBeNull();
|
|
716
|
+
expect(chatMsg!.isInterrupt).toBe(true);
|
|
717
|
+
expect(chatMsg!.content).toBe('[Request interrupted by user]');
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it('does not mark non-canonical interrupt text variants', () => {
|
|
721
|
+
const sdkMsg: SDKNativeMessage = {
|
|
722
|
+
type: 'user',
|
|
723
|
+
uuid: 'interrupt-non-canonical',
|
|
724
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
725
|
+
message: {
|
|
726
|
+
content: 'prefix [Request interrupted by user]',
|
|
727
|
+
},
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
731
|
+
|
|
732
|
+
expect(chatMsg).not.toBeNull();
|
|
733
|
+
expect(chatMsg!.isInterrupt).toBeUndefined();
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('does not mark regular user messages as interrupt', () => {
|
|
737
|
+
const sdkMsg: SDKNativeMessage = {
|
|
738
|
+
type: 'user',
|
|
739
|
+
uuid: 'user-regular',
|
|
740
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
741
|
+
message: {
|
|
742
|
+
content: 'Hello, how are you?',
|
|
743
|
+
},
|
|
744
|
+
};
|
|
745
|
+
|
|
746
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
747
|
+
|
|
748
|
+
expect(chatMsg).not.toBeNull();
|
|
749
|
+
expect(chatMsg!.isInterrupt).toBeUndefined();
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it('marks rebuilt context messages with isRebuiltContext flag', () => {
|
|
753
|
+
const sdkMsg: SDKNativeMessage = {
|
|
754
|
+
type: 'user',
|
|
755
|
+
uuid: 'rebuilt-1',
|
|
756
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
757
|
+
message: {
|
|
758
|
+
content: 'User: hi\n\nAssistant: Hello!\n\nUser: how are you?',
|
|
759
|
+
},
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
763
|
+
|
|
764
|
+
expect(chatMsg).not.toBeNull();
|
|
765
|
+
expect(chatMsg!.isRebuiltContext).toBe(true);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('marks rebuilt context messages starting with Assistant', () => {
|
|
769
|
+
const sdkMsg: SDKNativeMessage = {
|
|
770
|
+
type: 'user',
|
|
771
|
+
uuid: 'rebuilt-2',
|
|
772
|
+
timestamp: '2024-01-15T10:31:00Z',
|
|
773
|
+
message: {
|
|
774
|
+
content: 'Assistant: Hello\n\nUser: Hi again',
|
|
775
|
+
},
|
|
776
|
+
};
|
|
777
|
+
|
|
778
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
779
|
+
|
|
780
|
+
expect(chatMsg).not.toBeNull();
|
|
781
|
+
expect(chatMsg!.isRebuiltContext).toBe(true);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it('does not mark regular messages starting with User as rebuilt context', () => {
|
|
785
|
+
const sdkMsg: SDKNativeMessage = {
|
|
786
|
+
type: 'user',
|
|
787
|
+
uuid: 'user-normal',
|
|
788
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
789
|
+
message: {
|
|
790
|
+
content: 'User settings should be configurable',
|
|
791
|
+
},
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
795
|
+
|
|
796
|
+
expect(chatMsg).not.toBeNull();
|
|
797
|
+
expect(chatMsg!.isRebuiltContext).toBeUndefined();
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it('extracts displayContent from user message with current_note tag', () => {
|
|
801
|
+
const sdkMsg: SDKNativeMessage = {
|
|
802
|
+
type: 'user',
|
|
803
|
+
uuid: 'user-note',
|
|
804
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
805
|
+
message: {
|
|
806
|
+
content: 'Explain this file\n\n<current_note>\nnotes/test.md\n</current_note>',
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
811
|
+
|
|
812
|
+
expect(chatMsg).not.toBeNull();
|
|
813
|
+
expect(chatMsg!.content).toBe('Explain this file\n\n<current_note>\nnotes/test.md\n</current_note>');
|
|
814
|
+
expect(chatMsg!.displayContent).toBe('Explain this file');
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it('extracts displayContent from user message with editor_selection tag', () => {
|
|
818
|
+
const sdkMsg: SDKNativeMessage = {
|
|
819
|
+
type: 'user',
|
|
820
|
+
uuid: 'user-selection',
|
|
821
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
822
|
+
message: {
|
|
823
|
+
content: 'Refactor this code\n\n<editor_selection path="src/main.ts">\nfunction foo() {}\n</editor_selection>',
|
|
824
|
+
},
|
|
825
|
+
};
|
|
826
|
+
|
|
827
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
828
|
+
|
|
829
|
+
expect(chatMsg).not.toBeNull();
|
|
830
|
+
expect(chatMsg!.displayContent).toBe('Refactor this code');
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('extracts displayContent from user message with multiple context tags', () => {
|
|
834
|
+
const sdkMsg: SDKNativeMessage = {
|
|
835
|
+
type: 'user',
|
|
836
|
+
uuid: 'user-multi',
|
|
837
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
838
|
+
message: {
|
|
839
|
+
content: 'Update this\n\n<current_note>\ntest.md\n</current_note>\n\n<editor_selection path="test.md">\nselected\n</editor_selection>',
|
|
840
|
+
},
|
|
841
|
+
};
|
|
842
|
+
|
|
843
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
844
|
+
|
|
845
|
+
expect(chatMsg).not.toBeNull();
|
|
846
|
+
expect(chatMsg!.displayContent).toBe('Update this');
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it('does not set displayContent for plain user messages without XML context', () => {
|
|
850
|
+
const sdkMsg: SDKNativeMessage = {
|
|
851
|
+
type: 'user',
|
|
852
|
+
uuid: 'user-plain',
|
|
853
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
854
|
+
message: {
|
|
855
|
+
content: 'Just a regular question',
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
|
|
859
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
860
|
+
|
|
861
|
+
expect(chatMsg).not.toBeNull();
|
|
862
|
+
expect(chatMsg!.displayContent).toBeUndefined();
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
describe('loadSDKSessionMessages', () => {
|
|
867
|
+
it('loads and converts all messages from session file', async () => {
|
|
868
|
+
mockExistsSync.mockReturnValue(true);
|
|
869
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
870
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
871
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
872
|
+
'{"type":"system","uuid":"s1"}',
|
|
873
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Thanks"}}',
|
|
874
|
+
].join('\n'));
|
|
875
|
+
|
|
876
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-full');
|
|
877
|
+
|
|
878
|
+
// Should have 3 messages (system skipped)
|
|
879
|
+
expect(result.messages).toHaveLength(3);
|
|
880
|
+
expect(result.messages[0].role).toBe('user');
|
|
881
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
882
|
+
expect(result.messages[1].role).toBe('assistant');
|
|
883
|
+
expect(result.messages[1].content).toBe('Hi!');
|
|
884
|
+
expect(result.messages[2].role).toBe('user');
|
|
885
|
+
expect(result.messages[2].content).toBe('Thanks');
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
it('sorts messages by timestamp ascending', async () => {
|
|
889
|
+
mockExistsSync.mockReturnValue(true);
|
|
890
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
891
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Second"}]}}',
|
|
892
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"First"}}',
|
|
893
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Third"}}',
|
|
894
|
+
].join('\n'));
|
|
895
|
+
|
|
896
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-unordered');
|
|
897
|
+
|
|
898
|
+
expect(result.messages[0].content).toBe('First');
|
|
899
|
+
expect(result.messages[1].content).toBe('Second');
|
|
900
|
+
expect(result.messages[2].content).toBe('Third');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('returns empty result when session does not exist', async () => {
|
|
904
|
+
mockExistsSync.mockReturnValue(false);
|
|
905
|
+
|
|
906
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'nonexistent');
|
|
907
|
+
|
|
908
|
+
expect(result.messages).toEqual([]);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it('matches tool_result from user message to tool_use in assistant message', async () => {
|
|
912
|
+
mockExistsSync.mockReturnValue(true);
|
|
913
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
914
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Search for cats"}}',
|
|
915
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Let me search"},{"type":"tool_use","id":"tool-1","name":"WebSearch","input":{"query":"cats"}}]}}',
|
|
916
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":"Found 10 results"}]}}',
|
|
917
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"I found 10 results about cats."}]}}',
|
|
918
|
+
].join('\n'));
|
|
919
|
+
|
|
920
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-cross-tool');
|
|
921
|
+
|
|
922
|
+
// Should have 2 messages (tool_result-only user skipped, assistant messages merged)
|
|
923
|
+
expect(result.messages).toHaveLength(2);
|
|
924
|
+
expect(result.messages[0].content).toBe('Search for cats');
|
|
925
|
+
// Merged assistant message has tool calls and combined content
|
|
926
|
+
expect(result.messages[1].toolCalls).toHaveLength(1);
|
|
927
|
+
expect(result.messages[1].toolCalls![0].id).toBe('tool-1');
|
|
928
|
+
expect(result.messages[1].toolCalls![0].result).toBe('Found 10 results');
|
|
929
|
+
expect(result.messages[1].toolCalls![0].status).toBe('completed');
|
|
930
|
+
expect(result.messages[1].content).toContain('Let me search');
|
|
931
|
+
expect(result.messages[1].content).toContain('I found 10 results about cats.');
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it('hydrates AskUserQuestion answers from result text when toolUseResult has no answers', async () => {
|
|
935
|
+
mockExistsSync.mockReturnValue(true);
|
|
936
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
937
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"ask-1","name":"AskUserQuestion","input":{"questions":[{"question":"Color?","options":["Blue","Red"]}]}}]}}',
|
|
938
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"ask-1","content":"\\"Color?\\"=\\"Blue\\""}]}}',
|
|
939
|
+
].join('\n'));
|
|
940
|
+
|
|
941
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-ask-result-fallback');
|
|
942
|
+
|
|
943
|
+
expect(result.messages).toHaveLength(1);
|
|
944
|
+
expect(result.messages[0].toolCalls).toHaveLength(1);
|
|
945
|
+
expect(result.messages[0].toolCalls?.[0].resolvedAnswers).toEqual({ 'Color?': 'Blue' });
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
it('skips user messages that are tool results', async () => {
|
|
949
|
+
mockExistsSync.mockReturnValue(true);
|
|
950
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
951
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
952
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}',
|
|
953
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"done"}]}}',
|
|
954
|
+
].join('\n'));
|
|
955
|
+
|
|
956
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-tool-result');
|
|
957
|
+
|
|
958
|
+
// Should have 2 messages (tool_result user skipped)
|
|
959
|
+
expect(result.messages).toHaveLength(2);
|
|
960
|
+
expect(result.messages[0].role).toBe('user');
|
|
961
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
962
|
+
expect(result.messages[1].role).toBe('assistant');
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
it('skips skill prompt injection messages (sourceToolUseID)', async () => {
|
|
966
|
+
mockExistsSync.mockReturnValue(true);
|
|
967
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
968
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"/commit"}}',
|
|
969
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"commit"}}]}}',
|
|
970
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Launching skill: commit"}]}}',
|
|
971
|
+
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","sourceToolUseID":"t1","isMeta":true,"message":{"content":[{"type":"text","text":"## Your task\\n\\nCommit the changes..."}]}}',
|
|
972
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Committing the changes now."}]}}',
|
|
973
|
+
].join('\n'));
|
|
974
|
+
|
|
975
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-skill');
|
|
976
|
+
|
|
977
|
+
// Should have 2 messages: user query, merged assistant (tool_use + text merged together)
|
|
978
|
+
// Skill prompt injection (u3) and tool result (u2) should be skipped
|
|
979
|
+
// Consecutive assistant messages are merged
|
|
980
|
+
expect(result.messages).toHaveLength(2);
|
|
981
|
+
expect(result.messages[0].role).toBe('user');
|
|
982
|
+
expect(result.messages[0].content).toBe('/commit');
|
|
983
|
+
expect(result.messages[1].role).toBe('assistant');
|
|
984
|
+
expect(result.messages[1].toolCalls?.[0].name).toBe('Skill');
|
|
985
|
+
expect(result.messages[1].content).toContain('Committing');
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it('skips meta messages without sourceToolUseID', async () => {
|
|
989
|
+
mockExistsSync.mockReturnValue(true);
|
|
990
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
991
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
992
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:00:01Z","isMeta":true,"message":{"content":"System context injection"}}',
|
|
993
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi there!"}]}}',
|
|
994
|
+
].join('\n'));
|
|
995
|
+
|
|
996
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-skip-meta');
|
|
997
|
+
|
|
998
|
+
// Should have 2 messages (meta message u2 skipped)
|
|
999
|
+
expect(result.messages).toHaveLength(2);
|
|
1000
|
+
expect(result.messages[0].role).toBe('user');
|
|
1001
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
1002
|
+
expect(result.messages[1].role).toBe('assistant');
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
it('preserves /compact command as user message with clean displayContent', async () => {
|
|
1006
|
+
// File ordering mirrors real SDK JSONL: compact_boundary written BEFORE /compact command.
|
|
1007
|
+
// The timestamp sort must reorder so /compact (earlier) precedes boundary (later).
|
|
1008
|
+
mockExistsSync.mockReturnValue(true);
|
|
1009
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1010
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1011
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1012
|
+
'{"type":"system","subtype":"compact_boundary","uuid":"c1","timestamp":"2024-01-15T10:02:10Z"}',
|
|
1013
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"<local-command-caveat>Caveat</local-command-caveat>"}}',
|
|
1014
|
+
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}',
|
|
1015
|
+
'{"type":"user","uuid":"u4","timestamp":"2024-01-15T10:02:11Z","message":{"content":"<local-command-stdout>Compacted </local-command-stdout>"}}',
|
|
1016
|
+
].join('\n'));
|
|
1017
|
+
|
|
1018
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact');
|
|
1019
|
+
|
|
1020
|
+
// Should have: user "Hello", assistant "Hi!", user "/compact", assistant compact_boundary
|
|
1021
|
+
// Meta (u2), stdout (u4) should be skipped
|
|
1022
|
+
// /compact (10:02:01) sorted before compact_boundary (10:02:10) by timestamp
|
|
1023
|
+
expect(result.messages).toHaveLength(4);
|
|
1024
|
+
expect(result.messages[0].role).toBe('user');
|
|
1025
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
1026
|
+
expect(result.messages[1].role).toBe('assistant');
|
|
1027
|
+
expect(result.messages[2].role).toBe('user');
|
|
1028
|
+
expect(result.messages[2].displayContent).toBe('/compact');
|
|
1029
|
+
expect(result.messages[3].role).toBe('assistant');
|
|
1030
|
+
expect(result.messages[3].contentBlocks).toEqual([{ type: 'context_compacted' }]);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
it('renders compact cancellation stderr as interrupt (not filtered)', async () => {
|
|
1034
|
+
mockExistsSync.mockReturnValue(true);
|
|
1035
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1036
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1037
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1038
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"<command-name>/compact</command-name>\\n<command-message>compact</command-message>\\n<command-args></command-args>"}}',
|
|
1039
|
+
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:01Z","message":{"content":"<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}',
|
|
1040
|
+
].join('\n'));
|
|
1041
|
+
|
|
1042
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-cancel');
|
|
1043
|
+
|
|
1044
|
+
// Compact cancellation stderr should appear as interrupt, not be filtered
|
|
1045
|
+
const interruptMsg = result.messages.find(m => m.isInterrupt);
|
|
1046
|
+
expect(interruptMsg).toBeDefined();
|
|
1047
|
+
expect(interruptMsg!.isInterrupt).toBe(true);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it('does not treat embedded compaction stderr mentions as interrupt markers', async () => {
|
|
1051
|
+
mockExistsSync.mockReturnValue(true);
|
|
1052
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1053
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1054
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1055
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:01Z","message":{"content":"## Context\\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>"}}',
|
|
1056
|
+
].join('\n'));
|
|
1057
|
+
|
|
1058
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-compact-quoted-cancel');
|
|
1059
|
+
|
|
1060
|
+
expect(result.messages).toHaveLength(2);
|
|
1061
|
+
expect(result.messages.some(m => m.isInterrupt)).toBe(false);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
it('preserves slash command invocations with clean displayContent', async () => {
|
|
1065
|
+
mockExistsSync.mockReturnValue(true);
|
|
1066
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1067
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1068
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1069
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:02:00Z","message":{"content":"<command-message>md2docx</command-message>\\n<command-name>/md2docx</command-name>"}}',
|
|
1070
|
+
'{"type":"user","uuid":"u3","timestamp":"2024-01-15T10:02:00Z","isMeta":true,"message":{"content":"Use bash command md2word..."}}',
|
|
1071
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"(no content)"}]}}',
|
|
1072
|
+
'{"type":"assistant","uuid":"a3","timestamp":"2024-01-15T10:03:01Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Skill","input":{"skill":"md2docx"}}]}}',
|
|
1073
|
+
].join('\n'));
|
|
1074
|
+
|
|
1075
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-slash-cmd');
|
|
1076
|
+
|
|
1077
|
+
// user "Hello", assistant "Hi!", user "/md2docx", assistant with Skill tool
|
|
1078
|
+
// META (u3) should be skipped; "(no content)" text should be filtered
|
|
1079
|
+
expect(result.messages).toHaveLength(4);
|
|
1080
|
+
expect(result.messages[2].role).toBe('user');
|
|
1081
|
+
expect(result.messages[2].displayContent).toBe('/md2docx');
|
|
1082
|
+
expect(result.messages[3].role).toBe('assistant');
|
|
1083
|
+
expect(result.messages[3].content).toBe('');
|
|
1084
|
+
expect(result.messages[3].toolCalls).toHaveLength(1);
|
|
1085
|
+
expect(result.messages[3].toolCalls![0].name).toBe('Skill');
|
|
1086
|
+
});
|
|
1087
|
+
|
|
1088
|
+
it('handles tool_result with error flag', async () => {
|
|
1089
|
+
mockExistsSync.mockReturnValue(true);
|
|
1090
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1091
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"invalid"}}]}}',
|
|
1092
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:01:00Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"Command not found","is_error":true}]}}',
|
|
1093
|
+
].join('\n'));
|
|
1094
|
+
|
|
1095
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-error-result');
|
|
1096
|
+
|
|
1097
|
+
expect(result.messages).toHaveLength(1);
|
|
1098
|
+
expect(result.messages[0].toolCalls![0].status).toBe('error');
|
|
1099
|
+
expect(result.messages[0].toolCalls![0].result).toBe('Command not found');
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('returns error pass-through from readSDKSession', async () => {
|
|
1103
|
+
mockExistsSync.mockReturnValue(true);
|
|
1104
|
+
mockFsPromises.readFile.mockRejectedValue(new Error('Disk failure'));
|
|
1105
|
+
|
|
1106
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-disk-err');
|
|
1107
|
+
|
|
1108
|
+
expect(result.messages).toEqual([]);
|
|
1109
|
+
expect(result.error).toBe('Disk failure');
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
it('merges tool calls from consecutive assistant messages', async () => {
|
|
1113
|
+
mockExistsSync.mockReturnValue(true);
|
|
1114
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1115
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}',
|
|
1116
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"tool_use","id":"t2","name":"Write","input":{"path":"b.ts"}}]}}',
|
|
1117
|
+
].join('\n'));
|
|
1118
|
+
|
|
1119
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-tools');
|
|
1120
|
+
|
|
1121
|
+
// Consecutive assistant messages should merge into one
|
|
1122
|
+
expect(result.messages).toHaveLength(1);
|
|
1123
|
+
expect(result.messages[0].toolCalls).toHaveLength(2);
|
|
1124
|
+
expect(result.messages[0].toolCalls![0].name).toBe('Read');
|
|
1125
|
+
expect(result.messages[0].toolCalls![1].name).toBe('Write');
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('updates assistantMessageId to last entry when merging assistant messages', async () => {
|
|
1129
|
+
mockExistsSync.mockReturnValue(true);
|
|
1130
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1131
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"hello"}}',
|
|
1132
|
+
'{"type":"assistant","uuid":"a1-first","parentUuid":"u1","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"thinking..."}]}}',
|
|
1133
|
+
'{"type":"assistant","uuid":"a1-mid","parentUuid":"a1-first","timestamp":"2024-01-15T10:00:02Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Read","input":{"path":"a.ts"}}]}}',
|
|
1134
|
+
'{"type":"assistant","uuid":"a1-last","parentUuid":"a1-mid","timestamp":"2024-01-15T10:00:03Z","message":{"content":[{"type":"text","text":"Done!"}]}}',
|
|
1135
|
+
].join('\n'));
|
|
1136
|
+
|
|
1137
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-uuid');
|
|
1138
|
+
|
|
1139
|
+
expect(result.messages).toHaveLength(2);
|
|
1140
|
+
const assistant = result.messages[1];
|
|
1141
|
+
expect(assistant.role).toBe('assistant');
|
|
1142
|
+
// Must be the last UUID so rewind targets the end of the turn
|
|
1143
|
+
expect(assistant.assistantMessageId).toBe('a1-last');
|
|
1144
|
+
});
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
describe('parseSDKMessageToChat - image extraction', () => {
|
|
1148
|
+
it('extracts image attachments from user message with image blocks', () => {
|
|
1149
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1150
|
+
type: 'user',
|
|
1151
|
+
uuid: 'user-img',
|
|
1152
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1153
|
+
message: {
|
|
1154
|
+
content: [
|
|
1155
|
+
{ type: 'text', text: 'Check this image' },
|
|
1156
|
+
{
|
|
1157
|
+
type: 'image',
|
|
1158
|
+
source: {
|
|
1159
|
+
type: 'base64',
|
|
1160
|
+
media_type: 'image/png',
|
|
1161
|
+
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk',
|
|
1162
|
+
},
|
|
1163
|
+
},
|
|
1164
|
+
],
|
|
1165
|
+
},
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1169
|
+
|
|
1170
|
+
expect(chatMsg).not.toBeNull();
|
|
1171
|
+
expect(chatMsg!.images).toHaveLength(1);
|
|
1172
|
+
expect(chatMsg!.images![0].mediaType).toBe('image/png');
|
|
1173
|
+
expect(chatMsg!.images![0].data).toContain('iVBORw0KGgo');
|
|
1174
|
+
expect(chatMsg!.images![0].source).toBe('paste');
|
|
1175
|
+
expect(chatMsg!.images![0].name).toBe('image-1');
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('does not extract images from assistant messages', () => {
|
|
1179
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1180
|
+
type: 'assistant',
|
|
1181
|
+
uuid: 'asst-img',
|
|
1182
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1183
|
+
message: {
|
|
1184
|
+
content: [
|
|
1185
|
+
{ type: 'text', text: 'Here is a response' },
|
|
1186
|
+
],
|
|
1187
|
+
},
|
|
1188
|
+
};
|
|
1189
|
+
|
|
1190
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1191
|
+
|
|
1192
|
+
expect(chatMsg).not.toBeNull();
|
|
1193
|
+
expect(chatMsg!.images).toBeUndefined();
|
|
1194
|
+
});
|
|
1195
|
+
|
|
1196
|
+
it('returns null for user message with only tool_result content blocks', () => {
|
|
1197
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1198
|
+
type: 'user',
|
|
1199
|
+
uuid: 'user-tool-only',
|
|
1200
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1201
|
+
message: {
|
|
1202
|
+
content: [
|
|
1203
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'result data' },
|
|
1204
|
+
],
|
|
1205
|
+
},
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1209
|
+
// Array content bypasses the null-return guard even without text/tool_use/images
|
|
1210
|
+
expect(chatMsg).not.toBeNull();
|
|
1211
|
+
});
|
|
1212
|
+
|
|
1213
|
+
it('returns null for user message with empty string content', () => {
|
|
1214
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1215
|
+
type: 'user',
|
|
1216
|
+
uuid: 'user-empty',
|
|
1217
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1218
|
+
message: {
|
|
1219
|
+
content: '',
|
|
1220
|
+
},
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1224
|
+
expect(chatMsg).toBeNull();
|
|
1225
|
+
});
|
|
1226
|
+
|
|
1227
|
+
it('returns null for user message with no content', () => {
|
|
1228
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1229
|
+
type: 'user',
|
|
1230
|
+
uuid: 'user-nocontent',
|
|
1231
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1232
|
+
message: {},
|
|
1233
|
+
};
|
|
1234
|
+
|
|
1235
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1236
|
+
expect(chatMsg).toBeNull();
|
|
1237
|
+
});
|
|
1238
|
+
|
|
1239
|
+
it('returns null for queue-operation messages', () => {
|
|
1240
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1241
|
+
type: 'queue-operation',
|
|
1242
|
+
uuid: 'queue-1',
|
|
1243
|
+
};
|
|
1244
|
+
|
|
1245
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1246
|
+
expect(chatMsg).toBeNull();
|
|
1247
|
+
});
|
|
1248
|
+
});
|
|
1249
|
+
|
|
1250
|
+
describe('parseSDKMessageToChat - content block edge cases', () => {
|
|
1251
|
+
it('skips text blocks that are whitespace-only in contentBlocks', () => {
|
|
1252
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1253
|
+
type: 'assistant',
|
|
1254
|
+
uuid: 'asst-whitespace',
|
|
1255
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1256
|
+
message: {
|
|
1257
|
+
content: [
|
|
1258
|
+
{ type: 'text', text: ' ' },
|
|
1259
|
+
{ type: 'text', text: 'Actual content' },
|
|
1260
|
+
],
|
|
1261
|
+
},
|
|
1262
|
+
};
|
|
1263
|
+
|
|
1264
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1265
|
+
expect(chatMsg).not.toBeNull();
|
|
1266
|
+
// The whitespace-only text block should be skipped in contentBlocks
|
|
1267
|
+
expect(chatMsg!.contentBlocks).toHaveLength(1);
|
|
1268
|
+
expect(chatMsg!.contentBlocks![0].type).toBe('text');
|
|
1269
|
+
});
|
|
1270
|
+
|
|
1271
|
+
it('skips thinking blocks with empty thinking field', () => {
|
|
1272
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1273
|
+
type: 'assistant',
|
|
1274
|
+
uuid: 'asst-empty-think',
|
|
1275
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1276
|
+
message: {
|
|
1277
|
+
content: [
|
|
1278
|
+
{ type: 'thinking', thinking: '' },
|
|
1279
|
+
{ type: 'text', text: 'Some answer' },
|
|
1280
|
+
],
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1285
|
+
expect(chatMsg).not.toBeNull();
|
|
1286
|
+
// Empty thinking block should be skipped
|
|
1287
|
+
expect(chatMsg!.contentBlocks).toHaveLength(1);
|
|
1288
|
+
expect(chatMsg!.contentBlocks![0].type).toBe('text');
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
it('skips tool_use blocks without id in contentBlocks', () => {
|
|
1292
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1293
|
+
type: 'assistant',
|
|
1294
|
+
uuid: 'asst-no-id-tool',
|
|
1295
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1296
|
+
message: {
|
|
1297
|
+
content: [
|
|
1298
|
+
{ type: 'tool_use', name: 'Bash', input: {} },
|
|
1299
|
+
{ type: 'text', text: 'After tool' },
|
|
1300
|
+
],
|
|
1301
|
+
},
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1305
|
+
expect(chatMsg).not.toBeNull();
|
|
1306
|
+
// tool_use without id should be skipped in contentBlocks
|
|
1307
|
+
expect(chatMsg!.contentBlocks).toHaveLength(1);
|
|
1308
|
+
expect(chatMsg!.contentBlocks![0].type).toBe('text');
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
it('returns undefined contentBlocks when all blocks are filtered out', () => {
|
|
1312
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1313
|
+
type: 'assistant',
|
|
1314
|
+
uuid: 'asst-all-filtered',
|
|
1315
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1316
|
+
message: {
|
|
1317
|
+
content: [
|
|
1318
|
+
{ type: 'tool_result', tool_use_id: 't1', content: 'result' },
|
|
1319
|
+
],
|
|
1320
|
+
},
|
|
1321
|
+
};
|
|
1322
|
+
|
|
1323
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1324
|
+
// Content is array (so not null), but all blocks filtered → undefined contentBlocks
|
|
1325
|
+
expect(chatMsg).not.toBeNull();
|
|
1326
|
+
expect(chatMsg!.contentBlocks).toBeUndefined();
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
it('handles tool_use without input field', () => {
|
|
1330
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1331
|
+
type: 'assistant',
|
|
1332
|
+
uuid: 'asst-no-input',
|
|
1333
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1334
|
+
message: {
|
|
1335
|
+
content: [
|
|
1336
|
+
{ type: 'tool_use', id: 'tool-noinput', name: 'SomeTool' },
|
|
1337
|
+
],
|
|
1338
|
+
},
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1342
|
+
expect(chatMsg).not.toBeNull();
|
|
1343
|
+
expect(chatMsg!.toolCalls).toHaveLength(1);
|
|
1344
|
+
expect(chatMsg!.toolCalls![0].input).toEqual({});
|
|
1345
|
+
});
|
|
1346
|
+
|
|
1347
|
+
it('handles tool_result with non-string content (JSON object)', () => {
|
|
1348
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1349
|
+
type: 'assistant',
|
|
1350
|
+
uuid: 'asst-json-result',
|
|
1351
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1352
|
+
message: {
|
|
1353
|
+
content: [
|
|
1354
|
+
{ type: 'tool_use', id: 'tool-json', name: 'Read', input: {} },
|
|
1355
|
+
{
|
|
1356
|
+
type: 'tool_result',
|
|
1357
|
+
tool_use_id: 'tool-json',
|
|
1358
|
+
content: { file: 'test.ts', lines: 42 },
|
|
1359
|
+
},
|
|
1360
|
+
],
|
|
1361
|
+
},
|
|
1362
|
+
};
|
|
1363
|
+
|
|
1364
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1365
|
+
expect(chatMsg).not.toBeNull();
|
|
1366
|
+
expect(chatMsg!.toolCalls).toHaveLength(1);
|
|
1367
|
+
// Non-string content should be JSON.stringified
|
|
1368
|
+
expect(chatMsg!.toolCalls![0].result).toBe('{"file":"test.ts","lines":42}');
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
describe('parseSDKMessageToChat - rebuilt context with A: shorthand', () => {
|
|
1373
|
+
it('detects rebuilt context using A: shorthand marker', () => {
|
|
1374
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1375
|
+
type: 'user',
|
|
1376
|
+
uuid: 'rebuilt-short',
|
|
1377
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1378
|
+
message: {
|
|
1379
|
+
content: 'User: hello\n\nA: hi there',
|
|
1380
|
+
},
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1384
|
+
expect(chatMsg).not.toBeNull();
|
|
1385
|
+
expect(chatMsg!.isRebuiltContext).toBe(true);
|
|
1386
|
+
});
|
|
1387
|
+
});
|
|
1388
|
+
|
|
1389
|
+
describe('parseSDKMessageToChat - interrupt tool use variant', () => {
|
|
1390
|
+
it('marks tool use interrupt messages', () => {
|
|
1391
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1392
|
+
type: 'user',
|
|
1393
|
+
uuid: 'interrupt-tool',
|
|
1394
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1395
|
+
message: {
|
|
1396
|
+
content: '[Request interrupted by user for tool use]',
|
|
1397
|
+
},
|
|
1398
|
+
};
|
|
1399
|
+
|
|
1400
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1401
|
+
expect(chatMsg).not.toBeNull();
|
|
1402
|
+
expect(chatMsg!.isInterrupt).toBe(true);
|
|
1403
|
+
});
|
|
1404
|
+
|
|
1405
|
+
it('does not mark quoted compact cancellation mention as interrupt', () => {
|
|
1406
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1407
|
+
type: 'user',
|
|
1408
|
+
uuid: 'interrupt-compact-quoted',
|
|
1409
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1410
|
+
message: {
|
|
1411
|
+
content: '## Context\n<local-command-stderr>Error: Compaction canceled.</local-command-stderr>',
|
|
1412
|
+
},
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1416
|
+
expect(chatMsg).not.toBeNull();
|
|
1417
|
+
expect(chatMsg!.isInterrupt).toBeUndefined();
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
it('marks compact cancellation stderr as interrupt', () => {
|
|
1421
|
+
const sdkMsg: SDKNativeMessage = {
|
|
1422
|
+
type: 'user',
|
|
1423
|
+
uuid: 'interrupt-compact',
|
|
1424
|
+
timestamp: '2024-01-15T10:30:00Z',
|
|
1425
|
+
message: {
|
|
1426
|
+
content: '<local-command-stderr>Error: Compaction canceled.</local-command-stderr>',
|
|
1427
|
+
},
|
|
1428
|
+
};
|
|
1429
|
+
|
|
1430
|
+
const chatMsg = parseSDKMessageToChat(sdkMsg);
|
|
1431
|
+
expect(chatMsg).not.toBeNull();
|
|
1432
|
+
expect(chatMsg!.isInterrupt).toBe(true);
|
|
1433
|
+
});
|
|
1434
|
+
});
|
|
1435
|
+
|
|
1436
|
+
describe('loadSDKSessionMessages - merge edge cases', () => {
|
|
1437
|
+
it('merges assistant content blocks when first has no content blocks', async () => {
|
|
1438
|
+
mockExistsSync.mockReturnValue(true);
|
|
1439
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1440
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}',
|
|
1441
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"thinking","thinking":"hmm"},{"type":"text","text":"Result here"}]}}',
|
|
1442
|
+
].join('\n'));
|
|
1443
|
+
|
|
1444
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-blocks');
|
|
1445
|
+
|
|
1446
|
+
expect(result.messages).toHaveLength(1);
|
|
1447
|
+
// Merged: tool call from first + content blocks from both
|
|
1448
|
+
expect(result.messages[0].toolCalls).toHaveLength(1);
|
|
1449
|
+
expect(result.messages[0].contentBlocks!.length).toBeGreaterThanOrEqual(2);
|
|
1450
|
+
});
|
|
1451
|
+
|
|
1452
|
+
it('merges assistant with empty target content', async () => {
|
|
1453
|
+
mockExistsSync.mockReturnValue(true);
|
|
1454
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1455
|
+
// First assistant: only tool_use, no text
|
|
1456
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:00:00Z","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{}}]}}',
|
|
1457
|
+
// Second assistant: has text content
|
|
1458
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:00:01Z","message":{"content":[{"type":"text","text":"Here is the result"}]}}',
|
|
1459
|
+
].join('\n'));
|
|
1460
|
+
|
|
1461
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-merge-empty-target');
|
|
1462
|
+
|
|
1463
|
+
expect(result.messages).toHaveLength(1);
|
|
1464
|
+
expect(result.messages[0].content).toBe('Here is the result');
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
it('handles multiple user images in a message', async () => {
|
|
1468
|
+
mockExistsSync.mockReturnValue(true);
|
|
1469
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1470
|
+
JSON.stringify({
|
|
1471
|
+
type: 'user',
|
|
1472
|
+
uuid: 'u-imgs',
|
|
1473
|
+
timestamp: '2024-01-15T10:00:00Z',
|
|
1474
|
+
message: {
|
|
1475
|
+
content: [
|
|
1476
|
+
{ type: 'text', text: 'Check these images' },
|
|
1477
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'abc123' } },
|
|
1478
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'def456' } },
|
|
1479
|
+
],
|
|
1480
|
+
},
|
|
1481
|
+
}),
|
|
1482
|
+
].join('\n'));
|
|
1483
|
+
|
|
1484
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-multi-images');
|
|
1485
|
+
|
|
1486
|
+
expect(result.messages).toHaveLength(1);
|
|
1487
|
+
expect(result.messages[0].images).toHaveLength(2);
|
|
1488
|
+
expect(result.messages[0].images![0].mediaType).toBe('image/png');
|
|
1489
|
+
expect(result.messages[0].images![1].mediaType).toBe('image/jpeg');
|
|
1490
|
+
expect(result.messages[0].images![1].name).toBe('image-2');
|
|
1491
|
+
});
|
|
1492
|
+
|
|
1493
|
+
it('extracts text from Agent tool results with array content', async () => {
|
|
1494
|
+
mockExistsSync.mockReturnValue(true);
|
|
1495
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1496
|
+
JSON.stringify({
|
|
1497
|
+
type: 'user', uuid: 'u1', timestamp: '2024-01-15T10:00:00Z',
|
|
1498
|
+
message: { content: 'Use an agent' },
|
|
1499
|
+
}),
|
|
1500
|
+
JSON.stringify({
|
|
1501
|
+
type: 'assistant', uuid: 'a1', parentUuid: 'u1', timestamp: '2024-01-15T10:00:01Z',
|
|
1502
|
+
message: { content: [{ type: 'tool_use', id: 'agent-1', name: 'Agent', input: { description: 'test', prompt: 'do stuff' } }] },
|
|
1503
|
+
}),
|
|
1504
|
+
// Agent tool result has array content (not string)
|
|
1505
|
+
JSON.stringify({
|
|
1506
|
+
type: 'user', uuid: 'tr1', parentUuid: 'a1', timestamp: '2024-01-15T10:00:30Z',
|
|
1507
|
+
toolUseResult: { status: 'completed', agentId: 'abc123' },
|
|
1508
|
+
message: { content: [{
|
|
1509
|
+
type: 'tool_result', tool_use_id: 'agent-1', is_error: false,
|
|
1510
|
+
content: [
|
|
1511
|
+
{ type: 'text', text: 'Agent completed the task successfully.' },
|
|
1512
|
+
{ type: 'text', text: 'agentId: abc123\n<usage>total_tokens: 500</usage>' },
|
|
1513
|
+
],
|
|
1514
|
+
}] },
|
|
1515
|
+
}),
|
|
1516
|
+
JSON.stringify({
|
|
1517
|
+
type: 'assistant', uuid: 'a2', parentUuid: 'tr1', timestamp: '2024-01-15T10:00:31Z',
|
|
1518
|
+
message: { content: [{ type: 'text', text: 'Done.' }] },
|
|
1519
|
+
}),
|
|
1520
|
+
].join('\n'));
|
|
1521
|
+
|
|
1522
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-agent-result');
|
|
1523
|
+
|
|
1524
|
+
// The Agent tool call should have extracted text, not JSON.stringify'd array
|
|
1525
|
+
const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.length);
|
|
1526
|
+
expect(assistantMsg).toBeDefined();
|
|
1527
|
+
const agentToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Agent');
|
|
1528
|
+
expect(agentToolCall).toBeDefined();
|
|
1529
|
+
expect(agentToolCall!.result).toBe(
|
|
1530
|
+
'Agent completed the task successfully.\nagentId: abc123\n<usage>total_tokens: 500</usage>'
|
|
1531
|
+
);
|
|
1532
|
+
// Must NOT contain JSON artifacts
|
|
1533
|
+
expect(agentToolCall!.result).not.toContain('"type":"text"');
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
describe('filterActiveBranch', () => {
|
|
1538
|
+
it('returns all entries for linear chain without resumeAtMessageId', () => {
|
|
1539
|
+
const entries: SDKNativeMessage[] = [
|
|
1540
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1541
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1542
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1543
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1544
|
+
];
|
|
1545
|
+
|
|
1546
|
+
const result = filterActiveBranch(entries);
|
|
1547
|
+
|
|
1548
|
+
expect(result).toHaveLength(4);
|
|
1549
|
+
expect(result).toEqual(entries);
|
|
1550
|
+
});
|
|
1551
|
+
|
|
1552
|
+
it('truncates linear chain at resumeAtMessageId UUID', () => {
|
|
1553
|
+
const entries: SDKNativeMessage[] = [
|
|
1554
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1555
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1556
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1557
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1558
|
+
];
|
|
1559
|
+
|
|
1560
|
+
const result = filterActiveBranch(entries, 'a1');
|
|
1561
|
+
|
|
1562
|
+
expect(result).toHaveLength(2);
|
|
1563
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']);
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it('returns only new branch after rewind + follow-up', () => {
|
|
1567
|
+
// Original: u1 → a1 → u2 → a2
|
|
1568
|
+
// Rewind to a1, follow-up: u3 → a3 (u3.parentUuid = a1)
|
|
1569
|
+
const entries: SDKNativeMessage[] = [
|
|
1570
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1571
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1572
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1573
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1574
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch point: a1 has 2 children
|
|
1575
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1576
|
+
];
|
|
1577
|
+
|
|
1578
|
+
const result = filterActiveBranch(entries);
|
|
1579
|
+
|
|
1580
|
+
// Should include: u1, a1, u3, a3 (new branch), not u2, a2
|
|
1581
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']);
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1584
|
+
it('returns latest branch after multiple rewinds', () => {
|
|
1585
|
+
// Original: u1 → a1 → u2 → a2
|
|
1586
|
+
// Rewind 1: u3 → a3 (parent a1)
|
|
1587
|
+
// Rewind 2: u4 → a4 (parent a1) — third child of a1
|
|
1588
|
+
const entries: SDKNativeMessage[] = [
|
|
1589
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1590
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1591
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1592
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1593
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' },
|
|
1594
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1595
|
+
{ type: 'user', uuid: 'u4', parentUuid: 'a1' },
|
|
1596
|
+
{ type: 'assistant', uuid: 'a4', parentUuid: 'u4' },
|
|
1597
|
+
];
|
|
1598
|
+
|
|
1599
|
+
const result = filterActiveBranch(entries);
|
|
1600
|
+
|
|
1601
|
+
// Last entry with uuid is a4, walk back: a4 → u4 → a1 → u1
|
|
1602
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u4', 'a4']);
|
|
1603
|
+
});
|
|
1604
|
+
|
|
1605
|
+
it('returns all entries when resumeAtMessageId UUID not found (safety)', () => {
|
|
1606
|
+
const entries: SDKNativeMessage[] = [
|
|
1607
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1608
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1609
|
+
];
|
|
1610
|
+
|
|
1611
|
+
const result = filterActiveBranch(entries, 'nonexistent-uuid');
|
|
1612
|
+
|
|
1613
|
+
expect(result).toHaveLength(2);
|
|
1614
|
+
expect(result).toEqual(entries);
|
|
1615
|
+
});
|
|
1616
|
+
|
|
1617
|
+
it('returns empty for empty entries', () => {
|
|
1618
|
+
const result = filterActiveBranch([]);
|
|
1619
|
+
expect(result).toEqual([]);
|
|
1620
|
+
});
|
|
1621
|
+
|
|
1622
|
+
it('does not misdetect branching when duplicate uuid entries exist', () => {
|
|
1623
|
+
// SDK may write the same message twice (e.g., around compaction).
|
|
1624
|
+
// Without dedup, duplicate entries inflate childCount causing false branch detection.
|
|
1625
|
+
const entries: SDKNativeMessage[] = [
|
|
1626
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1627
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1628
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1629
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1630
|
+
// Duplicate of u2 — SDK writes this again
|
|
1631
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1632
|
+
];
|
|
1633
|
+
|
|
1634
|
+
// Without dedup fix, a1 would have childCount=2 (u2 counted twice),
|
|
1635
|
+
// triggering branch detection and excluding u2/a2.
|
|
1636
|
+
const result = filterActiveBranch(entries);
|
|
1637
|
+
|
|
1638
|
+
// Should be a no-op (linear chain, no branching)
|
|
1639
|
+
expect(result).toHaveLength(4);
|
|
1640
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u2', 'a2']);
|
|
1641
|
+
});
|
|
1642
|
+
|
|
1643
|
+
it('correctly truncates at resumeAtMessageId when duplicates exist', () => {
|
|
1644
|
+
const entries: SDKNativeMessage[] = [
|
|
1645
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1646
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1647
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1648
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1649
|
+
// Duplicate of u2
|
|
1650
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1651
|
+
];
|
|
1652
|
+
|
|
1653
|
+
const result = filterActiveBranch(entries, 'a1');
|
|
1654
|
+
|
|
1655
|
+
// Should truncate at a1, including only u1 and a1
|
|
1656
|
+
expect(result).toHaveLength(2);
|
|
1657
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']);
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it('preserves no-uuid entries within active branch region', () => {
|
|
1661
|
+
const entries: SDKNativeMessage[] = [
|
|
1662
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1663
|
+
{ type: 'queue-operation' }, // No uuid — between u1 (active) and a1 (active)
|
|
1664
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1665
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1666
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1667
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch
|
|
1668
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1669
|
+
];
|
|
1670
|
+
|
|
1671
|
+
const result = filterActiveBranch(entries);
|
|
1672
|
+
|
|
1673
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1674
|
+
expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']);
|
|
1675
|
+
// queue-operation is between u1 (active) and a1 (active), so preserved
|
|
1676
|
+
expect(result.some(e => e.type === 'queue-operation')).toBe(true);
|
|
1677
|
+
});
|
|
1678
|
+
|
|
1679
|
+
it('truncates at resumeAtMessageId on latest branch when branching exists', () => {
|
|
1680
|
+
// Rewind 1 + follow-up created a branch: u3/a3 branch off a1
|
|
1681
|
+
// Rewind 2 on the new branch (no follow-up): resumeAtMessageId = a1
|
|
1682
|
+
// On reload, should truncate at a1, not show u3/a3
|
|
1683
|
+
const entries: SDKNativeMessage[] = [
|
|
1684
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1685
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1686
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' }, // old branch
|
|
1687
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' }, // old branch
|
|
1688
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' }, // new branch (from rewind 1)
|
|
1689
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' }, // new branch
|
|
1690
|
+
];
|
|
1691
|
+
|
|
1692
|
+
// Rewind 2 on new branch: truncate at a1
|
|
1693
|
+
const result = filterActiveBranch(entries, 'a1');
|
|
1694
|
+
|
|
1695
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1']);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it('truncates at resumeAtMessageId mid-branch when branching exists', () => {
|
|
1699
|
+
// Branch from a1: old (u2→a2) and new (u3→a3→u4→a4)
|
|
1700
|
+
// Rewind on new branch to u4: resumeAtMessageId = a3
|
|
1701
|
+
const entries: SDKNativeMessage[] = [
|
|
1702
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1703
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1704
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1705
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1706
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' },
|
|
1707
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1708
|
+
{ type: 'user', uuid: 'u4', parentUuid: 'a3' },
|
|
1709
|
+
{ type: 'assistant', uuid: 'a4', parentUuid: 'u4' },
|
|
1710
|
+
];
|
|
1711
|
+
|
|
1712
|
+
const result = filterActiveBranch(entries, 'a3');
|
|
1713
|
+
|
|
1714
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']);
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
it('ignores resumeAtMessageId not on latest branch', () => {
|
|
1718
|
+
// Branch from a1: old (u2→a2) and new (u3→a3)
|
|
1719
|
+
// resumeAtMessageId points to a2 (on the OLD branch) — should be ignored
|
|
1720
|
+
const entries: SDKNativeMessage[] = [
|
|
1721
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1722
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1723
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1724
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1725
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' },
|
|
1726
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1727
|
+
];
|
|
1728
|
+
|
|
1729
|
+
// a2 is on old branch, not an ancestor of leaf a3
|
|
1730
|
+
const result = filterActiveBranch(entries, 'a2');
|
|
1731
|
+
|
|
1732
|
+
// Should return full latest branch (ignoring stale resumeAtMessageId)
|
|
1733
|
+
expect(result.map(e => e.uuid)).toEqual(['u1', 'a1', 'u3', 'a3']);
|
|
1734
|
+
});
|
|
1735
|
+
|
|
1736
|
+
it('drops no-uuid entries in old branch region', () => {
|
|
1737
|
+
const entries: SDKNativeMessage[] = [
|
|
1738
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1739
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1740
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'a1' },
|
|
1741
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1742
|
+
{ type: 'queue-operation' }, // No uuid — between a2 (old) and u3 (active)
|
|
1743
|
+
{ type: 'user', uuid: 'u3', parentUuid: 'a1' }, // Branch
|
|
1744
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u3' },
|
|
1745
|
+
];
|
|
1746
|
+
|
|
1747
|
+
const result = filterActiveBranch(entries);
|
|
1748
|
+
|
|
1749
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1750
|
+
expect(uuids).toEqual(['u1', 'a1', 'u3', 'a3']);
|
|
1751
|
+
// queue-operation between a2 (not active) and u3 (active) — should be dropped
|
|
1752
|
+
expect(result.some(e => e.type === 'queue-operation')).toBe(false);
|
|
1753
|
+
});
|
|
1754
|
+
|
|
1755
|
+
it('excludes progress entries and does not treat them as branches', () => {
|
|
1756
|
+
// Simulates Agent tool call: assistant issues tool_use, SDK writes progress chain,
|
|
1757
|
+
// then next user message is parented to end of progress chain.
|
|
1758
|
+
// Without fix: progress creates false branching, losing the conversation branch.
|
|
1759
|
+
const entries: SDKNativeMessage[] = [
|
|
1760
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1761
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1762
|
+
// a1 is a tool_use (Agent). SDK writes tool_result + progress chain as siblings:
|
|
1763
|
+
{ type: 'user', uuid: 'tr1', parentUuid: 'a1', toolUseResult: {} }, // tool result
|
|
1764
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'tr1' }, // response after tool
|
|
1765
|
+
// Progress chain branching off a1 (subagent execution logs):
|
|
1766
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' },
|
|
1767
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' },
|
|
1768
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p3', parentUuid: 'p2' },
|
|
1769
|
+
// Next conversation message parented to end of progress chain:
|
|
1770
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'p3' },
|
|
1771
|
+
{ type: 'assistant', uuid: 'a3', parentUuid: 'u2' },
|
|
1772
|
+
];
|
|
1773
|
+
|
|
1774
|
+
const result = filterActiveBranch(entries);
|
|
1775
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1776
|
+
|
|
1777
|
+
// All conversation entries should be present, progress entries excluded
|
|
1778
|
+
expect(uuids).toEqual(['u1', 'a1', 'tr1', 'a2', 'u2', 'a3']);
|
|
1779
|
+
expect(result.every(e => (e.type as string) !== 'progress')).toBe(true);
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
it('reparents through long progress chains to preserve full conversation', () => {
|
|
1783
|
+
// Two turns, each with Agent tool calls generating progress entries.
|
|
1784
|
+
// The second turn's user message is parented to the end of the first progress chain.
|
|
1785
|
+
const entries: SDKNativeMessage[] = [
|
|
1786
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1787
|
+
{ type: 'assistant', uuid: 'a1-think', parentUuid: 'u1' },
|
|
1788
|
+
{ type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1-think' }, // Agent tool_use
|
|
1789
|
+
// Conversation branch: tool result → assistant response
|
|
1790
|
+
{ type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} },
|
|
1791
|
+
{ type: 'assistant', uuid: 'a1-think2', parentUuid: 'tr1' },
|
|
1792
|
+
{ type: 'assistant', uuid: 'a1-text', parentUuid: 'a1-think2' },
|
|
1793
|
+
// Progress chain off a1-tool:
|
|
1794
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1-tool' },
|
|
1795
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' },
|
|
1796
|
+
// System entry chained to progress:
|
|
1797
|
+
{ type: 'system', uuid: 'sys1', parentUuid: 'p2' },
|
|
1798
|
+
// Second turn parented to system (which is parented to progress chain):
|
|
1799
|
+
{ type: 'user', uuid: 'u2', parentUuid: 'sys1' },
|
|
1800
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'u2' },
|
|
1801
|
+
];
|
|
1802
|
+
|
|
1803
|
+
const result = filterActiveBranch(entries);
|
|
1804
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1805
|
+
|
|
1806
|
+
// Must include BOTH turns' content — nothing lost
|
|
1807
|
+
expect(uuids).toContain('a1-text'); // First turn's response
|
|
1808
|
+
expect(uuids).toContain('u2'); // Second turn's input
|
|
1809
|
+
expect(uuids).toContain('a2'); // Second turn's response
|
|
1810
|
+
// Progress entries must be excluded
|
|
1811
|
+
expect(uuids).not.toContain('p1');
|
|
1812
|
+
expect(uuids).not.toContain('p2');
|
|
1813
|
+
});
|
|
1814
|
+
|
|
1815
|
+
it('does not treat parallel tool calls as branches', () => {
|
|
1816
|
+
// Assistant sends two tool_use blocks in parallel. SDK writes them as
|
|
1817
|
+
// separate entries. Their tool results are parented to respective entries.
|
|
1818
|
+
const entries: SDKNativeMessage[] = [
|
|
1819
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1820
|
+
{ type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' },
|
|
1821
|
+
{ type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' }, // first tool_use
|
|
1822
|
+
{ type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' }, // second tool_use (parallel)
|
|
1823
|
+
// Tool results:
|
|
1824
|
+
{ type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} },
|
|
1825
|
+
{ type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} },
|
|
1826
|
+
// Assistant continues after both results:
|
|
1827
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'tr2' },
|
|
1828
|
+
];
|
|
1829
|
+
|
|
1830
|
+
const result = filterActiveBranch(entries);
|
|
1831
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1832
|
+
|
|
1833
|
+
// Both tool calls and their results should be present
|
|
1834
|
+
expect(uuids).toContain('a1-tool1');
|
|
1835
|
+
expect(uuids).toContain('a1-tool2');
|
|
1836
|
+
expect(uuids).toContain('tr1');
|
|
1837
|
+
expect(uuids).toContain('tr2');
|
|
1838
|
+
expect(uuids).toContain('a2');
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
it('handles real rewind alongside progress entries', () => {
|
|
1842
|
+
// Turn 1 with Agent tool (progress entries), then a real rewind at a1.
|
|
1843
|
+
const entries: SDKNativeMessage[] = [
|
|
1844
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1845
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1846
|
+
// Original continuation:
|
|
1847
|
+
{ type: 'user', uuid: 'u2-old', parentUuid: 'a1' },
|
|
1848
|
+
{ type: 'assistant', uuid: 'a2-old', parentUuid: 'u2-old' },
|
|
1849
|
+
// Progress entries off a1:
|
|
1850
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p1', parentUuid: 'a1' },
|
|
1851
|
+
{ type: 'progress' as SDKNativeMessage['type'], uuid: 'p2', parentUuid: 'p1' },
|
|
1852
|
+
// Rewind: new user message also branching off a1
|
|
1853
|
+
{ type: 'user', uuid: 'u2-new', parentUuid: 'a1' },
|
|
1854
|
+
{ type: 'assistant', uuid: 'a2-new', parentUuid: 'u2-new' },
|
|
1855
|
+
];
|
|
1856
|
+
|
|
1857
|
+
const result = filterActiveBranch(entries);
|
|
1858
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1859
|
+
|
|
1860
|
+
// Should follow the latest branch (u2-new), not the old one or progress
|
|
1861
|
+
expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a2-new']);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it('detects rewind when abandoned path continues through assistant/tool nodes', () => {
|
|
1865
|
+
const entries: SDKNativeMessage[] = [
|
|
1866
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1867
|
+
{ type: 'assistant', uuid: 'a1', parentUuid: 'u1' },
|
|
1868
|
+
{ type: 'assistant', uuid: 'a1-tool', parentUuid: 'a1' },
|
|
1869
|
+
{ type: 'user', uuid: 'tr1', parentUuid: 'a1-tool', toolUseResult: {} },
|
|
1870
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'tr1' },
|
|
1871
|
+
{ type: 'user', uuid: 'u2-new', parentUuid: 'a1' },
|
|
1872
|
+
{ type: 'assistant', uuid: 'a3-new', parentUuid: 'u2-new' },
|
|
1873
|
+
];
|
|
1874
|
+
|
|
1875
|
+
const result = filterActiveBranch(entries);
|
|
1876
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1877
|
+
|
|
1878
|
+
expect(uuids).toEqual(['u1', 'a1', 'u2-new', 'a3-new']);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
it('preserves earlier parallel tool-result descendants when a later rewind exists', () => {
|
|
1882
|
+
const entries: SDKNativeMessage[] = [
|
|
1883
|
+
{ type: 'user', uuid: 'u1', parentUuid: null },
|
|
1884
|
+
{ type: 'assistant', uuid: 'a1-text', parentUuid: 'u1' },
|
|
1885
|
+
{ type: 'assistant', uuid: 'a1-tool1', parentUuid: 'a1-text' },
|
|
1886
|
+
{ type: 'assistant', uuid: 'a1-tool2', parentUuid: 'a1-text' },
|
|
1887
|
+
{ type: 'user', uuid: 'tr1', parentUuid: 'a1-tool1', toolUseResult: {} },
|
|
1888
|
+
{ type: 'user', uuid: 'tr2', parentUuid: 'a1-tool2', toolUseResult: {} },
|
|
1889
|
+
{ type: 'assistant', uuid: 'a2', parentUuid: 'tr2' },
|
|
1890
|
+
{ type: 'user', uuid: 'u3-old', parentUuid: 'a2' },
|
|
1891
|
+
{ type: 'assistant', uuid: 'a3-old', parentUuid: 'u3-old' },
|
|
1892
|
+
{ type: 'user', uuid: 'u3-new', parentUuid: 'a2' },
|
|
1893
|
+
{ type: 'assistant', uuid: 'a3-new', parentUuid: 'u3-new' },
|
|
1894
|
+
];
|
|
1895
|
+
|
|
1896
|
+
const result = filterActiveBranch(entries);
|
|
1897
|
+
const uuids = result.filter(e => e.uuid).map(e => e.uuid);
|
|
1898
|
+
|
|
1899
|
+
expect(uuids).toEqual([
|
|
1900
|
+
'u1',
|
|
1901
|
+
'a1-text',
|
|
1902
|
+
'a1-tool1',
|
|
1903
|
+
'a1-tool2',
|
|
1904
|
+
'tr1',
|
|
1905
|
+
'tr2',
|
|
1906
|
+
'a2',
|
|
1907
|
+
'u3-new',
|
|
1908
|
+
'a3-new',
|
|
1909
|
+
]);
|
|
1910
|
+
});
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
describe('loadSDKSessionMessages with resumeAtMessageId', () => {
|
|
1914
|
+
it('returns identical behavior without resumeAtMessageId', async () => {
|
|
1915
|
+
mockExistsSync.mockReturnValue(true);
|
|
1916
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1917
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1918
|
+
'{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1919
|
+
].join('\n'));
|
|
1920
|
+
|
|
1921
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-resume');
|
|
1922
|
+
|
|
1923
|
+
expect(result.messages).toHaveLength(2);
|
|
1924
|
+
});
|
|
1925
|
+
|
|
1926
|
+
it('truncates messages at resumeAtMessageId on linear JSONL', async () => {
|
|
1927
|
+
mockExistsSync.mockReturnValue(true);
|
|
1928
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1929
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1930
|
+
'{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1931
|
+
'{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"More"}}',
|
|
1932
|
+
'{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"More response"}]}}',
|
|
1933
|
+
].join('\n'));
|
|
1934
|
+
|
|
1935
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-truncate', 'a1');
|
|
1936
|
+
|
|
1937
|
+
// Should only have u1 and a1 (truncated at a1)
|
|
1938
|
+
expect(result.messages).toHaveLength(2);
|
|
1939
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
1940
|
+
expect(result.messages[1].content).toBe('Hi!');
|
|
1941
|
+
});
|
|
1942
|
+
|
|
1943
|
+
it('returns correct active branch on branched JSONL', async () => {
|
|
1944
|
+
mockExistsSync.mockReturnValue(true);
|
|
1945
|
+
mockFsPromises.readFile.mockResolvedValue([
|
|
1946
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Hello"}}',
|
|
1947
|
+
'{"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"text","text":"Hi!"}]}}',
|
|
1948
|
+
'{"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"2024-01-15T10:02:00Z","message":{"content":"Old branch"}}',
|
|
1949
|
+
'{"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"2024-01-15T10:03:00Z","message":{"content":[{"type":"text","text":"Old response"}]}}',
|
|
1950
|
+
'{"type":"user","uuid":"u3","parentUuid":"a1","timestamp":"2024-01-15T10:04:00Z","message":{"content":"New branch"}}',
|
|
1951
|
+
'{"type":"assistant","uuid":"a3","parentUuid":"u3","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"New response"}]}}',
|
|
1952
|
+
].join('\n'));
|
|
1953
|
+
|
|
1954
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-branched');
|
|
1955
|
+
|
|
1956
|
+
// Should have: u1 "Hello", a1 "Hi!", u3 "New branch", a3 "New response"
|
|
1957
|
+
// Old branch (u2, a2) should be excluded
|
|
1958
|
+
expect(result.messages).toHaveLength(4);
|
|
1959
|
+
expect(result.messages[0].content).toBe('Hello');
|
|
1960
|
+
expect(result.messages[1].content).toBe('Hi!');
|
|
1961
|
+
expect(result.messages[2].content).toBe('New branch');
|
|
1962
|
+
expect(result.messages[3].content).toBe('New response');
|
|
1963
|
+
});
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
describe('extractToolResultContent', () => {
|
|
1967
|
+
it('passes through string content unchanged', () => {
|
|
1968
|
+
expect(extractToolResultContent('hello world')).toBe('hello world');
|
|
1969
|
+
});
|
|
1970
|
+
|
|
1971
|
+
it('extracts text from array of content blocks (Agent results)', () => {
|
|
1972
|
+
const content = [
|
|
1973
|
+
{ type: 'text', text: 'First block of output.' },
|
|
1974
|
+
{ type: 'text', text: 'agentId: abc123\n<usage>total_tokens: 1000</usage>' },
|
|
1975
|
+
];
|
|
1976
|
+
expect(extractToolResultContent(content)).toBe(
|
|
1977
|
+
'First block of output.\nagentId: abc123\n<usage>total_tokens: 1000</usage>'
|
|
1978
|
+
);
|
|
1979
|
+
});
|
|
1980
|
+
|
|
1981
|
+
it('skips non-text blocks in array content', () => {
|
|
1982
|
+
const content = [
|
|
1983
|
+
{ type: 'image', source: { type: 'base64', data: 'abc' } },
|
|
1984
|
+
{ type: 'text', text: 'The only text.' },
|
|
1985
|
+
];
|
|
1986
|
+
expect(extractToolResultContent(content)).toBe('The only text.');
|
|
1987
|
+
});
|
|
1988
|
+
|
|
1989
|
+
it('returns empty string for null/undefined content', () => {
|
|
1990
|
+
expect(extractToolResultContent(null)).toBe('');
|
|
1991
|
+
expect(extractToolResultContent(undefined)).toBe('');
|
|
1992
|
+
});
|
|
1993
|
+
|
|
1994
|
+
it('JSON-stringifies unknown content types as fallback', () => {
|
|
1995
|
+
expect(extractToolResultContent({ custom: 'value' })).toBe('{"custom":"value"}');
|
|
1996
|
+
});
|
|
1997
|
+
|
|
1998
|
+
it('handles empty array content', () => {
|
|
1999
|
+
expect(extractToolResultContent([])).toBe('');
|
|
2000
|
+
});
|
|
2001
|
+
|
|
2002
|
+
it('JSON-stringifies non-empty array with no text blocks (e.g. tool_reference)', () => {
|
|
2003
|
+
const content = [
|
|
2004
|
+
{ type: 'tool_reference', tool_name: 'WebSearch' },
|
|
2005
|
+
{ type: 'tool_reference', tool_name: 'Grep' },
|
|
2006
|
+
];
|
|
2007
|
+
expect(extractToolResultContent(content)).toBe(JSON.stringify(content));
|
|
2008
|
+
});
|
|
2009
|
+
|
|
2010
|
+
it('JSON-stringifies non-empty array with no text blocks using fallbackIndent', () => {
|
|
2011
|
+
const content = [
|
|
2012
|
+
{ type: 'tool_reference', tool_name: 'Read' },
|
|
2013
|
+
];
|
|
2014
|
+
expect(extractToolResultContent(content, { fallbackIndent: 2 })).toBe(
|
|
2015
|
+
JSON.stringify(content, null, 2)
|
|
2016
|
+
);
|
|
2017
|
+
});
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
describe('collectAsyncSubagentResults', () => {
|
|
2021
|
+
it('extracts task-notification data from queue-operation enqueue entries', () => {
|
|
2022
|
+
const entries: SDKNativeMessage[] = [
|
|
2023
|
+
{
|
|
2024
|
+
type: 'queue-operation',
|
|
2025
|
+
operation: 'enqueue',
|
|
2026
|
+
content: `<task-notification>
|
|
2027
|
+
<task-id>ae5eb9a</task-id>
|
|
2028
|
+
<status>completed</status>
|
|
2029
|
+
<summary>Agent "Review code" completed</summary>
|
|
2030
|
+
<result>Found 3 issues in the codebase.
|
|
2031
|
+
|
|
2032
|
+
1. Missing error handling in auth module.
|
|
2033
|
+
2. Unused import in utils.ts.
|
|
2034
|
+
3. Race condition in fetchData.</result>
|
|
2035
|
+
</task-notification>`,
|
|
2036
|
+
},
|
|
2037
|
+
];
|
|
2038
|
+
|
|
2039
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2040
|
+
|
|
2041
|
+
expect(results.size).toBe(1);
|
|
2042
|
+
const entry = results.get('ae5eb9a')!;
|
|
2043
|
+
expect(entry.status).toBe('completed');
|
|
2044
|
+
expect(entry.result).toContain('Found 3 issues');
|
|
2045
|
+
expect(entry.result).toContain('Race condition in fetchData.');
|
|
2046
|
+
});
|
|
2047
|
+
|
|
2048
|
+
it('collects multiple queue-operation entries', () => {
|
|
2049
|
+
const entries: SDKNativeMessage[] = [
|
|
2050
|
+
{
|
|
2051
|
+
type: 'queue-operation',
|
|
2052
|
+
operation: 'enqueue',
|
|
2053
|
+
content: '<task-notification><task-id>agent-1</task-id><status>completed</status><result>Result 1</result></task-notification>',
|
|
2054
|
+
},
|
|
2055
|
+
{
|
|
2056
|
+
type: 'queue-operation',
|
|
2057
|
+
operation: 'enqueue',
|
|
2058
|
+
content: '<task-notification><task-id>agent-2</task-id><status>error</status><result>Task failed</result></task-notification>',
|
|
2059
|
+
},
|
|
2060
|
+
];
|
|
2061
|
+
|
|
2062
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2063
|
+
|
|
2064
|
+
expect(results.size).toBe(2);
|
|
2065
|
+
expect(results.get('agent-1')!.status).toBe('completed');
|
|
2066
|
+
expect(results.get('agent-2')!.status).toBe('error');
|
|
2067
|
+
expect(results.get('agent-2')!.result).toBe('Task failed');
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
it('skips dequeue operations', () => {
|
|
2071
|
+
const entries: SDKNativeMessage[] = [
|
|
2072
|
+
{
|
|
2073
|
+
type: 'queue-operation',
|
|
2074
|
+
operation: 'dequeue',
|
|
2075
|
+
sessionId: 'session-1',
|
|
2076
|
+
},
|
|
2077
|
+
];
|
|
2078
|
+
|
|
2079
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2080
|
+
expect(results.size).toBe(0);
|
|
2081
|
+
});
|
|
2082
|
+
|
|
2083
|
+
it('skips entries without task-notification content', () => {
|
|
2084
|
+
const entries: SDKNativeMessage[] = [
|
|
2085
|
+
{
|
|
2086
|
+
type: 'queue-operation',
|
|
2087
|
+
operation: 'enqueue',
|
|
2088
|
+
content: 'some other content',
|
|
2089
|
+
},
|
|
2090
|
+
];
|
|
2091
|
+
|
|
2092
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2093
|
+
expect(results.size).toBe(0);
|
|
2094
|
+
});
|
|
2095
|
+
|
|
2096
|
+
it('skips entries without task-id or result', () => {
|
|
2097
|
+
const entries: SDKNativeMessage[] = [
|
|
2098
|
+
{
|
|
2099
|
+
type: 'queue-operation',
|
|
2100
|
+
operation: 'enqueue',
|
|
2101
|
+
content: '<task-notification><status>completed</status><result>No task-id</result></task-notification>',
|
|
2102
|
+
},
|
|
2103
|
+
{
|
|
2104
|
+
type: 'queue-operation',
|
|
2105
|
+
operation: 'enqueue',
|
|
2106
|
+
content: '<task-notification><task-id>has-id</task-id><status>completed</status></task-notification>',
|
|
2107
|
+
},
|
|
2108
|
+
];
|
|
2109
|
+
|
|
2110
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2111
|
+
expect(results.size).toBe(0);
|
|
2112
|
+
});
|
|
2113
|
+
|
|
2114
|
+
it('defaults status to completed when status tag is missing', () => {
|
|
2115
|
+
const entries: SDKNativeMessage[] = [
|
|
2116
|
+
{
|
|
2117
|
+
type: 'queue-operation',
|
|
2118
|
+
operation: 'enqueue',
|
|
2119
|
+
content: '<task-notification><task-id>no-status</task-id><result>Done</result></task-notification>',
|
|
2120
|
+
},
|
|
2121
|
+
];
|
|
2122
|
+
|
|
2123
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2124
|
+
expect(results.get('no-status')!.status).toBe('completed');
|
|
2125
|
+
});
|
|
2126
|
+
|
|
2127
|
+
it('ignores non-queue-operation messages', () => {
|
|
2128
|
+
const entries: SDKNativeMessage[] = [
|
|
2129
|
+
{ type: 'user', uuid: 'u1', message: { content: 'hello' } },
|
|
2130
|
+
{ type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'hi' }] } },
|
|
2131
|
+
];
|
|
2132
|
+
|
|
2133
|
+
const results = collectAsyncSubagentResults(entries);
|
|
2134
|
+
expect(results.size).toBe(0);
|
|
2135
|
+
});
|
|
2136
|
+
});
|
|
2137
|
+
|
|
2138
|
+
describe('resolveToolUseResultStatus', () => {
|
|
2139
|
+
it('preserves orphaned fallback when the tool result has no stronger signal', () => {
|
|
2140
|
+
expect(resolveToolUseResultStatus(undefined, 'orphaned')).toBe('orphaned');
|
|
2141
|
+
expect(resolveToolUseResultStatus({ status: 'unknown' }, 'orphaned')).toBe('orphaned');
|
|
2142
|
+
});
|
|
2143
|
+
|
|
2144
|
+
it('maps task notification failure statuses to error', () => {
|
|
2145
|
+
expect(resolveToolUseResultStatus({ status: 'failed' }, 'completed')).toBe('error');
|
|
2146
|
+
expect(resolveToolUseResultStatus({ status: 'stopped' }, 'completed')).toBe('error');
|
|
2147
|
+
expect(resolveToolUseResultStatus({ status: 'killed' }, 'completed')).toBe('error');
|
|
2148
|
+
});
|
|
2149
|
+
});
|
|
2150
|
+
|
|
2151
|
+
describe('loadSDKSessionMessages - async subagent hydration', () => {
|
|
2152
|
+
it('populates toolCall.subagent for async Task tools from queue-operation results', async () => {
|
|
2153
|
+
mockExistsSync.mockReturnValue(true);
|
|
2154
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2155
|
+
const p = String(filePath);
|
|
2156
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2157
|
+
return [
|
|
2158
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}',
|
|
2159
|
+
// Assistant spawns async Task
|
|
2160
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}',
|
|
2161
|
+
// Task tool_result with agentId (SDK launch shape)
|
|
2162
|
+
`{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched","description":"Review code","prompt":"Check for bugs","outputFile":"/tmp/agent.output"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}`,
|
|
2163
|
+
// Queue-operation with full result
|
|
2164
|
+
`{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Found 3 issues:\\n1. Missing error handling\\n2. Unused import\\n3. Race condition</result></task-notification>"}`,
|
|
2165
|
+
// Assistant continues after
|
|
2166
|
+
'{"type":"assistant","uuid":"a2","timestamp":"2024-01-15T10:05:00Z","message":{"content":[{"type":"text","text":"The review found 3 issues."}]}}',
|
|
2167
|
+
].join('\n');
|
|
2168
|
+
}
|
|
2169
|
+
// Subagent sidecar file
|
|
2170
|
+
return '';
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-hydrate');
|
|
2174
|
+
|
|
2175
|
+
// Should have: user message, merged assistant with Task tool, assistant follow-up
|
|
2176
|
+
expect(result.messages.length).toBeGreaterThanOrEqual(2);
|
|
2177
|
+
|
|
2178
|
+
const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2179
|
+
expect(assistantMsg).toBeDefined();
|
|
2180
|
+
|
|
2181
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2182
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2183
|
+
expect(taskToolCall.subagent!.mode).toBe('async');
|
|
2184
|
+
expect(taskToolCall.subagent!.agentId).toBe('ae5eb9a');
|
|
2185
|
+
expect(taskToolCall.subagent!.status).toBe('completed');
|
|
2186
|
+
expect(taskToolCall.subagent!.asyncStatus).toBe('completed');
|
|
2187
|
+
expect(taskToolCall.subagent!.result).toContain('Found 3 issues');
|
|
2188
|
+
expect(taskToolCall.subagent!.result).toContain('Race condition');
|
|
2189
|
+
// toolCall.result should also be updated
|
|
2190
|
+
expect(taskToolCall.result).toContain('Found 3 issues');
|
|
2191
|
+
expect(taskToolCall.status).toBe('completed');
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
it('prefers queue-operation completion over misleading async tool_result error flags', async () => {
|
|
2195
|
+
mockExistsSync.mockReturnValue(true);
|
|
2196
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2197
|
+
const p = String(filePath);
|
|
2198
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2199
|
+
return [
|
|
2200
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run background task"}}',
|
|
2201
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review code","prompt":"Check for bugs","run_in_background":true}}]}}',
|
|
2202
|
+
`{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}`,
|
|
2203
|
+
`{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><summary>Agent completed</summary><result>Background work finished cleanly</result></task-notification>"}`,
|
|
2204
|
+
].join('\n');
|
|
2205
|
+
}
|
|
2206
|
+
return '';
|
|
2207
|
+
});
|
|
2208
|
+
|
|
2209
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-error-flag');
|
|
2210
|
+
|
|
2211
|
+
const assistantMsg = result.messages.find(m => m.role === 'assistant' && m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2212
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2213
|
+
|
|
2214
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2215
|
+
expect(taskToolCall.subagent!.status).toBe('completed');
|
|
2216
|
+
expect(taskToolCall.subagent!.asyncStatus).toBe('completed');
|
|
2217
|
+
expect(taskToolCall.subagent!.result).toBe('Background work finished cleanly');
|
|
2218
|
+
expect(taskToolCall.status).toBe('completed');
|
|
2219
|
+
expect(taskToolCall.result).toBe('Background work finished cleanly');
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
it('uses truncated API result when no queue-operation exists', async () => {
|
|
2223
|
+
mockExistsSync.mockReturnValue(true);
|
|
2224
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2225
|
+
const p = String(filePath);
|
|
2226
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2227
|
+
return [
|
|
2228
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}',
|
|
2229
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}',
|
|
2230
|
+
`{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched."}]}}`,
|
|
2231
|
+
// No queue-operation entry
|
|
2232
|
+
].join('\n');
|
|
2233
|
+
}
|
|
2234
|
+
return '';
|
|
2235
|
+
});
|
|
2236
|
+
|
|
2237
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-no-queue-op');
|
|
2238
|
+
|
|
2239
|
+
const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2240
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2241
|
+
|
|
2242
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2243
|
+
expect(taskToolCall.subagent!.agentId).toBe('abc123');
|
|
2244
|
+
expect(taskToolCall.subagent!.status).toBe('running');
|
|
2245
|
+
expect(taskToolCall.subagent!.asyncStatus).toBe('running');
|
|
2246
|
+
expect(taskToolCall.status).toBe('running');
|
|
2247
|
+
// Falls back to the API content (truncated)
|
|
2248
|
+
expect(taskToolCall.subagent!.result).toBe('Task launched.');
|
|
2249
|
+
});
|
|
2250
|
+
|
|
2251
|
+
it('treats async launch tool results as running even when the content block is flagged as error', async () => {
|
|
2252
|
+
mockExistsSync.mockReturnValue(true);
|
|
2253
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2254
|
+
const p = String(filePath);
|
|
2255
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2256
|
+
return [
|
|
2257
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}',
|
|
2258
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}',
|
|
2259
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background.","is_error":true}]}}',
|
|
2260
|
+
].join('\n');
|
|
2261
|
+
}
|
|
2262
|
+
return '';
|
|
2263
|
+
});
|
|
2264
|
+
|
|
2265
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-async-launch-error-flag');
|
|
2266
|
+
|
|
2267
|
+
const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2268
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2269
|
+
|
|
2270
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2271
|
+
expect(taskToolCall.subagent!.status).toBe('running');
|
|
2272
|
+
expect(taskToolCall.subagent!.asyncStatus).toBe('running');
|
|
2273
|
+
expect(taskToolCall.status).toBe('running');
|
|
2274
|
+
expect(taskToolCall.result).toBe('Task launched in background.');
|
|
2275
|
+
});
|
|
2276
|
+
|
|
2277
|
+
it('treats queue-operation success status as completed', async () => {
|
|
2278
|
+
mockExistsSync.mockReturnValue(true);
|
|
2279
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2280
|
+
const p = String(filePath);
|
|
2281
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2282
|
+
return [
|
|
2283
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run task"}}',
|
|
2284
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Test task","prompt":"test","run_in_background":true}}]}}',
|
|
2285
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"abc123","status":"async_launched"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Task launched in background."}]}}',
|
|
2286
|
+
'{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>abc123</task-id><status>success</status><result>Background task succeeded</result></task-notification>"}',
|
|
2287
|
+
].join('\n');
|
|
2288
|
+
}
|
|
2289
|
+
return '';
|
|
2290
|
+
});
|
|
2291
|
+
|
|
2292
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-queue-success');
|
|
2293
|
+
|
|
2294
|
+
const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2295
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2296
|
+
|
|
2297
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2298
|
+
expect(taskToolCall.subagent!.status).toBe('completed');
|
|
2299
|
+
expect(taskToolCall.subagent!.asyncStatus).toBe('completed');
|
|
2300
|
+
expect(taskToolCall.status).toBe('completed');
|
|
2301
|
+
expect(taskToolCall.result).toBe('Background task succeeded');
|
|
2302
|
+
});
|
|
2303
|
+
|
|
2304
|
+
it('does not build SubagentInfo for sync Task tools', async () => {
|
|
2305
|
+
mockExistsSync.mockReturnValue(true);
|
|
2306
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2307
|
+
const p = String(filePath);
|
|
2308
|
+
if (p.endsWith('.jsonl') && !p.includes('subagents')) {
|
|
2309
|
+
return [
|
|
2310
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Run sync task"}}',
|
|
2311
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Sync task","prompt":"test","run_in_background":false}}]}}',
|
|
2312
|
+
'{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Sync result"}]}}',
|
|
2313
|
+
].join('\n');
|
|
2314
|
+
}
|
|
2315
|
+
return '';
|
|
2316
|
+
});
|
|
2317
|
+
|
|
2318
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sync-task');
|
|
2319
|
+
|
|
2320
|
+
const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2321
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2322
|
+
|
|
2323
|
+
// Sync tasks should NOT get SubagentInfo from this pass
|
|
2324
|
+
expect(taskToolCall.subagent).toBeUndefined();
|
|
2325
|
+
});
|
|
2326
|
+
|
|
2327
|
+
it('loads subagent tool calls from sidecar JSONL', async () => {
|
|
2328
|
+
mockExistsSync.mockReturnValue(true);
|
|
2329
|
+
mockFsPromises.readFile.mockImplementation(async (filePath: any) => {
|
|
2330
|
+
const p = String(filePath);
|
|
2331
|
+
if (p.includes('subagents/agent-ae5eb9a.jsonl')) {
|
|
2332
|
+
return [
|
|
2333
|
+
'{"type":"assistant","timestamp":"2024-01-15T10:02:00Z","message":{"content":[{"type":"tool_use","id":"sub-tool-1","name":"Grep","input":{"pattern":"TODO"}}]}}',
|
|
2334
|
+
'{"type":"user","timestamp":"2024-01-15T10:02:01Z","message":{"content":[{"type":"tool_result","tool_use_id":"sub-tool-1","content":"3 matches found"}]}}',
|
|
2335
|
+
].join('\n');
|
|
2336
|
+
}
|
|
2337
|
+
if (p.endsWith('.jsonl')) {
|
|
2338
|
+
return [
|
|
2339
|
+
'{"type":"user","uuid":"u1","timestamp":"2024-01-15T10:00:00Z","message":{"content":"Review"}}',
|
|
2340
|
+
'{"type":"assistant","uuid":"a1","timestamp":"2024-01-15T10:01:00Z","message":{"content":[{"type":"tool_use","id":"task-1","name":"Task","input":{"description":"Review","prompt":"check","run_in_background":true}}]}}',
|
|
2341
|
+
`{"type":"user","uuid":"u2","timestamp":"2024-01-15T10:01:01Z","toolUseResult":{"isAsync":true,"agentId":"ae5eb9a"},"message":{"content":[{"type":"tool_result","tool_use_id":"task-1","content":"Launched"}]}}`,
|
|
2342
|
+
`{"type":"queue-operation","operation":"enqueue","content":"<task-notification><task-id>ae5eb9a</task-id><status>completed</status><result>Done reviewing</result></task-notification>"}`,
|
|
2343
|
+
].join('\n');
|
|
2344
|
+
}
|
|
2345
|
+
return '';
|
|
2346
|
+
});
|
|
2347
|
+
|
|
2348
|
+
const result = await loadSDKSessionMessages('/Users/test/vault', 'session-sidecar');
|
|
2349
|
+
|
|
2350
|
+
const assistantMsg = result.messages.find(m => m.toolCalls?.some(tc => tc.name === 'Task'));
|
|
2351
|
+
const taskToolCall = assistantMsg!.toolCalls!.find(tc => tc.name === 'Task')!;
|
|
2352
|
+
|
|
2353
|
+
expect(taskToolCall.subagent).toBeDefined();
|
|
2354
|
+
expect(taskToolCall.subagent!.toolCalls).toHaveLength(1);
|
|
2355
|
+
expect(taskToolCall.subagent!.toolCalls[0].name).toBe('Grep');
|
|
2356
|
+
expect(taskToolCall.subagent!.toolCalls[0].result).toBe('3 matches found');
|
|
2357
|
+
});
|
|
2358
|
+
});
|
|
2359
|
+
});
|