@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,2445 @@
|
|
|
1
|
+
import '@/providers';
|
|
2
|
+
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
|
|
7
|
+
import type { PreparedChatTurn } from '@/core/runtime/types';
|
|
8
|
+
import type { StreamChunk } from '@/core/types/chat';
|
|
9
|
+
import { encodeCodexModelSelectionId } from '@/providers/codex/modelSelection';
|
|
10
|
+
import { CODEX_SPARK_MODEL, DEFAULT_CODEX_PRIMARY_MODEL } from '@/providers/codex/types/models';
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Mocks
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
const mockTransportRequest = jest.fn();
|
|
17
|
+
const mockTransportNotify = jest.fn();
|
|
18
|
+
const mockTransportOnNotification = jest.fn();
|
|
19
|
+
const mockTransportOnServerRequest = jest.fn();
|
|
20
|
+
const mockTransportDispose = jest.fn();
|
|
21
|
+
const mockTransportStart = jest.fn();
|
|
22
|
+
const mockResolveLaunchSpec = jest.fn();
|
|
23
|
+
|
|
24
|
+
jest.mock('@/providers/codex/runtime/CodexRpcTransport', () => ({
|
|
25
|
+
CodexRpcTransport: jest.fn().mockImplementation(() => ({
|
|
26
|
+
request: mockTransportRequest,
|
|
27
|
+
notify: mockTransportNotify,
|
|
28
|
+
onNotification: mockTransportOnNotification,
|
|
29
|
+
onServerRequest: mockTransportOnServerRequest,
|
|
30
|
+
dispose: mockTransportDispose,
|
|
31
|
+
start: mockTransportStart,
|
|
32
|
+
})),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
const mockProcessStart = jest.fn();
|
|
36
|
+
const mockProcessShutdown = jest.fn().mockResolvedValue(undefined);
|
|
37
|
+
const mockProcessIsAlive = jest.fn().mockReturnValue(true);
|
|
38
|
+
const mockProcessOnExit = jest.fn();
|
|
39
|
+
const mockProcessStdin = { write: jest.fn((_c: any, _e: any, cb: any) => cb?.()) };
|
|
40
|
+
const mockProcessStdout = {};
|
|
41
|
+
const mockProcessStderr = {};
|
|
42
|
+
|
|
43
|
+
jest.mock('@/providers/codex/runtime/CodexAppServerProcess', () => ({
|
|
44
|
+
CodexAppServerProcess: jest.fn().mockImplementation(() => ({
|
|
45
|
+
start: mockProcessStart,
|
|
46
|
+
shutdown: mockProcessShutdown,
|
|
47
|
+
isAlive: mockProcessIsAlive,
|
|
48
|
+
onExit: mockProcessOnExit,
|
|
49
|
+
get stdin() { return mockProcessStdin; },
|
|
50
|
+
get stdout() { return mockProcessStdout; },
|
|
51
|
+
get stderr() { return mockProcessStderr; },
|
|
52
|
+
})),
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
jest.mock('@/utils/path', () => ({
|
|
56
|
+
getVaultPath: jest.fn().mockReturnValue('/test/vault'),
|
|
57
|
+
}));
|
|
58
|
+
|
|
59
|
+
jest.mock('@/utils/env', () => ({
|
|
60
|
+
...jest.requireActual('@/utils/env'),
|
|
61
|
+
getEnhancedPath: jest.fn().mockReturnValue('/usr/bin:/usr/local/bin'),
|
|
62
|
+
}));
|
|
63
|
+
|
|
64
|
+
jest.mock('@/providers/codex/runtime/codexAppServerSupport', () => {
|
|
65
|
+
const actual = jest.requireActual('@/providers/codex/runtime/codexAppServerSupport');
|
|
66
|
+
return {
|
|
67
|
+
...actual,
|
|
68
|
+
resolveCodexAppServerLaunchSpec: (...args: unknown[]) => mockResolveLaunchSpec(...args),
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Import after mocks
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
import { CodexAppServerProcess as MockedProcessClass } from '@/providers/codex/runtime/CodexAppServerProcess';
|
|
76
|
+
import { CodexChatRuntime } from '@/providers/codex/runtime/CodexChatRuntime';
|
|
77
|
+
|
|
78
|
+
type CapturedServerRequestHandler = (requestId: string | number, params: unknown) => Promise<unknown>;
|
|
79
|
+
|
|
80
|
+
// Notification handlers captured by onNotification
|
|
81
|
+
let notificationHandlers: Map<string, (params: unknown) => void>;
|
|
82
|
+
let serverRequestHandlers: Map<string, CapturedServerRequestHandler>;
|
|
83
|
+
|
|
84
|
+
function captureHandlers(): void {
|
|
85
|
+
notificationHandlers = new Map();
|
|
86
|
+
serverRequestHandlers = new Map();
|
|
87
|
+
|
|
88
|
+
mockTransportOnNotification.mockImplementation((method: string, handler: any) => {
|
|
89
|
+
notificationHandlers.set(method, handler);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
mockTransportOnServerRequest.mockImplementation((method: string, handler: any) => {
|
|
93
|
+
serverRequestHandlers.set(method, handler);
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Emit a notification as if the app-server sent it
|
|
98
|
+
function emitNotification(method: string, params: unknown): void {
|
|
99
|
+
const handler = notificationHandlers.get(method);
|
|
100
|
+
if (handler) handler(params);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function emitServerRequest(
|
|
104
|
+
method: string,
|
|
105
|
+
requestId: string | number,
|
|
106
|
+
params: unknown,
|
|
107
|
+
): Promise<unknown> {
|
|
108
|
+
const handler = serverRequestHandlers.get(method);
|
|
109
|
+
if (!handler) {
|
|
110
|
+
throw new Error(`No handler registered for ${method}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return handler(requestId, params);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Helpers
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function createMockPlugin(overrides: Record<string, unknown> = {}): any {
|
|
121
|
+
return {
|
|
122
|
+
settings: {
|
|
123
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
124
|
+
effortLevel: 'medium',
|
|
125
|
+
systemPrompt: '',
|
|
126
|
+
mediaFolder: '',
|
|
127
|
+
userName: '',
|
|
128
|
+
...overrides,
|
|
129
|
+
},
|
|
130
|
+
getActiveEnvironmentVariables: jest.fn().mockReturnValue(
|
|
131
|
+
'OPENAI_API_KEY=test-key\nOPENAI_BASE_URL=https://example.test/v1',
|
|
132
|
+
),
|
|
133
|
+
getResolvedProviderCliPath: jest.fn().mockReturnValue('/usr/local/bin/codex'),
|
|
134
|
+
app: {
|
|
135
|
+
vault: {
|
|
136
|
+
adapter: { basePath: '/test/vault' },
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function createTurn(text = 'hello', overrides: Partial<PreparedChatTurn> = {}): PreparedChatTurn {
|
|
143
|
+
return {
|
|
144
|
+
request: { text },
|
|
145
|
+
persistedContent: text,
|
|
146
|
+
prompt: text,
|
|
147
|
+
isCompact: false,
|
|
148
|
+
mcpMentions: new Set(),
|
|
149
|
+
...overrides,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function createCompactTurn(): PreparedChatTurn {
|
|
154
|
+
return createTurn('/compact', { isCompact: true });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function createWslLaunchSpec(overrides: Record<string, unknown> = {}) {
|
|
158
|
+
return {
|
|
159
|
+
target: {
|
|
160
|
+
method: 'wsl',
|
|
161
|
+
platformFamily: 'unix',
|
|
162
|
+
platformOs: 'linux',
|
|
163
|
+
distroName: 'Ubuntu',
|
|
164
|
+
},
|
|
165
|
+
command: 'wsl.exe',
|
|
166
|
+
args: ['--distribution', 'Ubuntu', '--cd', '/mnt/c/vault', 'codex', 'app-server', '--listen', 'stdio://'],
|
|
167
|
+
spawnCwd: 'C:\\vault',
|
|
168
|
+
targetCwd: '/mnt/c/vault',
|
|
169
|
+
env: {
|
|
170
|
+
OPENAI_API_KEY: 'test-key',
|
|
171
|
+
},
|
|
172
|
+
pathMapper: {
|
|
173
|
+
target: {
|
|
174
|
+
method: 'wsl',
|
|
175
|
+
platformFamily: 'unix',
|
|
176
|
+
platformOs: 'linux',
|
|
177
|
+
distroName: 'Ubuntu',
|
|
178
|
+
},
|
|
179
|
+
toTargetPath: jest.fn((value: string) => {
|
|
180
|
+
if (!value) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
if (value.startsWith('/home/') || value.startsWith('/mnt/')) {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (value.startsWith('/tmp/')) {
|
|
187
|
+
return value.replace('/tmp/', '/mnt/c/tmp/');
|
|
188
|
+
}
|
|
189
|
+
if (value.startsWith('/external/')) {
|
|
190
|
+
return value.replace('/external/', '/mnt/d/external/');
|
|
191
|
+
}
|
|
192
|
+
if (value.startsWith('\\\\wsl$\\Ubuntu\\')) {
|
|
193
|
+
return `/${value.slice('\\\\wsl$\\Ubuntu\\'.length).replace(/\\/g, '/')}`;
|
|
194
|
+
}
|
|
195
|
+
return `/mnt/c/${value.replace(/^\/+/, '').replace(/\\/g, '/')}`;
|
|
196
|
+
}),
|
|
197
|
+
toHostPath: jest.fn((value: string) => {
|
|
198
|
+
if (value.startsWith('/home/user/.codex/sessions/')) {
|
|
199
|
+
return value.replace('/home/user/.codex/sessions/', '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\').replace(/\//g, '\\');
|
|
200
|
+
}
|
|
201
|
+
if (value === '/home/user/.codex/sessions') {
|
|
202
|
+
return '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions';
|
|
203
|
+
}
|
|
204
|
+
if (value === '/home/user/.codex') {
|
|
205
|
+
return '\\\\wsl$\\Ubuntu\\home\\user\\.codex';
|
|
206
|
+
}
|
|
207
|
+
return value;
|
|
208
|
+
}),
|
|
209
|
+
mapTargetPathList: jest.fn((values: string[]) => values.map(value => {
|
|
210
|
+
if (value.startsWith('/external/')) {
|
|
211
|
+
return value.replace('/external/', '/mnt/d/external/');
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
})),
|
|
215
|
+
canRepresentHostPath: jest.fn(() => true),
|
|
216
|
+
},
|
|
217
|
+
...overrides,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function collectChunks(gen: AsyncGenerator<StreamChunk>): Promise<StreamChunk[]> {
|
|
222
|
+
const chunks: StreamChunk[] = [];
|
|
223
|
+
for await (const chunk of gen) {
|
|
224
|
+
chunks.push(chunk);
|
|
225
|
+
}
|
|
226
|
+
return chunks;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Default thread/start response
|
|
230
|
+
function threadStartResponse(threadId = 'thread-001') {
|
|
231
|
+
return {
|
|
232
|
+
thread: {
|
|
233
|
+
id: threadId,
|
|
234
|
+
path: `/tmp/sessions/${threadId}.jsonl`,
|
|
235
|
+
preview: '',
|
|
236
|
+
ephemeral: false,
|
|
237
|
+
status: { type: 'idle' },
|
|
238
|
+
turns: [] as Array<{ id: string; items: unknown[]; status: string; error: null }>,
|
|
239
|
+
cwd: '/test/vault',
|
|
240
|
+
cliVersion: '0.117.0',
|
|
241
|
+
modelProvider: 'openai_http',
|
|
242
|
+
source: 'vscode',
|
|
243
|
+
createdAt: 0,
|
|
244
|
+
updatedAt: 0,
|
|
245
|
+
agentNickname: null,
|
|
246
|
+
agentRole: null,
|
|
247
|
+
gitInfo: null,
|
|
248
|
+
name: null,
|
|
249
|
+
},
|
|
250
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
251
|
+
modelProvider: 'openai_http',
|
|
252
|
+
serviceTier: null,
|
|
253
|
+
cwd: '/test/vault',
|
|
254
|
+
approvalPolicy: 'never',
|
|
255
|
+
approvalsReviewer: 'user',
|
|
256
|
+
sandbox: { type: 'workspaceWrite' },
|
|
257
|
+
reasoningEffort: 'medium',
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function turnStartResponse(turnId = 'turn-001') {
|
|
262
|
+
return {
|
|
263
|
+
turn: { id: turnId, items: [], status: 'inProgress', error: null },
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Setup default transport.request mock: initialize → thread/start → turn/start
|
|
268
|
+
function setupDefaultRequestMock(
|
|
269
|
+
threadId = 'thread-001',
|
|
270
|
+
turnId = 'turn-001',
|
|
271
|
+
options: { isResume?: boolean } = {},
|
|
272
|
+
): void {
|
|
273
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
274
|
+
switch (method) {
|
|
275
|
+
case 'initialize':
|
|
276
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
277
|
+
case 'thread/start':
|
|
278
|
+
return threadStartResponse(threadId);
|
|
279
|
+
case 'thread/resume':
|
|
280
|
+
return threadStartResponse(threadId);
|
|
281
|
+
case 'turn/start':
|
|
282
|
+
// After turn/start, schedule notifications
|
|
283
|
+
setTimeout(() => {
|
|
284
|
+
emitNotification('item/agentMessage/delta', {
|
|
285
|
+
threadId, turnId, itemId: 'msg1', delta: 'Hello!',
|
|
286
|
+
});
|
|
287
|
+
emitNotification('thread/tokenUsage/updated', {
|
|
288
|
+
threadId, turnId,
|
|
289
|
+
tokenUsage: {
|
|
290
|
+
total: { totalTokens: 1000, inputTokens: 900, cachedInputTokens: 100, outputTokens: 100, reasoningOutputTokens: 50 },
|
|
291
|
+
last: { totalTokens: 1000, inputTokens: 900, cachedInputTokens: 100, outputTokens: 100, reasoningOutputTokens: 50 },
|
|
292
|
+
modelContextWindow: 200000,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
emitNotification('turn/completed', {
|
|
296
|
+
threadId, turn: { id: turnId, items: [], status: 'completed', error: null },
|
|
297
|
+
});
|
|
298
|
+
}, 0);
|
|
299
|
+
return turnStartResponse(turnId);
|
|
300
|
+
case 'turn/interrupt':
|
|
301
|
+
return {};
|
|
302
|
+
default:
|
|
303
|
+
throw new Error(`Unexpected request: ${method}`);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Find a specific RPC method call from transport request mock
|
|
309
|
+
function findCall(method: string) {
|
|
310
|
+
return mockTransportRequest.mock.calls.find((c: any[]) => c[0] === method) as any;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Build a request handler that returns the initialize response for all methods,
|
|
314
|
+
// with overrides for specific methods. Every handler gets the initialize case for free.
|
|
315
|
+
function buildRequestHandler(
|
|
316
|
+
handlers: Record<string, (...args: any[]) => any>,
|
|
317
|
+
): (method: string, ...args: any[]) => Promise<any> {
|
|
318
|
+
const initResponse = { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
319
|
+
return async (method: string, ...args: any[]) => {
|
|
320
|
+
if (method === 'initialize') return initResponse;
|
|
321
|
+
const handler = handlers[method];
|
|
322
|
+
if (handler) return handler(...args);
|
|
323
|
+
return {};
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Tests
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
describe('CodexChatRuntime', () => {
|
|
332
|
+
let runtime: CodexChatRuntime;
|
|
333
|
+
|
|
334
|
+
beforeEach(() => {
|
|
335
|
+
jest.clearAllMocks();
|
|
336
|
+
mockProcessIsAlive.mockReturnValue(true);
|
|
337
|
+
mockResolveLaunchSpec.mockImplementation((plugin: any) => ({
|
|
338
|
+
target: {
|
|
339
|
+
method: 'host-native',
|
|
340
|
+
platformFamily: 'unix',
|
|
341
|
+
platformOs: 'macos',
|
|
342
|
+
},
|
|
343
|
+
command: plugin.getResolvedProviderCliPath('codex') ?? 'codex',
|
|
344
|
+
args: ['app-server', '--listen', 'stdio://'],
|
|
345
|
+
spawnCwd: '/test/vault',
|
|
346
|
+
targetCwd: '/test/vault',
|
|
347
|
+
env: {
|
|
348
|
+
OPENAI_API_KEY: 'test-key',
|
|
349
|
+
OPENAI_BASE_URL: 'https://example.test/v1',
|
|
350
|
+
PATH: '/usr/bin:/usr/local/bin',
|
|
351
|
+
},
|
|
352
|
+
pathMapper: {
|
|
353
|
+
target: {
|
|
354
|
+
method: 'host-native',
|
|
355
|
+
platformFamily: 'unix',
|
|
356
|
+
platformOs: 'macos',
|
|
357
|
+
},
|
|
358
|
+
toTargetPath: jest.fn((value: string) => value),
|
|
359
|
+
toHostPath: jest.fn((value: string) => value),
|
|
360
|
+
mapTargetPathList: jest.fn((values: string[]) => values),
|
|
361
|
+
canRepresentHostPath: jest.fn(() => true),
|
|
362
|
+
},
|
|
363
|
+
}));
|
|
364
|
+
captureHandlers();
|
|
365
|
+
setupDefaultRequestMock();
|
|
366
|
+
runtime = new CodexChatRuntime(createMockPlugin());
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
afterEach(() => {
|
|
370
|
+
runtime.cleanup();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should have codex as providerId', () => {
|
|
374
|
+
expect(runtime.providerId).toBe('codex');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
it('should return codex capabilities', () => {
|
|
378
|
+
const caps = runtime.getCapabilities();
|
|
379
|
+
expect(caps.providerId).toBe('codex');
|
|
380
|
+
expect(caps.supportsRewind).toBe(false);
|
|
381
|
+
expect(caps.supportsFork).toBe(true);
|
|
382
|
+
expect(caps.supportsPlanMode).toBe(true);
|
|
383
|
+
expect(caps.supportsInstructionMode).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('should return empty commands', async () => {
|
|
387
|
+
expect(await runtime.getSupportedCommands()).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it('should return canRewind: false', async () => {
|
|
391
|
+
expect((await runtime.rewind('u1', 'a1')).canRewind).toBe(false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('ensureReady - app-server lifecycle', () => {
|
|
395
|
+
it('spawns the app-server process', async () => {
|
|
396
|
+
await runtime.ensureReady();
|
|
397
|
+
|
|
398
|
+
expect(MockedProcessClass).toHaveBeenCalledWith(expect.objectContaining({
|
|
399
|
+
command: '/usr/local/bin/codex',
|
|
400
|
+
spawnCwd: '/test/vault',
|
|
401
|
+
targetCwd: '/test/vault',
|
|
402
|
+
env: expect.objectContaining({
|
|
403
|
+
OPENAI_API_KEY: 'test-key',
|
|
404
|
+
OPENAI_BASE_URL: 'https://example.test/v1',
|
|
405
|
+
}),
|
|
406
|
+
}));
|
|
407
|
+
expect(mockProcessStart).toHaveBeenCalled();
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it('sends initialize and initialized', async () => {
|
|
411
|
+
await runtime.ensureReady();
|
|
412
|
+
|
|
413
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
414
|
+
'initialize',
|
|
415
|
+
expect.objectContaining({
|
|
416
|
+
clientInfo: { name: 'claudian', version: '1.0.0' },
|
|
417
|
+
}),
|
|
418
|
+
);
|
|
419
|
+
expect(mockTransportNotify).toHaveBeenCalledWith('initialized');
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
it('does not rebuild when config has not changed', async () => {
|
|
423
|
+
await runtime.ensureReady();
|
|
424
|
+
const firstCallCount = (MockedProcessClass as jest.Mock).mock.calls.length;
|
|
425
|
+
|
|
426
|
+
await runtime.ensureReady();
|
|
427
|
+
expect((MockedProcessClass as jest.Mock).mock.calls.length).toBe(firstCallCount);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
it('rebuilds when the system prompt changes', async () => {
|
|
431
|
+
await runtime.ensureReady();
|
|
432
|
+
|
|
433
|
+
const plugin = (runtime as any).plugin;
|
|
434
|
+
plugin.settings.systemPrompt = 'New instructions';
|
|
435
|
+
|
|
436
|
+
const rebuilt = await runtime.ensureReady();
|
|
437
|
+
expect(rebuilt).toBe(true);
|
|
438
|
+
// Shutdown was called on old process
|
|
439
|
+
expect(mockTransportDispose).toHaveBeenCalled();
|
|
440
|
+
expect(mockProcessShutdown).toHaveBeenCalled();
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
it('rebuilds when force is true', async () => {
|
|
444
|
+
await runtime.ensureReady();
|
|
445
|
+
const rebuilt = await runtime.ensureReady({ force: true });
|
|
446
|
+
expect(rebuilt).toBe(true);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('rebuilds when the existing app-server process is no longer alive', async () => {
|
|
450
|
+
await runtime.ensureReady();
|
|
451
|
+
const firstCallCount = (MockedProcessClass as jest.Mock).mock.calls.length;
|
|
452
|
+
|
|
453
|
+
mockProcessIsAlive.mockReturnValue(false);
|
|
454
|
+
|
|
455
|
+
const rebuilt = await runtime.ensureReady();
|
|
456
|
+
|
|
457
|
+
expect(rebuilt).toBe(true);
|
|
458
|
+
expect((MockedProcessClass as jest.Mock).mock.calls.length).toBe(firstCallCount + 1);
|
|
459
|
+
expect(mockTransportDispose).toHaveBeenCalled();
|
|
460
|
+
expect(mockProcessShutdown).toHaveBeenCalled();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
describe('query - new thread', () => {
|
|
465
|
+
it('sends thread/start and streams text', async () => {
|
|
466
|
+
const chunks = await collectChunks(runtime.query(createTurn('hi')));
|
|
467
|
+
|
|
468
|
+
// Verify thread/start was called
|
|
469
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
470
|
+
'thread/start',
|
|
471
|
+
expect.objectContaining({
|
|
472
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
473
|
+
cwd: '/test/vault',
|
|
474
|
+
persistExtendedHistory: true,
|
|
475
|
+
experimentalRawEvents: true,
|
|
476
|
+
baseInstructions: expect.any(String),
|
|
477
|
+
}),
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Verify text chunk
|
|
481
|
+
expect(chunks).toContainEqual({ type: 'text', content: 'Hello!' });
|
|
482
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it('handles host-native initialize responses that omit codexHome', async () => {
|
|
486
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
487
|
+
switch (method) {
|
|
488
|
+
case 'initialize':
|
|
489
|
+
return { userAgent: 'test/0.1', platformFamily: 'unix', platformOs: 'macos' };
|
|
490
|
+
case 'thread/start':
|
|
491
|
+
return threadStartResponse('thread-no-home');
|
|
492
|
+
case 'turn/start':
|
|
493
|
+
setTimeout(() => {
|
|
494
|
+
emitNotification('item/agentMessage/delta', {
|
|
495
|
+
threadId: 'thread-no-home',
|
|
496
|
+
turnId: 'turn-no-home',
|
|
497
|
+
itemId: 'msg1',
|
|
498
|
+
delta: 'Hello!',
|
|
499
|
+
});
|
|
500
|
+
emitNotification('turn/completed', {
|
|
501
|
+
threadId: 'thread-no-home',
|
|
502
|
+
turn: { id: 'turn-no-home', items: [], status: 'completed', error: null },
|
|
503
|
+
});
|
|
504
|
+
}, 0);
|
|
505
|
+
return turnStartResponse('turn-no-home');
|
|
506
|
+
case 'turn/interrupt':
|
|
507
|
+
return {};
|
|
508
|
+
default:
|
|
509
|
+
throw new Error(`Unexpected request: ${method}`);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
const chunks = await collectChunks(runtime.query(createTurn('hi')));
|
|
514
|
+
|
|
515
|
+
expect(chunks).toContainEqual({ type: 'text', content: 'Hello!' });
|
|
516
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
517
|
+
expect(findCall('thread/start')).toBeDefined();
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('sends reasoning summary off for GPT-5.3 Codex Spark turns', async () => {
|
|
521
|
+
runtime.cleanup();
|
|
522
|
+
runtime = new CodexChatRuntime(createMockPlugin({
|
|
523
|
+
model: encodeCodexModelSelectionId(CODEX_SPARK_MODEL),
|
|
524
|
+
providerConfigs: {
|
|
525
|
+
codex: {
|
|
526
|
+
customModels: CODEX_SPARK_MODEL,
|
|
527
|
+
reasoningSummary: 'detailed',
|
|
528
|
+
},
|
|
529
|
+
},
|
|
530
|
+
}));
|
|
531
|
+
|
|
532
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
533
|
+
|
|
534
|
+
const turnStartCall = findCall('turn/start');
|
|
535
|
+
expect(turnStartCall[1]).toMatchObject({
|
|
536
|
+
model: CODEX_SPARK_MODEL,
|
|
537
|
+
summary: 'none',
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it('sends the configured reasoning summary for other Codex models', async () => {
|
|
542
|
+
runtime.cleanup();
|
|
543
|
+
runtime = new CodexChatRuntime(createMockPlugin({
|
|
544
|
+
providerConfigs: {
|
|
545
|
+
codex: {
|
|
546
|
+
reasoningSummary: 'concise',
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
}));
|
|
550
|
+
|
|
551
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
552
|
+
|
|
553
|
+
const turnStartCall = findCall('turn/start');
|
|
554
|
+
expect(turnStartCall[1]).toMatchObject({
|
|
555
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
556
|
+
summary: 'concise',
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('derives WSL transcript and memories roots from thread paths when initialize omits codexHome', async () => {
|
|
561
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec());
|
|
562
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
563
|
+
if (method === 'initialize') {
|
|
564
|
+
return {
|
|
565
|
+
userAgent: 'test/0.1',
|
|
566
|
+
platformFamily: 'unix',
|
|
567
|
+
platformOs: 'linux',
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (method === 'thread/start') {
|
|
572
|
+
return {
|
|
573
|
+
...threadStartResponse('thread-wsl-no-home'),
|
|
574
|
+
thread: {
|
|
575
|
+
...threadStartResponse('thread-wsl-no-home').thread,
|
|
576
|
+
path: '/home/user/.codex/sessions/2026/04/14/thread-wsl-no-home.jsonl',
|
|
577
|
+
},
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (method === 'turn/start') {
|
|
582
|
+
setTimeout(() => {
|
|
583
|
+
emitNotification('turn/completed', {
|
|
584
|
+
threadId: 'thread-wsl-no-home',
|
|
585
|
+
turn: { id: 'turn-wsl-no-home', items: [], status: 'completed', error: null },
|
|
586
|
+
});
|
|
587
|
+
}, 0);
|
|
588
|
+
return turnStartResponse('turn-wsl-no-home');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return {};
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
595
|
+
|
|
596
|
+
const turnStartCall = findCall('turn/start');
|
|
597
|
+
expect(turnStartCall[1].sandboxPolicy).toMatchObject({
|
|
598
|
+
type: 'workspaceWrite',
|
|
599
|
+
writableRoots: expect.arrayContaining([
|
|
600
|
+
'/mnt/c/vault',
|
|
601
|
+
'/home/user/.codex/memories',
|
|
602
|
+
]),
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const result = runtime.buildSessionUpdates({ conversation: null, sessionInvalidated: false });
|
|
606
|
+
expect((result.updates.providerState as any)).toMatchObject({
|
|
607
|
+
threadId: 'thread-wsl-no-home',
|
|
608
|
+
sessionFilePath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\2026\\04\\14\\thread-wsl-no-home.jsonl',
|
|
609
|
+
transcriptRootPath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions',
|
|
610
|
+
});
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
it('uses the launch spec target cwd when starting a WSL-backed thread', async () => {
|
|
614
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec());
|
|
615
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
616
|
+
if (method === 'initialize') {
|
|
617
|
+
return {
|
|
618
|
+
userAgent: 'test/0.1',
|
|
619
|
+
codexHome: '/home/user/.codex',
|
|
620
|
+
platformFamily: 'unix',
|
|
621
|
+
platformOs: 'linux',
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (method === 'thread/start') {
|
|
626
|
+
return threadStartResponse('thread-wsl');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (method === 'turn/start') {
|
|
630
|
+
setTimeout(() => {
|
|
631
|
+
emitNotification('turn/completed', {
|
|
632
|
+
threadId: 'thread-wsl',
|
|
633
|
+
turn: { id: 'turn-wsl', items: [], status: 'completed', error: null },
|
|
634
|
+
});
|
|
635
|
+
}, 0);
|
|
636
|
+
return turnStartResponse('turn-wsl');
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return {};
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
643
|
+
|
|
644
|
+
expect(MockedProcessClass).toHaveBeenCalledWith(expect.objectContaining({
|
|
645
|
+
command: 'wsl.exe',
|
|
646
|
+
targetCwd: '/mnt/c/vault',
|
|
647
|
+
}));
|
|
648
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
649
|
+
'thread/start',
|
|
650
|
+
expect.objectContaining({
|
|
651
|
+
cwd: '/mnt/c/vault',
|
|
652
|
+
}),
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
it('stores host-readable WSL transcript paths in provider state', async () => {
|
|
657
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec());
|
|
658
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
659
|
+
if (method === 'initialize') {
|
|
660
|
+
return {
|
|
661
|
+
userAgent: 'test/0.1',
|
|
662
|
+
codexHome: '/home/user/.codex',
|
|
663
|
+
platformFamily: 'unix',
|
|
664
|
+
platformOs: 'linux',
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (method === 'thread/start') {
|
|
669
|
+
return {
|
|
670
|
+
...threadStartResponse('thread-wsl-path'),
|
|
671
|
+
thread: {
|
|
672
|
+
...threadStartResponse('thread-wsl-path').thread,
|
|
673
|
+
path: '/home/user/.codex/sessions/2026/04/06/thread-wsl-path.jsonl',
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (method === 'turn/start') {
|
|
679
|
+
setTimeout(() => {
|
|
680
|
+
emitNotification('turn/completed', {
|
|
681
|
+
threadId: 'thread-wsl-path',
|
|
682
|
+
turn: { id: 'turn-wsl-path', items: [], status: 'completed', error: null },
|
|
683
|
+
});
|
|
684
|
+
}, 0);
|
|
685
|
+
return turnStartResponse('turn-wsl-path');
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return {};
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
692
|
+
|
|
693
|
+
const result = runtime.buildSessionUpdates({ conversation: null, sessionInvalidated: false });
|
|
694
|
+
expect((result.updates.providerState as any)).toMatchObject({
|
|
695
|
+
threadId: 'thread-wsl-path',
|
|
696
|
+
sessionFilePath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\2026\\04\\06\\thread-wsl-path.jsonl',
|
|
697
|
+
transcriptRootPath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions',
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('passes baseInstructions (no temp file)', async () => {
|
|
702
|
+
const plugin = createMockPlugin({ systemPrompt: 'Be helpful.' });
|
|
703
|
+
const rt = new CodexChatRuntime(plugin);
|
|
704
|
+
|
|
705
|
+
await collectChunks(rt.query(createTurn()));
|
|
706
|
+
|
|
707
|
+
const threadStartCall = findCall('thread/start');
|
|
708
|
+
expect(threadStartCall).toBeDefined();
|
|
709
|
+
expect(threadStartCall[1].baseInstructions).toContain('Be helpful.');
|
|
710
|
+
|
|
711
|
+
rt.cleanup();
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('captures thread ID and session file path', async () => {
|
|
715
|
+
await collectChunks(runtime.query(createTurn()));
|
|
716
|
+
|
|
717
|
+
expect(runtime.getSessionId()).toBe('thread-001');
|
|
718
|
+
|
|
719
|
+
const result = runtime.buildSessionUpdates({ conversation: null, sessionInvalidated: false });
|
|
720
|
+
expect(result.updates.sessionId).toBe('thread-001');
|
|
721
|
+
expect((result.updates.providerState as any).threadId).toBe('thread-001');
|
|
722
|
+
expect((result.updates.providerState as any).sessionFilePath).toBe('/tmp/sessions/thread-001.jsonl');
|
|
723
|
+
});
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
describe('query - thread resume', () => {
|
|
727
|
+
it('sends thread/resume when a threadId exists', async () => {
|
|
728
|
+
runtime.syncConversationState({
|
|
729
|
+
sessionId: 'thread-existing',
|
|
730
|
+
providerState: { threadId: 'thread-existing', sessionFilePath: '/tmp/existing.jsonl' },
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
setupDefaultRequestMock('thread-existing');
|
|
734
|
+
captureHandlers();
|
|
735
|
+
|
|
736
|
+
await collectChunks(runtime.query(createTurn()));
|
|
737
|
+
|
|
738
|
+
const resumeCall = findCall('thread/resume');
|
|
739
|
+
expect(resumeCall).toBeDefined();
|
|
740
|
+
expect(resumeCall[1].threadId).toBe('thread-existing');
|
|
741
|
+
expect(resumeCall[1].baseInstructions).toBeDefined();
|
|
742
|
+
expect(resumeCall[1].experimentalRawEvents).toBe(true);
|
|
743
|
+
|
|
744
|
+
const startCall = findCall('thread/start');
|
|
745
|
+
expect(startCall).toBeUndefined();
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('skips resume when thread is already loaded in this daemon', async () => {
|
|
749
|
+
// First query starts a new thread
|
|
750
|
+
await collectChunks(runtime.query(createTurn()));
|
|
751
|
+
expect(runtime.getSessionId()).toBe('thread-001');
|
|
752
|
+
|
|
753
|
+
// Clear mocks for second query
|
|
754
|
+
mockTransportRequest.mockClear();
|
|
755
|
+
captureHandlers();
|
|
756
|
+
setupDefaultRequestMock('thread-001');
|
|
757
|
+
|
|
758
|
+
// Second query on same thread should skip both start and resume
|
|
759
|
+
await collectChunks(runtime.query(createTurn('second')));
|
|
760
|
+
|
|
761
|
+
const startCall = findCall('thread/start');
|
|
762
|
+
const resumeCall = findCall('thread/resume');
|
|
763
|
+
expect(startCall).toBeUndefined();
|
|
764
|
+
expect(resumeCall).toBeUndefined();
|
|
765
|
+
});
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
describe('query - streaming', () => {
|
|
769
|
+
it('yields usage chunk from token usage notification', async () => {
|
|
770
|
+
const chunks = await collectChunks(runtime.query(createTurn()));
|
|
771
|
+
|
|
772
|
+
const usageChunk = chunks.find(c => c.type === 'usage');
|
|
773
|
+
expect(usageChunk).toBeDefined();
|
|
774
|
+
expect(usageChunk).toMatchObject({
|
|
775
|
+
type: 'usage',
|
|
776
|
+
usage: {
|
|
777
|
+
inputTokens: 900,
|
|
778
|
+
cacheReadInputTokens: 100,
|
|
779
|
+
cacheCreationInputTokens: 0,
|
|
780
|
+
contextWindow: 200000,
|
|
781
|
+
},
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
it('yields tool_use and tool_result from item notifications', async () => {
|
|
786
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
787
|
+
'thread/start': () => threadStartResponse('thread-tools'),
|
|
788
|
+
'turn/start': () => {
|
|
789
|
+
setTimeout(() => {
|
|
790
|
+
emitNotification('item/started', {
|
|
791
|
+
item: {
|
|
792
|
+
type: 'commandExecution',
|
|
793
|
+
id: 'call_1',
|
|
794
|
+
command: 'echo test',
|
|
795
|
+
cwd: '/test/vault',
|
|
796
|
+
processId: '1',
|
|
797
|
+
source: 'unifiedExecStartup',
|
|
798
|
+
status: 'inProgress',
|
|
799
|
+
commandActions: [{ type: 'unknown', command: 'echo test' }],
|
|
800
|
+
aggregatedOutput: null,
|
|
801
|
+
exitCode: null,
|
|
802
|
+
durationMs: null,
|
|
803
|
+
},
|
|
804
|
+
threadId: 'thread-tools',
|
|
805
|
+
turnId: 'turn-tools',
|
|
806
|
+
});
|
|
807
|
+
emitNotification('item/completed', {
|
|
808
|
+
item: {
|
|
809
|
+
type: 'commandExecution',
|
|
810
|
+
id: 'call_1',
|
|
811
|
+
command: 'echo test',
|
|
812
|
+
cwd: '/test/vault',
|
|
813
|
+
processId: '1',
|
|
814
|
+
source: 'unifiedExecStartup',
|
|
815
|
+
status: 'completed',
|
|
816
|
+
commandActions: [],
|
|
817
|
+
aggregatedOutput: 'test\n',
|
|
818
|
+
exitCode: 0,
|
|
819
|
+
durationMs: 10,
|
|
820
|
+
},
|
|
821
|
+
threadId: 'thread-tools',
|
|
822
|
+
turnId: 'turn-tools',
|
|
823
|
+
});
|
|
824
|
+
emitNotification('turn/completed', {
|
|
825
|
+
threadId: 'thread-tools',
|
|
826
|
+
turn: { id: 'turn-tools', items: [], status: 'completed', error: null },
|
|
827
|
+
});
|
|
828
|
+
}, 0);
|
|
829
|
+
return turnStartResponse('turn-tools');
|
|
830
|
+
},
|
|
831
|
+
}));
|
|
832
|
+
|
|
833
|
+
const chunks = await collectChunks(runtime.query(createTurn()));
|
|
834
|
+
|
|
835
|
+
expect(chunks).toContainEqual(expect.objectContaining({
|
|
836
|
+
type: 'tool_use',
|
|
837
|
+
id: 'call_1',
|
|
838
|
+
name: 'Bash',
|
|
839
|
+
}));
|
|
840
|
+
expect(chunks).toContainEqual(expect.objectContaining({
|
|
841
|
+
type: 'tool_result',
|
|
842
|
+
id: 'call_1',
|
|
843
|
+
content: 'test\n',
|
|
844
|
+
isError: false,
|
|
845
|
+
}));
|
|
846
|
+
});
|
|
847
|
+
|
|
848
|
+
it('streams raw response items instead of tailing the Codex session file', async () => {
|
|
849
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-raw-runtime-'));
|
|
850
|
+
const sessionFilePath = path.join(tmpDir, 'thread-tail.jsonl');
|
|
851
|
+
fs.writeFileSync(sessionFilePath, '');
|
|
852
|
+
|
|
853
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
854
|
+
'thread/start': () => {
|
|
855
|
+
const response = threadStartResponse('thread-tail');
|
|
856
|
+
response.thread.path = sessionFilePath;
|
|
857
|
+
return response;
|
|
858
|
+
},
|
|
859
|
+
'turn/start': () => {
|
|
860
|
+
setTimeout(() => {
|
|
861
|
+
fs.appendFileSync(
|
|
862
|
+
sessionFilePath,
|
|
863
|
+
[
|
|
864
|
+
JSON.stringify({
|
|
865
|
+
timestamp: '2026-03-28T10:00:01.000Z',
|
|
866
|
+
type: 'response_item',
|
|
867
|
+
payload: {
|
|
868
|
+
type: 'function_call',
|
|
869
|
+
name: 'exec_command',
|
|
870
|
+
arguments: '{"command":"cat src/main.ts"}',
|
|
871
|
+
call_id: 'call_tail_1',
|
|
872
|
+
},
|
|
873
|
+
}),
|
|
874
|
+
JSON.stringify({
|
|
875
|
+
timestamp: '2026-03-28T10:00:02.000Z',
|
|
876
|
+
type: 'response_item',
|
|
877
|
+
payload: {
|
|
878
|
+
type: 'function_call_output',
|
|
879
|
+
call_id: 'call_tail_1',
|
|
880
|
+
output: 'Exit code: 0\nOutput:\nimport x from "./main";',
|
|
881
|
+
},
|
|
882
|
+
}),
|
|
883
|
+
].join('\n') + '\n',
|
|
884
|
+
);
|
|
885
|
+
|
|
886
|
+
emitNotification('rawResponseItem/completed', {
|
|
887
|
+
threadId: 'thread-tail',
|
|
888
|
+
turnId: 'turn-tail',
|
|
889
|
+
item: {
|
|
890
|
+
type: 'function_call',
|
|
891
|
+
name: 'exec_command',
|
|
892
|
+
call_id: 'call_raw_1',
|
|
893
|
+
arguments: '{"command":"cat package.json"}',
|
|
894
|
+
},
|
|
895
|
+
});
|
|
896
|
+
emitNotification('rawResponseItem/completed', {
|
|
897
|
+
threadId: 'thread-tail',
|
|
898
|
+
turnId: 'turn-tail',
|
|
899
|
+
item: {
|
|
900
|
+
type: 'function_call_output',
|
|
901
|
+
call_id: 'call_raw_1',
|
|
902
|
+
output: 'Exit code: 0\nOutput:\nraw package output',
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
emitNotification('turn/completed', {
|
|
906
|
+
threadId: 'thread-tail',
|
|
907
|
+
turn: { id: 'turn-tail', items: [], status: 'completed', error: null },
|
|
908
|
+
});
|
|
909
|
+
}, 0);
|
|
910
|
+
return turnStartResponse('turn-tail');
|
|
911
|
+
},
|
|
912
|
+
}));
|
|
913
|
+
|
|
914
|
+
try {
|
|
915
|
+
const chunks = await collectChunks(runtime.query(createTurn()));
|
|
916
|
+
|
|
917
|
+
expect(chunks).toContainEqual(expect.objectContaining({
|
|
918
|
+
type: 'tool_use',
|
|
919
|
+
id: 'call_raw_1',
|
|
920
|
+
name: 'Bash',
|
|
921
|
+
input: { command: 'cat package.json' },
|
|
922
|
+
}));
|
|
923
|
+
expect(chunks).toContainEqual(expect.objectContaining({
|
|
924
|
+
type: 'tool_result',
|
|
925
|
+
id: 'call_raw_1',
|
|
926
|
+
content: 'raw package output',
|
|
927
|
+
isError: false,
|
|
928
|
+
}));
|
|
929
|
+
expect(chunks).not.toContainEqual(expect.objectContaining({ id: 'call_tail_1' }));
|
|
930
|
+
} finally {
|
|
931
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
932
|
+
}
|
|
933
|
+
}, 10000);
|
|
934
|
+
|
|
935
|
+
it('emits error then done on failed turn', async () => {
|
|
936
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
937
|
+
'thread/start': () => threadStartResponse('thread-fail'),
|
|
938
|
+
'turn/start': () => {
|
|
939
|
+
setTimeout(() => {
|
|
940
|
+
emitNotification('turn/completed', {
|
|
941
|
+
threadId: 'thread-fail',
|
|
942
|
+
turn: {
|
|
943
|
+
id: 'turn-fail',
|
|
944
|
+
items: [],
|
|
945
|
+
status: 'failed',
|
|
946
|
+
error: { message: 'Model error', codexErrorInfo: 'other', additionalDetails: null },
|
|
947
|
+
},
|
|
948
|
+
});
|
|
949
|
+
}, 0);
|
|
950
|
+
return turnStartResponse('turn-fail');
|
|
951
|
+
},
|
|
952
|
+
}));
|
|
953
|
+
|
|
954
|
+
const chunks = await collectChunks(runtime.query(createTurn()));
|
|
955
|
+
|
|
956
|
+
expect(chunks).toContainEqual({ type: 'error', content: 'Model error' });
|
|
957
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it('ignores stale turn completion from a canceled previous turn', async () => {
|
|
961
|
+
let turnStartCount = 0;
|
|
962
|
+
|
|
963
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
964
|
+
if (method === 'initialize') {
|
|
965
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
if (method === 'thread/start') {
|
|
969
|
+
return threadStartResponse('thread-stale');
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
if (method === 'turn/start') {
|
|
973
|
+
turnStartCount += 1;
|
|
974
|
+
|
|
975
|
+
if (turnStartCount === 1) {
|
|
976
|
+
return turnStartResponse('turn-old');
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
setTimeout(() => {
|
|
980
|
+
emitNotification('turn/completed', {
|
|
981
|
+
threadId: 'thread-stale',
|
|
982
|
+
turn: { id: 'turn-old', items: [], status: 'completed', error: null },
|
|
983
|
+
});
|
|
984
|
+
emitNotification('item/agentMessage/delta', {
|
|
985
|
+
threadId: 'thread-stale',
|
|
986
|
+
turnId: 'turn-new',
|
|
987
|
+
itemId: 'msg-new',
|
|
988
|
+
delta: 'Fresh response',
|
|
989
|
+
});
|
|
990
|
+
emitNotification('turn/completed', {
|
|
991
|
+
threadId: 'thread-stale',
|
|
992
|
+
turn: { id: 'turn-new', items: [], status: 'completed', error: null },
|
|
993
|
+
});
|
|
994
|
+
}, 0);
|
|
995
|
+
|
|
996
|
+
return turnStartResponse('turn-new');
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (method === 'turn/interrupt') {
|
|
1000
|
+
return {};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
return {};
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
const firstGen = runtime.query(createTurn('first'));
|
|
1007
|
+
const firstResult = firstGen.next();
|
|
1008
|
+
await new Promise(r => setTimeout(r, 25));
|
|
1009
|
+
|
|
1010
|
+
runtime.cancel();
|
|
1011
|
+
|
|
1012
|
+
const first = await firstResult;
|
|
1013
|
+
const interruptedChunks: StreamChunk[] = [];
|
|
1014
|
+
if (!first.done && first.value) interruptedChunks.push(first.value);
|
|
1015
|
+
for await (const chunk of firstGen) interruptedChunks.push(chunk);
|
|
1016
|
+
|
|
1017
|
+
expect(interruptedChunks).toContainEqual({ type: 'done' });
|
|
1018
|
+
|
|
1019
|
+
const secondChunks = await collectChunks(runtime.query(createTurn('second')));
|
|
1020
|
+
|
|
1021
|
+
expect(secondChunks).toContainEqual({ type: 'text', content: 'Fresh response' });
|
|
1022
|
+
expect(secondChunks.filter(chunk => chunk.type === 'done')).toHaveLength(1);
|
|
1023
|
+
});
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
describe('cancel', () => {
|
|
1027
|
+
it('sends turn/interrupt with current threadId and turnId', async () => {
|
|
1028
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
1029
|
+
'thread/start': () => threadStartResponse('thread-cancel'),
|
|
1030
|
+
'turn/start': () => turnStartResponse('turn-cancel'),
|
|
1031
|
+
'turn/interrupt': () => ({}),
|
|
1032
|
+
}));
|
|
1033
|
+
|
|
1034
|
+
const gen = runtime.query(createTurn());
|
|
1035
|
+
// Kick the generator so it enters the chunk-waiting loop
|
|
1036
|
+
const firstResult = gen.next();
|
|
1037
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1038
|
+
|
|
1039
|
+
runtime.cancel();
|
|
1040
|
+
|
|
1041
|
+
// Collect all chunks
|
|
1042
|
+
const chunks: StreamChunk[] = [];
|
|
1043
|
+
const first = await firstResult;
|
|
1044
|
+
if (!first.done && first.value) chunks.push(first.value);
|
|
1045
|
+
for await (const chunk of gen) chunks.push(chunk);
|
|
1046
|
+
|
|
1047
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
1048
|
+
'turn/interrupt',
|
|
1049
|
+
{ threadId: 'thread-cancel', turnId: 'turn-cancel' },
|
|
1050
|
+
);
|
|
1051
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
1052
|
+
});
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
describe('session management', () => {
|
|
1056
|
+
it('clears provider state when session is invalidated', () => {
|
|
1057
|
+
runtime.syncConversationState({
|
|
1058
|
+
sessionId: 'thread_inv',
|
|
1059
|
+
providerState: { threadId: 'thread_inv', sessionFilePath: '/tmp/inv.jsonl' },
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
const result = runtime.buildSessionUpdates({
|
|
1063
|
+
conversation: {} as any,
|
|
1064
|
+
sessionInvalidated: true,
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
expect(result.updates.sessionId).toBeNull();
|
|
1068
|
+
expect(result.updates.providerState).toBeUndefined();
|
|
1069
|
+
});
|
|
1070
|
+
|
|
1071
|
+
it('round-trips an existing session file path', () => {
|
|
1072
|
+
runtime.syncConversationState({
|
|
1073
|
+
sessionId: 'thread_rt',
|
|
1074
|
+
providerState: { threadId: 'thread_rt', sessionFilePath: '/tmp/rt.jsonl' },
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
const result = runtime.buildSessionUpdates({
|
|
1078
|
+
conversation: null,
|
|
1079
|
+
sessionInvalidated: false,
|
|
1080
|
+
});
|
|
1081
|
+
|
|
1082
|
+
expect((result.updates.providerState as any).sessionFilePath).toBe('/tmp/rt.jsonl');
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('resolveSessionIdForFork falls back to conversation.sessionId', () => {
|
|
1086
|
+
expect(runtime.resolveSessionIdForFork({
|
|
1087
|
+
id: 'conv-legacy',
|
|
1088
|
+
providerId: 'codex',
|
|
1089
|
+
title: 'Legacy Codex Conversation',
|
|
1090
|
+
createdAt: Date.now(),
|
|
1091
|
+
updatedAt: Date.now(),
|
|
1092
|
+
sessionId: 'legacy-session',
|
|
1093
|
+
providerState: {
|
|
1094
|
+
forkSource: { sessionId: 'source-thread', resumeAt: 'turn-1' },
|
|
1095
|
+
},
|
|
1096
|
+
messages: [],
|
|
1097
|
+
})).toBe('legacy-session');
|
|
1098
|
+
});
|
|
1099
|
+
});
|
|
1100
|
+
|
|
1101
|
+
describe('query - image support', () => {
|
|
1102
|
+
it('attaches structured skill inputs for explicit $skill references', async () => {
|
|
1103
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
1104
|
+
'thread/start': () => threadStartResponse('thread-skill'),
|
|
1105
|
+
'skills/list': (params: Record<string, unknown>) => {
|
|
1106
|
+
expect(params.cwds).toEqual(['/test/vault']);
|
|
1107
|
+
return {
|
|
1108
|
+
data: [
|
|
1109
|
+
{
|
|
1110
|
+
cwd: '/test/vault',
|
|
1111
|
+
skills: [
|
|
1112
|
+
{
|
|
1113
|
+
name: 'analyze',
|
|
1114
|
+
description: 'Analyze code',
|
|
1115
|
+
path: '/test/vault/.codex/skills/analyze/SKILL.md',
|
|
1116
|
+
scope: 'repo',
|
|
1117
|
+
enabled: true,
|
|
1118
|
+
},
|
|
1119
|
+
],
|
|
1120
|
+
errors: [],
|
|
1121
|
+
},
|
|
1122
|
+
],
|
|
1123
|
+
};
|
|
1124
|
+
},
|
|
1125
|
+
'turn/start': () => {
|
|
1126
|
+
setTimeout(() => {
|
|
1127
|
+
emitNotification('turn/completed', {
|
|
1128
|
+
threadId: 'thread-skill',
|
|
1129
|
+
turn: { id: 'turn-skill', items: [], status: 'completed', error: null },
|
|
1130
|
+
});
|
|
1131
|
+
}, 0);
|
|
1132
|
+
return turnStartResponse('turn-skill');
|
|
1133
|
+
},
|
|
1134
|
+
}));
|
|
1135
|
+
|
|
1136
|
+
await collectChunks(runtime.query(createTurn('$analyze inspect this repo')));
|
|
1137
|
+
|
|
1138
|
+
expect(findCall('skills/list')).toBeDefined();
|
|
1139
|
+
expect(findCall('turn/start')?.[1]?.input).toEqual(
|
|
1140
|
+
expect.arrayContaining([
|
|
1141
|
+
expect.objectContaining({ type: 'text', text: '$analyze inspect this repo' }),
|
|
1142
|
+
expect.objectContaining({
|
|
1143
|
+
type: 'skill',
|
|
1144
|
+
name: 'analyze',
|
|
1145
|
+
path: '/test/vault/.codex/skills/analyze/SKILL.md',
|
|
1146
|
+
}),
|
|
1147
|
+
]),
|
|
1148
|
+
);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it('converts image attachments to localImage inputs', async () => {
|
|
1152
|
+
const turn = createTurn('describe this');
|
|
1153
|
+
turn.request.images = [
|
|
1154
|
+
{ id: 'img1', name: 'test.png', data: Buffer.from('fake-png').toString('base64'), mediaType: 'image/png', size: 100, source: 'file' as const },
|
|
1155
|
+
];
|
|
1156
|
+
|
|
1157
|
+
await collectChunks(runtime.query(turn));
|
|
1158
|
+
|
|
1159
|
+
const turnStartCall = findCall('turn/start');
|
|
1160
|
+
expect(turnStartCall).toBeDefined();
|
|
1161
|
+
const input = turnStartCall[1].input;
|
|
1162
|
+
expect(input).toEqual(
|
|
1163
|
+
expect.arrayContaining([
|
|
1164
|
+
expect.objectContaining({ type: 'localImage' }),
|
|
1165
|
+
expect.objectContaining({ type: 'text', text: 'describe this' }),
|
|
1166
|
+
]),
|
|
1167
|
+
);
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
it('cleans up temporary image files after the turn completes', async () => {
|
|
1171
|
+
const turn = createTurn('describe this');
|
|
1172
|
+
turn.request.images = [
|
|
1173
|
+
{ id: 'img1', name: 'test.png', data: Buffer.from('fake-png').toString('base64'), mediaType: 'image/png', size: 100, source: 'file' as const },
|
|
1174
|
+
];
|
|
1175
|
+
|
|
1176
|
+
await collectChunks(runtime.query(turn));
|
|
1177
|
+
|
|
1178
|
+
const turnStartCall = findCall('turn/start');
|
|
1179
|
+
const imageInput = turnStartCall?.[1]?.input?.find((item: Record<string, unknown>) => item.type === 'localImage');
|
|
1180
|
+
|
|
1181
|
+
expect(imageInput).toBeDefined();
|
|
1182
|
+
expect(fs.existsSync(imageInput.path as string)).toBe(false);
|
|
1183
|
+
expect(fs.existsSync(path.dirname(imageInput.path as string))).toBe(false);
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
it('maps localImage paths through the launch spec path mapper for WSL', async () => {
|
|
1187
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec());
|
|
1188
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1189
|
+
if (method === 'initialize') {
|
|
1190
|
+
return {
|
|
1191
|
+
userAgent: 'test/0.1',
|
|
1192
|
+
codexHome: '/home/user/.codex',
|
|
1193
|
+
platformFamily: 'unix',
|
|
1194
|
+
platformOs: 'linux',
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (method === 'thread/start') {
|
|
1199
|
+
return threadStartResponse('thread-image-wsl');
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
if (method === 'turn/start') {
|
|
1203
|
+
setTimeout(() => {
|
|
1204
|
+
emitNotification('turn/completed', {
|
|
1205
|
+
threadId: 'thread-image-wsl',
|
|
1206
|
+
turn: { id: 'turn-image-wsl', items: [], status: 'completed', error: null },
|
|
1207
|
+
});
|
|
1208
|
+
}, 0);
|
|
1209
|
+
return turnStartResponse('turn-image-wsl');
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
return {};
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
const turn = createTurn('describe this');
|
|
1216
|
+
turn.request.images = [
|
|
1217
|
+
{ id: 'img1', name: 'test.png', data: Buffer.from('fake-png').toString('base64'), mediaType: 'image/png', size: 100, source: 'file' as const },
|
|
1218
|
+
];
|
|
1219
|
+
|
|
1220
|
+
await collectChunks(runtime.query(turn));
|
|
1221
|
+
|
|
1222
|
+
const turnStartCall = findCall('turn/start');
|
|
1223
|
+
const imageInput = turnStartCall?.[1]?.input?.find((item: Record<string, unknown>) => item.type === 'localImage');
|
|
1224
|
+
|
|
1225
|
+
expect(imageInput).toBeDefined();
|
|
1226
|
+
expect(imageInput.path).toContain('/mnt/c/');
|
|
1227
|
+
});
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
describe('serverRequest/resolved lifecycle', () => {
|
|
1231
|
+
it('subscribes to serverRequest/resolved notifications', async () => {
|
|
1232
|
+
const gen = runtime.query(createTurn());
|
|
1233
|
+
// Kick the generator to start execution
|
|
1234
|
+
gen.next();
|
|
1235
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1236
|
+
|
|
1237
|
+
expect(notificationHandlers.has('serverRequest/resolved')).toBe(true);
|
|
1238
|
+
|
|
1239
|
+
// Clean up generator
|
|
1240
|
+
runtime.cancel();
|
|
1241
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
1242
|
+
for await (const _chunk of gen) { /* drain */ }
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it('only dismisses approval UI when serverRequest/resolved matches the active request and thread', async () => {
|
|
1246
|
+
const dismisser = jest.fn();
|
|
1247
|
+
runtime.setApprovalDismisser(dismisser);
|
|
1248
|
+
runtime.setApprovalCallback(jest.fn().mockImplementation(async () => new Promise(() => {})));
|
|
1249
|
+
|
|
1250
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
1251
|
+
'thread/start': () => threadStartResponse('thread-dismiss'),
|
|
1252
|
+
'turn/start': () => {
|
|
1253
|
+
setTimeout(() => {
|
|
1254
|
+
void emitServerRequest('item/commandExecution/requestApproval', 'req-live', {
|
|
1255
|
+
threadId: 'thread-dismiss',
|
|
1256
|
+
turnId: 'turn-dismiss',
|
|
1257
|
+
itemId: 'cmd-1',
|
|
1258
|
+
command: 'echo test',
|
|
1259
|
+
cwd: '/test/vault',
|
|
1260
|
+
});
|
|
1261
|
+
emitNotification('serverRequest/resolved', {
|
|
1262
|
+
threadId: 'thread-other',
|
|
1263
|
+
requestId: 'req-live',
|
|
1264
|
+
});
|
|
1265
|
+
emitNotification('serverRequest/resolved', {
|
|
1266
|
+
threadId: 'thread-dismiss',
|
|
1267
|
+
requestId: 'req-stale',
|
|
1268
|
+
});
|
|
1269
|
+
emitNotification('turn/completed', {
|
|
1270
|
+
threadId: 'thread-dismiss',
|
|
1271
|
+
turn: { id: 'turn-dismiss', items: [], status: 'completed', error: null },
|
|
1272
|
+
});
|
|
1273
|
+
}, 0);
|
|
1274
|
+
return turnStartResponse('turn-dismiss');
|
|
1275
|
+
},
|
|
1276
|
+
}));
|
|
1277
|
+
|
|
1278
|
+
await collectChunks(runtime.query(createTurn()));
|
|
1279
|
+
|
|
1280
|
+
expect(dismisser).not.toHaveBeenCalled();
|
|
1281
|
+
|
|
1282
|
+
emitNotification('serverRequest/resolved', {
|
|
1283
|
+
threadId: 'thread-dismiss',
|
|
1284
|
+
requestId: 'req-live',
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
expect(dismisser).toHaveBeenCalledTimes(1);
|
|
1288
|
+
});
|
|
1289
|
+
});
|
|
1290
|
+
|
|
1291
|
+
describe('cancel dismisses approval UI', () => {
|
|
1292
|
+
it('calls approvalDismisser on cancel', async () => {
|
|
1293
|
+
const dismisser = jest.fn();
|
|
1294
|
+
runtime.setApprovalDismisser(dismisser);
|
|
1295
|
+
|
|
1296
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
1297
|
+
'thread/start': () => threadStartResponse('thread-cancel-dismiss'),
|
|
1298
|
+
'turn/start': () => turnStartResponse('turn-cancel-dismiss'),
|
|
1299
|
+
'turn/interrupt': () => ({}),
|
|
1300
|
+
}));
|
|
1301
|
+
|
|
1302
|
+
const gen = runtime.query(createTurn());
|
|
1303
|
+
const firstResult = gen.next();
|
|
1304
|
+
await new Promise(r => setTimeout(r, 50));
|
|
1305
|
+
|
|
1306
|
+
runtime.cancel();
|
|
1307
|
+
|
|
1308
|
+
const chunks: StreamChunk[] = [];
|
|
1309
|
+
const first = await firstResult;
|
|
1310
|
+
if (!first.done && first.value) chunks.push(first.value);
|
|
1311
|
+
for await (const chunk of gen) chunks.push(chunk);
|
|
1312
|
+
|
|
1313
|
+
expect(dismisser).toHaveBeenCalled();
|
|
1314
|
+
});
|
|
1315
|
+
});
|
|
1316
|
+
|
|
1317
|
+
describe('thread/resume reasserts current settings', () => {
|
|
1318
|
+
it('sends approvalPolicy and sandbox on thread/resume', async () => {
|
|
1319
|
+
const plugin = createMockPlugin({ permissionMode: 'yolo' });
|
|
1320
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1321
|
+
|
|
1322
|
+
rt.syncConversationState({
|
|
1323
|
+
sessionId: 'thread-resume-settings',
|
|
1324
|
+
providerState: { threadId: 'thread-resume-settings', sessionFilePath: '/tmp/resume.jsonl' },
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
setupDefaultRequestMock('thread-resume-settings');
|
|
1328
|
+
captureHandlers();
|
|
1329
|
+
|
|
1330
|
+
await collectChunks(rt.query(createTurn()));
|
|
1331
|
+
|
|
1332
|
+
const resumeCall = findCall('thread/resume');
|
|
1333
|
+
expect(resumeCall).toBeDefined();
|
|
1334
|
+
expect(resumeCall[1].approvalPolicy).toBe('never');
|
|
1335
|
+
expect(resumeCall[1].sandbox).toBe('danger-full-access');
|
|
1336
|
+
|
|
1337
|
+
rt.cleanup();
|
|
1338
|
+
});
|
|
1339
|
+
|
|
1340
|
+
it('sends model on thread/resume', async () => {
|
|
1341
|
+
const plugin = createMockPlugin({ model: 'gpt-5.4-mini' });
|
|
1342
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1343
|
+
|
|
1344
|
+
rt.syncConversationState({
|
|
1345
|
+
sessionId: 'thread-resume-model',
|
|
1346
|
+
providerState: { threadId: 'thread-resume-model' },
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
setupDefaultRequestMock('thread-resume-model');
|
|
1350
|
+
captureHandlers();
|
|
1351
|
+
|
|
1352
|
+
await collectChunks(rt.query(createTurn()));
|
|
1353
|
+
|
|
1354
|
+
const resumeCall = findCall('thread/resume');
|
|
1355
|
+
expect(resumeCall).toBeDefined();
|
|
1356
|
+
expect(resumeCall[1].model).toBe('gpt-5.4-mini');
|
|
1357
|
+
|
|
1358
|
+
rt.cleanup();
|
|
1359
|
+
});
|
|
1360
|
+
|
|
1361
|
+
it('sends serviceTier on thread/resume when fast mode is enabled', async () => {
|
|
1362
|
+
const plugin = createMockPlugin({ model: DEFAULT_CODEX_PRIMARY_MODEL, serviceTier: 'fast' });
|
|
1363
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1364
|
+
|
|
1365
|
+
rt.syncConversationState({
|
|
1366
|
+
sessionId: 'thread-resume-fast',
|
|
1367
|
+
providerState: { threadId: 'thread-resume-fast' },
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
setupDefaultRequestMock('thread-resume-fast');
|
|
1371
|
+
captureHandlers();
|
|
1372
|
+
|
|
1373
|
+
await collectChunks(rt.query(createTurn()));
|
|
1374
|
+
|
|
1375
|
+
const resumeCall = findCall('thread/resume');
|
|
1376
|
+
expect(resumeCall).toBeDefined();
|
|
1377
|
+
expect(resumeCall[1].serviceTier).toBe('fast');
|
|
1378
|
+
|
|
1379
|
+
rt.cleanup();
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
it('reasserts approvalPolicy and sandboxPolicy on turn/start for already-loaded threads', async () => {
|
|
1383
|
+
const plugin = createMockPlugin({ permissionMode: 'normal' });
|
|
1384
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1385
|
+
|
|
1386
|
+
await collectChunks(rt.query(createTurn('first')));
|
|
1387
|
+
|
|
1388
|
+
mockTransportRequest.mockClear();
|
|
1389
|
+
captureHandlers();
|
|
1390
|
+
setupDefaultRequestMock('thread-001', 'turn-002');
|
|
1391
|
+
|
|
1392
|
+
plugin.settings.permissionMode = 'yolo';
|
|
1393
|
+
await collectChunks(rt.query(createTurn('second')));
|
|
1394
|
+
|
|
1395
|
+
const turnStartCall = findCall('turn/start');
|
|
1396
|
+
expect(turnStartCall).toBeDefined();
|
|
1397
|
+
expect(turnStartCall[1].approvalPolicy).toBe('never');
|
|
1398
|
+
expect(turnStartCall[1].sandboxPolicy).toEqual({ type: 'dangerFullAccess' });
|
|
1399
|
+
|
|
1400
|
+
rt.cleanup();
|
|
1401
|
+
});
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
describe('query - permission modes', () => {
|
|
1405
|
+
it('uses danger-full-access for yolo mode', async () => {
|
|
1406
|
+
const plugin = createMockPlugin({ permissionMode: 'yolo' });
|
|
1407
|
+
const yoloRuntime = new CodexChatRuntime(plugin);
|
|
1408
|
+
|
|
1409
|
+
await collectChunks(yoloRuntime.query(createTurn()));
|
|
1410
|
+
|
|
1411
|
+
const threadStartCall = findCall('thread/start');
|
|
1412
|
+
expect(threadStartCall[1].sandbox).toBe('danger-full-access');
|
|
1413
|
+
expect(threadStartCall[1].approvalPolicy).toBe('never');
|
|
1414
|
+
|
|
1415
|
+
yoloRuntime.cleanup();
|
|
1416
|
+
});
|
|
1417
|
+
|
|
1418
|
+
it('sends serviceTier fast on thread/start and turn/start when fast mode is enabled', async () => {
|
|
1419
|
+
const plugin = createMockPlugin({ serviceTier: 'fast' });
|
|
1420
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1421
|
+
|
|
1422
|
+
await collectChunks(rt.query(createTurn()));
|
|
1423
|
+
|
|
1424
|
+
const threadStartCall = findCall('thread/start');
|
|
1425
|
+
const turnStartCall = findCall('turn/start');
|
|
1426
|
+
expect(threadStartCall[1].serviceTier).toBe('fast');
|
|
1427
|
+
expect(turnStartCall[1].serviceTier).toBe('fast');
|
|
1428
|
+
|
|
1429
|
+
rt.cleanup();
|
|
1430
|
+
});
|
|
1431
|
+
|
|
1432
|
+
it('sends serviceTier null on turn/start when fast mode is disabled', async () => {
|
|
1433
|
+
const plugin = createMockPlugin({ serviceTier: 'default' });
|
|
1434
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1435
|
+
|
|
1436
|
+
await collectChunks(rt.query(createTurn()));
|
|
1437
|
+
|
|
1438
|
+
const turnStartCall = findCall('turn/start');
|
|
1439
|
+
expect(turnStartCall[1].serviceTier).toBeNull();
|
|
1440
|
+
|
|
1441
|
+
rt.cleanup();
|
|
1442
|
+
});
|
|
1443
|
+
|
|
1444
|
+
it('uses workspace-write with on-request for normal mode', async () => {
|
|
1445
|
+
const plugin = createMockPlugin({ permissionMode: 'normal' });
|
|
1446
|
+
const safeRuntime = new CodexChatRuntime(plugin);
|
|
1447
|
+
|
|
1448
|
+
await collectChunks(safeRuntime.query(createTurn()));
|
|
1449
|
+
|
|
1450
|
+
const threadStartCall = findCall('thread/start');
|
|
1451
|
+
expect(threadStartCall[1].sandbox).toBe('workspace-write');
|
|
1452
|
+
expect(threadStartCall[1].approvalPolicy).toBe('on-request');
|
|
1453
|
+
|
|
1454
|
+
safeRuntime.cleanup();
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
it('falls back to normal mode for unrecognized permissionMode', async () => {
|
|
1458
|
+
const plugin = createMockPlugin({ permissionMode: 'plan' });
|
|
1459
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1460
|
+
|
|
1461
|
+
await collectChunks(rt.query(createTurn()));
|
|
1462
|
+
|
|
1463
|
+
const threadStartCall = findCall('thread/start');
|
|
1464
|
+
expect(threadStartCall[1].sandbox).toBe('workspace-write');
|
|
1465
|
+
expect(threadStartCall[1].approvalPolicy).toBe('on-request');
|
|
1466
|
+
|
|
1467
|
+
rt.cleanup();
|
|
1468
|
+
});
|
|
1469
|
+
|
|
1470
|
+
it('always sends baseline sandboxPolicy even without external context', async () => {
|
|
1471
|
+
const plugin = createMockPlugin({ permissionMode: 'normal' });
|
|
1472
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1473
|
+
|
|
1474
|
+
await collectChunks(rt.query(createTurn()));
|
|
1475
|
+
|
|
1476
|
+
const turnStartCall = findCall('turn/start');
|
|
1477
|
+
expect(turnStartCall[1].sandboxPolicy).toBeDefined();
|
|
1478
|
+
expect(turnStartCall[1].sandboxPolicy.type).toBe('workspaceWrite');
|
|
1479
|
+
expect(turnStartCall[1].sandboxPolicy.writableRoots).toContain('/test/vault');
|
|
1480
|
+
|
|
1481
|
+
rt.cleanup();
|
|
1482
|
+
});
|
|
1483
|
+
|
|
1484
|
+
it('sends explicit dangerFullAccess sandboxPolicy in yolo mode', async () => {
|
|
1485
|
+
const plugin = createMockPlugin({ permissionMode: 'yolo' });
|
|
1486
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1487
|
+
|
|
1488
|
+
await collectChunks(rt.query(createTurn()));
|
|
1489
|
+
|
|
1490
|
+
const turnStartCall = findCall('turn/start');
|
|
1491
|
+
expect(turnStartCall[1].sandboxPolicy).toEqual({ type: 'dangerFullAccess' });
|
|
1492
|
+
|
|
1493
|
+
rt.cleanup();
|
|
1494
|
+
});
|
|
1495
|
+
|
|
1496
|
+
it('sends sandboxPolicy with external context writable roots in normal mode', async () => {
|
|
1497
|
+
const turn = createTurn('inspect both locations');
|
|
1498
|
+
turn.request.externalContextPaths = ['/external/a', '/external/b'];
|
|
1499
|
+
|
|
1500
|
+
await collectChunks(runtime.query(turn));
|
|
1501
|
+
|
|
1502
|
+
const turnStartCall = findCall('turn/start');
|
|
1503
|
+
|
|
1504
|
+
expect(turnStartCall[1].sandboxPolicy).toMatchObject({
|
|
1505
|
+
type: 'workspaceWrite',
|
|
1506
|
+
writableRoots: expect.arrayContaining([
|
|
1507
|
+
'/test/vault',
|
|
1508
|
+
'/external/a',
|
|
1509
|
+
'/external/b',
|
|
1510
|
+
]),
|
|
1511
|
+
readOnlyAccess: { type: 'fullAccess' },
|
|
1512
|
+
networkAccess: false,
|
|
1513
|
+
excludeTmpdirEnvVar: false,
|
|
1514
|
+
excludeSlashTmp: false,
|
|
1515
|
+
});
|
|
1516
|
+
});
|
|
1517
|
+
|
|
1518
|
+
it('maps external context and memory roots into the target filesystem for WSL', async () => {
|
|
1519
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec({
|
|
1520
|
+
pathMapper: {
|
|
1521
|
+
target: {
|
|
1522
|
+
method: 'wsl',
|
|
1523
|
+
platformFamily: 'unix',
|
|
1524
|
+
platformOs: 'linux',
|
|
1525
|
+
distroName: 'Ubuntu',
|
|
1526
|
+
},
|
|
1527
|
+
toTargetPath: jest.fn((value: string) => {
|
|
1528
|
+
if (value.startsWith('/tmp/')) {
|
|
1529
|
+
return value.replace('/tmp/', '/mnt/c/tmp/');
|
|
1530
|
+
}
|
|
1531
|
+
if (value.startsWith('/external/')) {
|
|
1532
|
+
return value.replace('/external/', '/mnt/d/external/');
|
|
1533
|
+
}
|
|
1534
|
+
return `/mnt/c/${value.replace(/^\/+/, '')}`;
|
|
1535
|
+
}),
|
|
1536
|
+
toHostPath: jest.fn((value: string) => {
|
|
1537
|
+
if (value === '/home/user/.codex') return '\\\\wsl$\\Ubuntu\\home\\user\\.codex';
|
|
1538
|
+
if (value === '/home/user/.codex/sessions') return '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions';
|
|
1539
|
+
return value;
|
|
1540
|
+
}),
|
|
1541
|
+
mapTargetPathList: jest.fn((values: string[]) => values.map(value => value.replace('/external/', '/mnt/d/external/'))),
|
|
1542
|
+
canRepresentHostPath: jest.fn(() => true),
|
|
1543
|
+
},
|
|
1544
|
+
}));
|
|
1545
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1546
|
+
if (method === 'initialize') {
|
|
1547
|
+
return {
|
|
1548
|
+
userAgent: 'test/0.1',
|
|
1549
|
+
codexHome: '/home/user/.codex',
|
|
1550
|
+
platformFamily: 'unix',
|
|
1551
|
+
platformOs: 'linux',
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
if (method === 'thread/start') {
|
|
1556
|
+
return threadStartResponse('thread-wsl-sandbox');
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
if (method === 'turn/start') {
|
|
1560
|
+
setTimeout(() => {
|
|
1561
|
+
emitNotification('turn/completed', {
|
|
1562
|
+
threadId: 'thread-wsl-sandbox',
|
|
1563
|
+
turn: { id: 'turn-wsl-sandbox', items: [], status: 'completed', error: null },
|
|
1564
|
+
});
|
|
1565
|
+
}, 0);
|
|
1566
|
+
return turnStartResponse('turn-wsl-sandbox');
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return {};
|
|
1570
|
+
});
|
|
1571
|
+
|
|
1572
|
+
const turn = createTurn('inspect both locations');
|
|
1573
|
+
turn.request.externalContextPaths = ['/external/a', '/external/b'];
|
|
1574
|
+
|
|
1575
|
+
await collectChunks(runtime.query(turn));
|
|
1576
|
+
|
|
1577
|
+
const turnStartCall = findCall('turn/start');
|
|
1578
|
+
expect(turnStartCall[1].sandboxPolicy).toMatchObject({
|
|
1579
|
+
type: 'workspaceWrite',
|
|
1580
|
+
writableRoots: expect.arrayContaining([
|
|
1581
|
+
'/mnt/c/vault',
|
|
1582
|
+
'/mnt/d/external/a',
|
|
1583
|
+
'/mnt/d/external/b',
|
|
1584
|
+
'/home/user/.codex/memories',
|
|
1585
|
+
]),
|
|
1586
|
+
});
|
|
1587
|
+
});
|
|
1588
|
+
|
|
1589
|
+
it('fails with a clear error when an external context path cannot be mapped into WSL', async () => {
|
|
1590
|
+
mockResolveLaunchSpec.mockReturnValue(createWslLaunchSpec({
|
|
1591
|
+
pathMapper: {
|
|
1592
|
+
target: {
|
|
1593
|
+
method: 'wsl',
|
|
1594
|
+
platformFamily: 'unix',
|
|
1595
|
+
platformOs: 'linux',
|
|
1596
|
+
distroName: 'Ubuntu',
|
|
1597
|
+
},
|
|
1598
|
+
toTargetPath: jest.fn((value: string) => value === '/external/a' ? null : `/mnt/c/${value.replace(/^\/+/, '')}`),
|
|
1599
|
+
toHostPath: jest.fn((value: string) => {
|
|
1600
|
+
if (value === '/home/user/.codex') return '\\\\wsl$\\Ubuntu\\home\\user\\.codex';
|
|
1601
|
+
if (value === '/home/user/.codex/sessions') return '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions';
|
|
1602
|
+
return value;
|
|
1603
|
+
}),
|
|
1604
|
+
mapTargetPathList: jest.fn(),
|
|
1605
|
+
canRepresentHostPath: jest.fn(() => true),
|
|
1606
|
+
},
|
|
1607
|
+
}));
|
|
1608
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1609
|
+
if (method === 'initialize') {
|
|
1610
|
+
return {
|
|
1611
|
+
userAgent: 'test/0.1',
|
|
1612
|
+
codexHome: '/home/user/.codex',
|
|
1613
|
+
platformFamily: 'unix',
|
|
1614
|
+
platformOs: 'linux',
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
if (method === 'thread/start') {
|
|
1619
|
+
return threadStartResponse('thread-wsl-error');
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
return {};
|
|
1623
|
+
});
|
|
1624
|
+
|
|
1625
|
+
const turn = createTurn('inspect location');
|
|
1626
|
+
turn.request.externalContextPaths = ['/external/a'];
|
|
1627
|
+
|
|
1628
|
+
const chunks = await collectChunks(runtime.query(turn));
|
|
1629
|
+
|
|
1630
|
+
expect(chunks).toContainEqual({
|
|
1631
|
+
type: 'error',
|
|
1632
|
+
content: 'Codex cannot access external context path from the selected target: /external/a',
|
|
1633
|
+
});
|
|
1634
|
+
});
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
describe('query - codexSafeMode read-only', () => {
|
|
1638
|
+
it('sends sandbox read-only on thread/resume when codexSafeMode is read-only', async () => {
|
|
1639
|
+
const plugin = createMockPlugin({ permissionMode: 'normal', codexSafeMode: 'read-only' });
|
|
1640
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1641
|
+
|
|
1642
|
+
rt.syncConversationState({
|
|
1643
|
+
sessionId: 'thread-resume-read-only',
|
|
1644
|
+
providerState: { threadId: 'thread-resume-read-only', sessionFilePath: '/tmp/resume.jsonl' },
|
|
1645
|
+
});
|
|
1646
|
+
|
|
1647
|
+
setupDefaultRequestMock('thread-resume-read-only');
|
|
1648
|
+
captureHandlers();
|
|
1649
|
+
|
|
1650
|
+
await collectChunks(rt.query(createTurn('resume')));
|
|
1651
|
+
|
|
1652
|
+
const resumeCall = findCall('thread/resume');
|
|
1653
|
+
expect(resumeCall).toBeDefined();
|
|
1654
|
+
expect(resumeCall[1].sandbox).toBe('read-only');
|
|
1655
|
+
expect(resumeCall[1].approvalPolicy).toBe('on-request');
|
|
1656
|
+
|
|
1657
|
+
rt.cleanup();
|
|
1658
|
+
});
|
|
1659
|
+
|
|
1660
|
+
it('sends sandbox read-only on thread/start when codexSafeMode is read-only', async () => {
|
|
1661
|
+
const plugin = createMockPlugin({ permissionMode: 'normal', codexSafeMode: 'read-only' });
|
|
1662
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1663
|
+
captureHandlers();
|
|
1664
|
+
setupDefaultRequestMock();
|
|
1665
|
+
|
|
1666
|
+
const turn = createTurn('hello');
|
|
1667
|
+
await collectChunks(rt.query(turn));
|
|
1668
|
+
|
|
1669
|
+
const threadStartCall = findCall('thread/start');
|
|
1670
|
+
expect(threadStartCall[1].sandbox).toBe('read-only');
|
|
1671
|
+
expect(threadStartCall[1].approvalPolicy).toBe('on-request');
|
|
1672
|
+
});
|
|
1673
|
+
|
|
1674
|
+
it('sends readOnly sandboxPolicy on turn/start when codexSafeMode is read-only', async () => {
|
|
1675
|
+
const plugin = createMockPlugin({ permissionMode: 'normal', codexSafeMode: 'read-only' });
|
|
1676
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1677
|
+
captureHandlers();
|
|
1678
|
+
setupDefaultRequestMock();
|
|
1679
|
+
|
|
1680
|
+
const turn = createTurn('hello');
|
|
1681
|
+
await collectChunks(rt.query(turn));
|
|
1682
|
+
|
|
1683
|
+
const turnStartCall = findCall('turn/start');
|
|
1684
|
+
expect(turnStartCall[1].sandboxPolicy).toEqual({
|
|
1685
|
+
type: 'readOnly',
|
|
1686
|
+
access: { type: 'fullAccess' },
|
|
1687
|
+
networkAccess: false,
|
|
1688
|
+
});
|
|
1689
|
+
});
|
|
1690
|
+
|
|
1691
|
+
it('reasserts readOnly sandboxPolicy on already-loaded threads when codexSafeMode changes', async () => {
|
|
1692
|
+
const plugin = createMockPlugin({ permissionMode: 'normal', codexSafeMode: 'workspace-write' });
|
|
1693
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1694
|
+
|
|
1695
|
+
await collectChunks(rt.query(createTurn('first')));
|
|
1696
|
+
|
|
1697
|
+
mockTransportRequest.mockClear();
|
|
1698
|
+
captureHandlers();
|
|
1699
|
+
setupDefaultRequestMock('thread-001', 'turn-002');
|
|
1700
|
+
|
|
1701
|
+
plugin.settings.codexSafeMode = 'read-only';
|
|
1702
|
+
await collectChunks(rt.query(createTurn('second')));
|
|
1703
|
+
|
|
1704
|
+
const turnStartCall = findCall('turn/start');
|
|
1705
|
+
expect(turnStartCall).toBeDefined();
|
|
1706
|
+
expect(turnStartCall[1].approvalPolicy).toBe('on-request');
|
|
1707
|
+
expect(turnStartCall[1].sandboxPolicy).toEqual({
|
|
1708
|
+
type: 'readOnly',
|
|
1709
|
+
access: { type: 'fullAccess' },
|
|
1710
|
+
networkAccess: false,
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
rt.cleanup();
|
|
1714
|
+
});
|
|
1715
|
+
});
|
|
1716
|
+
|
|
1717
|
+
describe('query - user_message_id emission', () => {
|
|
1718
|
+
it('records user message metadata after turn/start', async () => {
|
|
1719
|
+
await collectChunks(runtime.query(createTurn('hi')));
|
|
1720
|
+
|
|
1721
|
+
expect(runtime.consumeTurnMetadata()).toMatchObject({
|
|
1722
|
+
userMessageId: 'turn-001',
|
|
1723
|
+
wasSent: true,
|
|
1724
|
+
});
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1727
|
+
|
|
1728
|
+
describe('steer', () => {
|
|
1729
|
+
it('sends turn/steer for the active turn', async () => {
|
|
1730
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
1731
|
+
'thread/start': () => threadStartResponse('thread-steer'),
|
|
1732
|
+
'turn/start': () => turnStartResponse('turn-steer'),
|
|
1733
|
+
'turn/steer': () => ({ turnId: 'turn-steer' }),
|
|
1734
|
+
}));
|
|
1735
|
+
|
|
1736
|
+
const queryPromise = collectChunks(runtime.query(createTurn('start here')));
|
|
1737
|
+
await new Promise(r => setTimeout(r, 0));
|
|
1738
|
+
|
|
1739
|
+
await expect(runtime.steer?.(createTurn('follow up'))).resolves.toBe(true);
|
|
1740
|
+
|
|
1741
|
+
emitNotification('turn/completed', {
|
|
1742
|
+
threadId: 'thread-steer',
|
|
1743
|
+
turn: { id: 'turn-steer', items: [], status: 'completed', error: null },
|
|
1744
|
+
});
|
|
1745
|
+
await queryPromise;
|
|
1746
|
+
|
|
1747
|
+
expect(findCall('turn/steer')).toEqual([
|
|
1748
|
+
'turn/steer',
|
|
1749
|
+
{
|
|
1750
|
+
threadId: 'thread-steer',
|
|
1751
|
+
expectedTurnId: 'turn-steer',
|
|
1752
|
+
input: [{ type: 'text', text: 'follow up', text_elements: [] }],
|
|
1753
|
+
},
|
|
1754
|
+
]);
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
it('returns false when there is no active turn to steer', async () => {
|
|
1758
|
+
await expect(runtime.steer?.(createTurn('follow up'))).resolves.toBe(false);
|
|
1759
|
+
});
|
|
1760
|
+
});
|
|
1761
|
+
|
|
1762
|
+
describe('query - plan mode (collaborationMode)', () => {
|
|
1763
|
+
it('includes collaborationMode in turn/start when permissionMode is plan', async () => {
|
|
1764
|
+
const plugin = createMockPlugin({ permissionMode: 'plan' });
|
|
1765
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1766
|
+
captureHandlers();
|
|
1767
|
+
setupDefaultRequestMock();
|
|
1768
|
+
|
|
1769
|
+
await collectChunks(rt.query(createTurn('plan this')));
|
|
1770
|
+
|
|
1771
|
+
const turnStartCall = findCall('turn/start');
|
|
1772
|
+
expect(turnStartCall).toBeDefined();
|
|
1773
|
+
expect(turnStartCall[1].collaborationMode).toEqual({
|
|
1774
|
+
mode: 'plan',
|
|
1775
|
+
settings: {
|
|
1776
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1777
|
+
reasoning_effort: 'medium',
|
|
1778
|
+
developer_instructions: null,
|
|
1779
|
+
},
|
|
1780
|
+
});
|
|
1781
|
+
|
|
1782
|
+
rt.cleanup();
|
|
1783
|
+
});
|
|
1784
|
+
|
|
1785
|
+
it('includes default collaborationMode when permissionMode is normal', async () => {
|
|
1786
|
+
const plugin = createMockPlugin({ permissionMode: 'normal' });
|
|
1787
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1788
|
+
captureHandlers();
|
|
1789
|
+
setupDefaultRequestMock();
|
|
1790
|
+
|
|
1791
|
+
await collectChunks(rt.query(createTurn('hello')));
|
|
1792
|
+
|
|
1793
|
+
const turnStartCall = findCall('turn/start');
|
|
1794
|
+
expect(turnStartCall).toBeDefined();
|
|
1795
|
+
expect(turnStartCall[1].collaborationMode).toEqual({
|
|
1796
|
+
mode: 'default',
|
|
1797
|
+
settings: {
|
|
1798
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1799
|
+
reasoning_effort: 'medium',
|
|
1800
|
+
developer_instructions: null,
|
|
1801
|
+
},
|
|
1802
|
+
});
|
|
1803
|
+
|
|
1804
|
+
rt.cleanup();
|
|
1805
|
+
});
|
|
1806
|
+
|
|
1807
|
+
it('includes default collaborationMode when permissionMode is yolo', async () => {
|
|
1808
|
+
const plugin = createMockPlugin({ permissionMode: 'yolo' });
|
|
1809
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1810
|
+
captureHandlers();
|
|
1811
|
+
setupDefaultRequestMock();
|
|
1812
|
+
|
|
1813
|
+
await collectChunks(rt.query(createTurn('hello')));
|
|
1814
|
+
|
|
1815
|
+
const turnStartCall = findCall('turn/start');
|
|
1816
|
+
expect(turnStartCall).toBeDefined();
|
|
1817
|
+
expect(turnStartCall[1].collaborationMode).toEqual({
|
|
1818
|
+
mode: 'default',
|
|
1819
|
+
settings: {
|
|
1820
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1821
|
+
reasoning_effort: 'medium',
|
|
1822
|
+
developer_instructions: null,
|
|
1823
|
+
},
|
|
1824
|
+
});
|
|
1825
|
+
|
|
1826
|
+
rt.cleanup();
|
|
1827
|
+
});
|
|
1828
|
+
|
|
1829
|
+
it('sends default collaborationMode after switching out of plan mode on the same thread', async () => {
|
|
1830
|
+
const plugin = createMockPlugin({ permissionMode: 'plan' });
|
|
1831
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1832
|
+
captureHandlers();
|
|
1833
|
+
setupDefaultRequestMock();
|
|
1834
|
+
|
|
1835
|
+
await collectChunks(rt.query(createTurn('plan this')));
|
|
1836
|
+
|
|
1837
|
+
plugin.settings.permissionMode = 'normal';
|
|
1838
|
+
await collectChunks(rt.query(createTurn('now edit')));
|
|
1839
|
+
|
|
1840
|
+
const turnStartCalls = mockTransportRequest.mock.calls.filter(
|
|
1841
|
+
(call: any[]) => call[0] === 'turn/start',
|
|
1842
|
+
);
|
|
1843
|
+
expect(turnStartCalls).toHaveLength(2);
|
|
1844
|
+
expect(turnStartCalls[0][1].collaborationMode).toEqual({
|
|
1845
|
+
mode: 'plan',
|
|
1846
|
+
settings: {
|
|
1847
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1848
|
+
reasoning_effort: 'medium',
|
|
1849
|
+
developer_instructions: null,
|
|
1850
|
+
},
|
|
1851
|
+
});
|
|
1852
|
+
expect(turnStartCalls[1][1].collaborationMode).toEqual({
|
|
1853
|
+
mode: 'default',
|
|
1854
|
+
settings: {
|
|
1855
|
+
model: DEFAULT_CODEX_PRIMARY_MODEL,
|
|
1856
|
+
reasoning_effort: 'medium',
|
|
1857
|
+
developer_instructions: null,
|
|
1858
|
+
},
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
rt.cleanup();
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it('configures router beginTurn before turn/start so buffered notifications see plan state', async () => {
|
|
1865
|
+
const plugin = createMockPlugin({ permissionMode: 'plan' });
|
|
1866
|
+
const rt = new CodexChatRuntime(plugin);
|
|
1867
|
+
captureHandlers();
|
|
1868
|
+
|
|
1869
|
+
// Intercept the turn/start request to verify router state was set before it
|
|
1870
|
+
let routerBeginCalledBeforeTurnStart = false;
|
|
1871
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1872
|
+
if (method === 'initialize') return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
1873
|
+
if (method === 'thread/start') return threadStartResponse('thread-plan');
|
|
1874
|
+
if (method === 'turn/start') {
|
|
1875
|
+
// Access the router via the runtime's private field to check beginTurn was called
|
|
1876
|
+
const router = (rt as any).notificationRouter;
|
|
1877
|
+
if (router && router.isPlanTurn === true) {
|
|
1878
|
+
routerBeginCalledBeforeTurnStart = true;
|
|
1879
|
+
}
|
|
1880
|
+
setTimeout(() => {
|
|
1881
|
+
emitNotification('turn/completed', {
|
|
1882
|
+
threadId: 'thread-plan',
|
|
1883
|
+
turn: { id: 'turn-plan', items: [], status: 'completed', error: null },
|
|
1884
|
+
});
|
|
1885
|
+
}, 0);
|
|
1886
|
+
return turnStartResponse('turn-plan');
|
|
1887
|
+
}
|
|
1888
|
+
return {};
|
|
1889
|
+
});
|
|
1890
|
+
|
|
1891
|
+
await collectChunks(rt.query(createTurn('plan it')));
|
|
1892
|
+
expect(routerBeginCalledBeforeTurnStart).toBe(true);
|
|
1893
|
+
|
|
1894
|
+
rt.cleanup();
|
|
1895
|
+
});
|
|
1896
|
+
});
|
|
1897
|
+
|
|
1898
|
+
describe('query - pending fork lifecycle', () => {
|
|
1899
|
+
it('syncConversationState with forkSource sets pending fork without setting session', () => {
|
|
1900
|
+
runtime.syncConversationState({
|
|
1901
|
+
sessionId: null,
|
|
1902
|
+
providerState: { forkSource: { sessionId: 'source-thread', resumeAt: 'turn-uuid-2' } },
|
|
1903
|
+
});
|
|
1904
|
+
|
|
1905
|
+
// Session should not be set to the source thread
|
|
1906
|
+
expect(runtime.getSessionId()).toBeNull();
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
it('first query with pending fork issues fork + resume + rollback + turn/start', async () => {
|
|
1910
|
+
runtime.syncConversationState({
|
|
1911
|
+
sessionId: null,
|
|
1912
|
+
providerState: { forkSource: { sessionId: 'source-thread', resumeAt: 'turn-uuid-2' } },
|
|
1913
|
+
});
|
|
1914
|
+
|
|
1915
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1916
|
+
switch (method) {
|
|
1917
|
+
case 'initialize':
|
|
1918
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
1919
|
+
case 'thread/fork': {
|
|
1920
|
+
const resp = threadStartResponse('fork-thread-1');
|
|
1921
|
+
resp.thread.turns = [
|
|
1922
|
+
{ id: 'turn-uuid-1', items: [], status: 'completed', error: null },
|
|
1923
|
+
{ id: 'turn-uuid-2', items: [], status: 'completed', error: null },
|
|
1924
|
+
{ id: 'turn-uuid-3', items: [], status: 'completed', error: null },
|
|
1925
|
+
];
|
|
1926
|
+
return resp;
|
|
1927
|
+
}
|
|
1928
|
+
case 'thread/resume':
|
|
1929
|
+
return threadStartResponse('fork-thread-1');
|
|
1930
|
+
case 'thread/rollback':
|
|
1931
|
+
return { thread: { ...threadStartResponse('fork-thread-1').thread, turns: [] } };
|
|
1932
|
+
case 'turn/start':
|
|
1933
|
+
setTimeout(() => {
|
|
1934
|
+
emitNotification('item/agentMessage/delta', {
|
|
1935
|
+
threadId: 'fork-thread-1', turnId: 'fork-turn-1', itemId: 'msg1', delta: 'Forked reply',
|
|
1936
|
+
});
|
|
1937
|
+
emitNotification('turn/completed', {
|
|
1938
|
+
threadId: 'fork-thread-1',
|
|
1939
|
+
turn: { id: 'fork-turn-1', items: [], status: 'completed', error: null },
|
|
1940
|
+
});
|
|
1941
|
+
}, 0);
|
|
1942
|
+
return turnStartResponse('fork-turn-1');
|
|
1943
|
+
default:
|
|
1944
|
+
return {};
|
|
1945
|
+
}
|
|
1946
|
+
});
|
|
1947
|
+
|
|
1948
|
+
captureHandlers();
|
|
1949
|
+
const chunks = await collectChunks(runtime.query(createTurn('forked input')));
|
|
1950
|
+
|
|
1951
|
+
// Verify request sequence: fork, resume, rollback, turn/start
|
|
1952
|
+
const calls = mockTransportRequest.mock.calls.map((c: any[]) => c[0]);
|
|
1953
|
+
const lifecycle = calls.filter((m: string) =>
|
|
1954
|
+
['thread/fork', 'thread/resume', 'thread/rollback', 'turn/start'].includes(m),
|
|
1955
|
+
);
|
|
1956
|
+
expect(lifecycle).toEqual(['thread/fork', 'thread/resume', 'thread/rollback', 'turn/start']);
|
|
1957
|
+
|
|
1958
|
+
// Verify fork params
|
|
1959
|
+
const forkCall = findCall('thread/fork');
|
|
1960
|
+
expect(forkCall[1].threadId).toBe('source-thread');
|
|
1961
|
+
|
|
1962
|
+
// Verify resume params
|
|
1963
|
+
const resumeCall = findCall('thread/resume');
|
|
1964
|
+
expect(resumeCall[1].threadId).toBe('fork-thread-1');
|
|
1965
|
+
|
|
1966
|
+
// Verify rollback params (1 turn after checkpoint: turn-uuid-3)
|
|
1967
|
+
const rollbackCall = findCall('thread/rollback');
|
|
1968
|
+
expect(rollbackCall[1].threadId).toBe('fork-thread-1');
|
|
1969
|
+
expect(rollbackCall[1].numTurns).toBe(1);
|
|
1970
|
+
|
|
1971
|
+
expect(chunks).toContainEqual({ type: 'text', content: 'Forked reply' });
|
|
1972
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
1973
|
+
|
|
1974
|
+
// After fork, session should be the fork thread
|
|
1975
|
+
expect(runtime.getSessionId()).toBe('fork-thread-1');
|
|
1976
|
+
});
|
|
1977
|
+
|
|
1978
|
+
it('skips rollback when resumeAt is the last turn', async () => {
|
|
1979
|
+
runtime.syncConversationState({
|
|
1980
|
+
sessionId: null,
|
|
1981
|
+
providerState: { forkSource: { sessionId: 'source-thread-2', resumeAt: 'turn-uuid-last' } },
|
|
1982
|
+
});
|
|
1983
|
+
|
|
1984
|
+
const requestSequence: string[] = [];
|
|
1985
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
1986
|
+
requestSequence.push(method);
|
|
1987
|
+
switch (method) {
|
|
1988
|
+
case 'initialize':
|
|
1989
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
1990
|
+
case 'thread/fork': {
|
|
1991
|
+
const resp = threadStartResponse('fork-no-rb');
|
|
1992
|
+
resp.thread.turns = [
|
|
1993
|
+
{ id: 'turn-uuid-first', items: [], status: 'completed', error: null },
|
|
1994
|
+
{ id: 'turn-uuid-last', items: [], status: 'completed', error: null },
|
|
1995
|
+
];
|
|
1996
|
+
return resp;
|
|
1997
|
+
}
|
|
1998
|
+
case 'thread/resume':
|
|
1999
|
+
return threadStartResponse('fork-no-rb');
|
|
2000
|
+
case 'turn/start':
|
|
2001
|
+
setTimeout(() => {
|
|
2002
|
+
emitNotification('turn/completed', {
|
|
2003
|
+
threadId: 'fork-no-rb',
|
|
2004
|
+
turn: { id: 'fork-turn-nr', items: [], status: 'completed', error: null },
|
|
2005
|
+
});
|
|
2006
|
+
}, 0);
|
|
2007
|
+
return turnStartResponse('fork-turn-nr');
|
|
2008
|
+
default:
|
|
2009
|
+
return {};
|
|
2010
|
+
}
|
|
2011
|
+
});
|
|
2012
|
+
|
|
2013
|
+
captureHandlers();
|
|
2014
|
+
await collectChunks(runtime.query(createTurn('no rollback needed')));
|
|
2015
|
+
|
|
2016
|
+
// Should NOT have called thread/rollback
|
|
2017
|
+
expect(requestSequence).not.toContain('thread/rollback');
|
|
2018
|
+
});
|
|
2019
|
+
|
|
2020
|
+
it('retries the pending fork instead of starting a fresh thread after a fork failure', async () => {
|
|
2021
|
+
runtime.syncConversationState({
|
|
2022
|
+
sessionId: null,
|
|
2023
|
+
providerState: { forkSource: { sessionId: 'source-thread-retry', resumeAt: 'turn-uuid-2' } },
|
|
2024
|
+
});
|
|
2025
|
+
|
|
2026
|
+
let forkAttempts = 0;
|
|
2027
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
2028
|
+
switch (method) {
|
|
2029
|
+
case 'initialize':
|
|
2030
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
2031
|
+
case 'thread/fork':
|
|
2032
|
+
forkAttempts += 1;
|
|
2033
|
+
if (forkAttempts === 1) {
|
|
2034
|
+
throw new Error('fork failed');
|
|
2035
|
+
}
|
|
2036
|
+
return {
|
|
2037
|
+
...threadStartResponse('fork-thread-retry'),
|
|
2038
|
+
thread: {
|
|
2039
|
+
...threadStartResponse('fork-thread-retry').thread,
|
|
2040
|
+
turns: [
|
|
2041
|
+
{ id: 'turn-uuid-1', items: [], status: 'completed', error: null },
|
|
2042
|
+
{ id: 'turn-uuid-2', items: [], status: 'completed', error: null },
|
|
2043
|
+
],
|
|
2044
|
+
},
|
|
2045
|
+
};
|
|
2046
|
+
case 'thread/resume':
|
|
2047
|
+
return threadStartResponse('fork-thread-retry');
|
|
2048
|
+
case 'turn/start':
|
|
2049
|
+
setTimeout(() => {
|
|
2050
|
+
emitNotification('turn/completed', {
|
|
2051
|
+
threadId: 'fork-thread-retry',
|
|
2052
|
+
turn: { id: 'fork-turn-retry', items: [], status: 'completed', error: null },
|
|
2053
|
+
});
|
|
2054
|
+
}, 0);
|
|
2055
|
+
return turnStartResponse('fork-turn-retry');
|
|
2056
|
+
default:
|
|
2057
|
+
return {};
|
|
2058
|
+
}
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
captureHandlers();
|
|
2062
|
+
const firstAttemptChunks = await collectChunks(runtime.query(createTurn('first attempt')));
|
|
2063
|
+
|
|
2064
|
+
expect(firstAttemptChunks).toContainEqual({ type: 'error', content: 'fork failed' });
|
|
2065
|
+
expect(runtime.getSessionId()).toBeNull();
|
|
2066
|
+
|
|
2067
|
+
mockTransportRequest.mockClear();
|
|
2068
|
+
captureHandlers();
|
|
2069
|
+
const retryChunks = await collectChunks(runtime.query(createTurn('retry after fork failure')));
|
|
2070
|
+
|
|
2071
|
+
const retryLifecycle = mockTransportRequest.mock.calls
|
|
2072
|
+
.map((call: any[]) => call[0])
|
|
2073
|
+
.filter((method: string) => ['thread/fork', 'thread/resume', 'thread/start', 'turn/start'].includes(method));
|
|
2074
|
+
|
|
2075
|
+
expect(retryLifecycle).toEqual(['thread/fork', 'thread/resume', 'turn/start']);
|
|
2076
|
+
expect(retryChunks).toContainEqual({ type: 'done' });
|
|
2077
|
+
expect(runtime.getSessionId()).toBe('fork-thread-retry');
|
|
2078
|
+
});
|
|
2079
|
+
|
|
2080
|
+
it('fails the fork when the resumeAt checkpoint is missing from the fork result', async () => {
|
|
2081
|
+
runtime.syncConversationState({
|
|
2082
|
+
sessionId: null,
|
|
2083
|
+
providerState: { forkSource: { sessionId: 'source-thread-missing', resumeAt: 'turn-uuid-missing' } },
|
|
2084
|
+
});
|
|
2085
|
+
|
|
2086
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
2087
|
+
switch (method) {
|
|
2088
|
+
case 'initialize':
|
|
2089
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
2090
|
+
case 'thread/fork':
|
|
2091
|
+
return {
|
|
2092
|
+
...threadStartResponse('fork-thread-missing'),
|
|
2093
|
+
thread: {
|
|
2094
|
+
...threadStartResponse('fork-thread-missing').thread,
|
|
2095
|
+
turns: [
|
|
2096
|
+
{ id: 'turn-uuid-1', items: [], status: 'completed', error: null },
|
|
2097
|
+
{ id: 'turn-uuid-2', items: [], status: 'completed', error: null },
|
|
2098
|
+
],
|
|
2099
|
+
},
|
|
2100
|
+
};
|
|
2101
|
+
default:
|
|
2102
|
+
return {};
|
|
2103
|
+
}
|
|
2104
|
+
});
|
|
2105
|
+
|
|
2106
|
+
captureHandlers();
|
|
2107
|
+
const chunks = await collectChunks(runtime.query(createTurn('fork with missing checkpoint')));
|
|
2108
|
+
|
|
2109
|
+
expect(chunks).toContainEqual({
|
|
2110
|
+
type: 'error',
|
|
2111
|
+
content: 'Fork checkpoint not found: turn-uuid-missing',
|
|
2112
|
+
});
|
|
2113
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
2114
|
+
|
|
2115
|
+
const methods = mockTransportRequest.mock.calls.map((call: any[]) => call[0]);
|
|
2116
|
+
expect(methods).toContain('thread/fork');
|
|
2117
|
+
expect(methods).not.toContain('thread/resume');
|
|
2118
|
+
expect(methods).not.toContain('turn/start');
|
|
2119
|
+
expect(runtime.getSessionId()).toBeNull();
|
|
2120
|
+
});
|
|
2121
|
+
|
|
2122
|
+
it('buildSessionUpdates preserves forkSource after fork thread established', async () => {
|
|
2123
|
+
// Simulate an established fork conversation
|
|
2124
|
+
runtime.syncConversationState({
|
|
2125
|
+
sessionId: null,
|
|
2126
|
+
providerState: {
|
|
2127
|
+
forkSource: { sessionId: 'source-thread', resumeAt: 'turn-uuid-2' },
|
|
2128
|
+
forkSourceSessionFilePath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\source-thread.jsonl',
|
|
2129
|
+
forkSourceTranscriptRootPath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions',
|
|
2130
|
+
},
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
mockTransportRequest.mockImplementation(async (method: string) => {
|
|
2134
|
+
switch (method) {
|
|
2135
|
+
case 'initialize':
|
|
2136
|
+
return { userAgent: 'test/0.1', codexHome: '/tmp', platformFamily: 'unix', platformOs: 'macos' };
|
|
2137
|
+
case 'thread/fork': {
|
|
2138
|
+
const resp = threadStartResponse('fork-established');
|
|
2139
|
+
resp.thread.turns = [
|
|
2140
|
+
{ id: 'turn-uuid-1', items: [], status: 'completed', error: null },
|
|
2141
|
+
{ id: 'turn-uuid-2', items: [], status: 'completed', error: null },
|
|
2142
|
+
];
|
|
2143
|
+
return resp;
|
|
2144
|
+
}
|
|
2145
|
+
case 'thread/resume':
|
|
2146
|
+
return threadStartResponse('fork-established');
|
|
2147
|
+
case 'turn/start':
|
|
2148
|
+
setTimeout(() => {
|
|
2149
|
+
emitNotification('turn/completed', {
|
|
2150
|
+
threadId: 'fork-established',
|
|
2151
|
+
turn: { id: 'fork-t1', items: [], status: 'completed', error: null },
|
|
2152
|
+
});
|
|
2153
|
+
}, 0);
|
|
2154
|
+
return turnStartResponse('fork-t1');
|
|
2155
|
+
default:
|
|
2156
|
+
return {};
|
|
2157
|
+
}
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
captureHandlers();
|
|
2161
|
+
await collectChunks(runtime.query(createTurn('first fork turn')));
|
|
2162
|
+
|
|
2163
|
+
const result = runtime.buildSessionUpdates({
|
|
2164
|
+
conversation: {
|
|
2165
|
+
id: 'conv-1',
|
|
2166
|
+
providerId: 'codex',
|
|
2167
|
+
title: 'Fork',
|
|
2168
|
+
createdAt: 0,
|
|
2169
|
+
updatedAt: 0,
|
|
2170
|
+
sessionId: null,
|
|
2171
|
+
messages: [],
|
|
2172
|
+
providerState: {
|
|
2173
|
+
forkSource: { sessionId: 'source-thread', resumeAt: 'turn-uuid-2' },
|
|
2174
|
+
forkSourceSessionFilePath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\source-thread.jsonl',
|
|
2175
|
+
forkSourceTranscriptRootPath: '\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions',
|
|
2176
|
+
},
|
|
2177
|
+
},
|
|
2178
|
+
sessionInvalidated: false,
|
|
2179
|
+
});
|
|
2180
|
+
|
|
2181
|
+
expect((result.updates.providerState as any).threadId).toBe('fork-established');
|
|
2182
|
+
expect((result.updates.providerState as any).forkSource).toEqual({
|
|
2183
|
+
sessionId: 'source-thread',
|
|
2184
|
+
resumeAt: 'turn-uuid-2',
|
|
2185
|
+
});
|
|
2186
|
+
expect((result.updates.providerState as any).forkSourceSessionFilePath).toBe(
|
|
2187
|
+
'\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions\\source-thread.jsonl',
|
|
2188
|
+
);
|
|
2189
|
+
expect((result.updates.providerState as any).forkSourceTranscriptRootPath).toBe(
|
|
2190
|
+
'\\\\wsl$\\Ubuntu\\home\\user\\.codex\\sessions',
|
|
2191
|
+
);
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
it('resetSession clears pending fork', () => {
|
|
2195
|
+
runtime.syncConversationState({
|
|
2196
|
+
sessionId: null,
|
|
2197
|
+
providerState: { forkSource: { sessionId: 'source', resumeAt: 'turn-1' } },
|
|
2198
|
+
});
|
|
2199
|
+
|
|
2200
|
+
runtime.resetSession();
|
|
2201
|
+
|
|
2202
|
+
// After reset, a normal query should start a new thread (not fork)
|
|
2203
|
+
expect(runtime.getSessionId()).toBeNull();
|
|
2204
|
+
});
|
|
2205
|
+
});
|
|
2206
|
+
|
|
2207
|
+
describe('query - manual compact', () => {
|
|
2208
|
+
it('calls thread/compact/start instead of turn/start for compact turns', async () => {
|
|
2209
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2210
|
+
'thread/start': () => threadStartResponse('thread-compact'),
|
|
2211
|
+
'thread/compact/start': () => {
|
|
2212
|
+
setTimeout(() => {
|
|
2213
|
+
emitNotification('turn/started', {
|
|
2214
|
+
threadId: 'thread-compact',
|
|
2215
|
+
turn: { id: 'turn-compact', items: [], status: 'inProgress', error: null },
|
|
2216
|
+
});
|
|
2217
|
+
emitNotification('item/started', {
|
|
2218
|
+
item: { type: 'contextCompaction', id: 'compact-1' },
|
|
2219
|
+
threadId: 'thread-compact',
|
|
2220
|
+
turnId: 'turn-compact',
|
|
2221
|
+
});
|
|
2222
|
+
emitNotification('item/completed', {
|
|
2223
|
+
item: { type: 'contextCompaction', id: 'compact-1' },
|
|
2224
|
+
threadId: 'thread-compact',
|
|
2225
|
+
turnId: 'turn-compact',
|
|
2226
|
+
});
|
|
2227
|
+
emitNotification('turn/completed', {
|
|
2228
|
+
threadId: 'thread-compact',
|
|
2229
|
+
turn: { id: 'turn-compact', items: [], status: 'completed', error: null },
|
|
2230
|
+
});
|
|
2231
|
+
}, 0);
|
|
2232
|
+
return {};
|
|
2233
|
+
},
|
|
2234
|
+
}));
|
|
2235
|
+
|
|
2236
|
+
const chunks = await collectChunks(runtime.query(createCompactTurn()));
|
|
2237
|
+
|
|
2238
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
2239
|
+
'thread/compact/start',
|
|
2240
|
+
{ threadId: 'thread-compact' },
|
|
2241
|
+
);
|
|
2242
|
+
const turnStartCall = findCall('turn/start');
|
|
2243
|
+
expect(turnStartCall).toBeUndefined();
|
|
2244
|
+
|
|
2245
|
+
expect(chunks).toContainEqual({ type: 'context_compacted' });
|
|
2246
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it('creates a new thread first if none exists, then compacts', async () => {
|
|
2250
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2251
|
+
'thread/start': () => threadStartResponse('thread-new-compact'),
|
|
2252
|
+
'thread/compact/start': () => {
|
|
2253
|
+
setTimeout(() => {
|
|
2254
|
+
emitNotification('turn/started', {
|
|
2255
|
+
threadId: 'thread-new-compact',
|
|
2256
|
+
turn: { id: 'turn-c', items: [], status: 'inProgress', error: null },
|
|
2257
|
+
});
|
|
2258
|
+
emitNotification('item/started', {
|
|
2259
|
+
item: { type: 'contextCompaction', id: 'compact-2' },
|
|
2260
|
+
threadId: 'thread-new-compact',
|
|
2261
|
+
turnId: 'turn-c',
|
|
2262
|
+
});
|
|
2263
|
+
emitNotification('turn/completed', {
|
|
2264
|
+
threadId: 'thread-new-compact',
|
|
2265
|
+
turn: { id: 'turn-c', items: [], status: 'completed', error: null },
|
|
2266
|
+
});
|
|
2267
|
+
}, 0);
|
|
2268
|
+
return {};
|
|
2269
|
+
},
|
|
2270
|
+
}));
|
|
2271
|
+
|
|
2272
|
+
await collectChunks(runtime.query(createCompactTurn()));
|
|
2273
|
+
|
|
2274
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
2275
|
+
'thread/start',
|
|
2276
|
+
expect.any(Object),
|
|
2277
|
+
);
|
|
2278
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
2279
|
+
'thread/compact/start',
|
|
2280
|
+
{ threadId: 'thread-new-compact' },
|
|
2281
|
+
);
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it('rejects /compact with extra arguments locally', async () => {
|
|
2285
|
+
const turn = createTurn('/compact extra args', { isCompact: true });
|
|
2286
|
+
|
|
2287
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({}));
|
|
2288
|
+
|
|
2289
|
+
const chunks = await collectChunks(runtime.query(turn));
|
|
2290
|
+
|
|
2291
|
+
expect(chunks).toContainEqual(expect.objectContaining({
|
|
2292
|
+
type: 'error',
|
|
2293
|
+
content: expect.stringContaining('/compact'),
|
|
2294
|
+
}));
|
|
2295
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
2296
|
+
|
|
2297
|
+
const compactCall = findCall('thread/compact/start');
|
|
2298
|
+
expect(compactCall).toBeUndefined();
|
|
2299
|
+
|
|
2300
|
+
const threadStartCall = findCall('thread/start');
|
|
2301
|
+
expect(threadStartCall).toBeUndefined();
|
|
2302
|
+
expect(runtime.getSessionId()).toBeNull();
|
|
2303
|
+
});
|
|
2304
|
+
|
|
2305
|
+
it('does not call buildInput for compact', async () => {
|
|
2306
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2307
|
+
'thread/start': () => threadStartResponse('thread-no-input'),
|
|
2308
|
+
'thread/compact/start': () => {
|
|
2309
|
+
setTimeout(() => {
|
|
2310
|
+
emitNotification('turn/started', {
|
|
2311
|
+
threadId: 'thread-no-input',
|
|
2312
|
+
turn: { id: 'turn-ni', items: [], status: 'inProgress', error: null },
|
|
2313
|
+
});
|
|
2314
|
+
emitNotification('turn/completed', {
|
|
2315
|
+
threadId: 'thread-no-input',
|
|
2316
|
+
turn: { id: 'turn-ni', items: [], status: 'completed', error: null },
|
|
2317
|
+
});
|
|
2318
|
+
}, 0);
|
|
2319
|
+
return {};
|
|
2320
|
+
},
|
|
2321
|
+
}));
|
|
2322
|
+
|
|
2323
|
+
await collectChunks(runtime.query(createCompactTurn()));
|
|
2324
|
+
|
|
2325
|
+
// turn/start was never called, which means buildInput was never called
|
|
2326
|
+
const turnStartCall = findCall('turn/start');
|
|
2327
|
+
expect(turnStartCall).toBeUndefined();
|
|
2328
|
+
});
|
|
2329
|
+
|
|
2330
|
+
it('preserves cancel semantics: cancel before turn/started does not crash', async () => {
|
|
2331
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2332
|
+
'thread/start': () => threadStartResponse('thread-cancel-compact'),
|
|
2333
|
+
// Don't emit turn/started - simulating cancel before it arrives
|
|
2334
|
+
'thread/compact/start': () => ({}),
|
|
2335
|
+
'turn/interrupt': () => ({}),
|
|
2336
|
+
}));
|
|
2337
|
+
|
|
2338
|
+
const gen = runtime.query(createCompactTurn());
|
|
2339
|
+
const firstResult = gen.next();
|
|
2340
|
+
await new Promise(r => setTimeout(r, 50));
|
|
2341
|
+
|
|
2342
|
+
runtime.cancel();
|
|
2343
|
+
|
|
2344
|
+
const chunks: StreamChunk[] = [];
|
|
2345
|
+
const first = await firstResult;
|
|
2346
|
+
if (!first.done && first.value) chunks.push(first.value);
|
|
2347
|
+
for await (const chunk of gen) chunks.push(chunk);
|
|
2348
|
+
|
|
2349
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
2350
|
+
});
|
|
2351
|
+
|
|
2352
|
+
it('preserves cancel semantics: cancel after turn/started sends turn/interrupt', async () => {
|
|
2353
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2354
|
+
'thread/start': () => threadStartResponse('thread-cc2'),
|
|
2355
|
+
'thread/compact/start': () => {
|
|
2356
|
+
setTimeout(() => {
|
|
2357
|
+
emitNotification('turn/started', {
|
|
2358
|
+
threadId: 'thread-cc2',
|
|
2359
|
+
turn: { id: 'turn-cc2', items: [], status: 'inProgress', error: null },
|
|
2360
|
+
});
|
|
2361
|
+
}, 0);
|
|
2362
|
+
return {};
|
|
2363
|
+
},
|
|
2364
|
+
'turn/interrupt': () => ({}),
|
|
2365
|
+
}));
|
|
2366
|
+
|
|
2367
|
+
const gen = runtime.query(createCompactTurn());
|
|
2368
|
+
const firstResult = gen.next();
|
|
2369
|
+
await new Promise(r => setTimeout(r, 50));
|
|
2370
|
+
|
|
2371
|
+
runtime.cancel();
|
|
2372
|
+
|
|
2373
|
+
const chunks: StreamChunk[] = [];
|
|
2374
|
+
const first = await firstResult;
|
|
2375
|
+
if (!first.done && first.value) chunks.push(first.value);
|
|
2376
|
+
for await (const chunk of gen) chunks.push(chunk);
|
|
2377
|
+
|
|
2378
|
+
expect(mockTransportRequest).toHaveBeenCalledWith(
|
|
2379
|
+
'turn/interrupt',
|
|
2380
|
+
{ threadId: 'thread-cc2', turnId: 'turn-cc2' },
|
|
2381
|
+
);
|
|
2382
|
+
});
|
|
2383
|
+
|
|
2384
|
+
it('captures thread ID after compact on a new thread', async () => {
|
|
2385
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2386
|
+
'thread/start': () => threadStartResponse('thread-persist'),
|
|
2387
|
+
'thread/compact/start': () => {
|
|
2388
|
+
setTimeout(() => {
|
|
2389
|
+
emitNotification('turn/started', {
|
|
2390
|
+
threadId: 'thread-persist',
|
|
2391
|
+
turn: { id: 'turn-p', items: [], status: 'inProgress', error: null },
|
|
2392
|
+
});
|
|
2393
|
+
emitNotification('turn/completed', {
|
|
2394
|
+
threadId: 'thread-persist',
|
|
2395
|
+
turn: { id: 'turn-p', items: [], status: 'completed', error: null },
|
|
2396
|
+
});
|
|
2397
|
+
}, 0);
|
|
2398
|
+
return {};
|
|
2399
|
+
},
|
|
2400
|
+
}));
|
|
2401
|
+
|
|
2402
|
+
await collectChunks(runtime.query(createCompactTurn()));
|
|
2403
|
+
|
|
2404
|
+
expect(runtime.getSessionId()).toBe('thread-persist');
|
|
2405
|
+
const result = runtime.buildSessionUpdates({ conversation: null, sessionInvalidated: false });
|
|
2406
|
+
expect((result.updates.providerState as any).threadId).toBe('thread-persist');
|
|
2407
|
+
});
|
|
2408
|
+
});
|
|
2409
|
+
|
|
2410
|
+
describe('turn/started notification establishes turn ID', () => {
|
|
2411
|
+
it('establishes turn ID from turn/started and flushes buffered notifications', async () => {
|
|
2412
|
+
mockTransportRequest.mockImplementation(buildRequestHandler({
|
|
2413
|
+
'thread/start': () => threadStartResponse('thread-ts'),
|
|
2414
|
+
'thread/compact/start': () => {
|
|
2415
|
+
// Simulate: turn/started arrives first, then items, then turn/completed
|
|
2416
|
+
setTimeout(() => {
|
|
2417
|
+
// Item arrives BEFORE turn/started — gets buffered
|
|
2418
|
+
emitNotification('item/agentMessage/delta', {
|
|
2419
|
+
threadId: 'thread-ts',
|
|
2420
|
+
turnId: 'turn-ts',
|
|
2421
|
+
itemId: 'msg-ts',
|
|
2422
|
+
delta: 'Buffered text',
|
|
2423
|
+
});
|
|
2424
|
+
// turn/started arrives and establishes the turn ID
|
|
2425
|
+
emitNotification('turn/started', {
|
|
2426
|
+
threadId: 'thread-ts',
|
|
2427
|
+
turn: { id: 'turn-ts', items: [], status: 'inProgress', error: null },
|
|
2428
|
+
});
|
|
2429
|
+
emitNotification('turn/completed', {
|
|
2430
|
+
threadId: 'thread-ts',
|
|
2431
|
+
turn: { id: 'turn-ts', items: [], status: 'completed', error: null },
|
|
2432
|
+
});
|
|
2433
|
+
}, 0);
|
|
2434
|
+
return {};
|
|
2435
|
+
},
|
|
2436
|
+
}));
|
|
2437
|
+
|
|
2438
|
+
const chunks = await collectChunks(runtime.query(createCompactTurn()));
|
|
2439
|
+
|
|
2440
|
+
// The buffered text should have been flushed after turn/started
|
|
2441
|
+
expect(chunks).toContainEqual({ type: 'text', content: 'Buffered text' });
|
|
2442
|
+
expect(chunks).toContainEqual({ type: 'done' });
|
|
2443
|
+
});
|
|
2444
|
+
});
|
|
2445
|
+
});
|