@gokulvenkatareddy/cortex 0.1.7
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/README.md +1295 -0
- package/apps/octogent/.github/workflows/ci.yml +40 -0
- package/apps/octogent/.shims/claude +4 -0
- package/apps/octogent/AGENTS.md +71 -0
- package/apps/octogent/CONTRIBUTING.md +72 -0
- package/apps/octogent/LICENSE +21 -0
- package/apps/octogent/README.md +184 -0
- package/apps/octogent/apps/api/AGENTS.md +32 -0
- package/apps/octogent/apps/api/package.json +19 -0
- package/apps/octogent/apps/api/src/agentStateDetection.ts +181 -0
- package/apps/octogent/apps/api/src/claudeSessionScanner.ts +235 -0
- package/apps/octogent/apps/api/src/claudeSkills.ts +182 -0
- package/apps/octogent/apps/api/src/claudeUsage.ts +922 -0
- package/apps/octogent/apps/api/src/cli.ts +595 -0
- package/apps/octogent/apps/api/src/codeIntelStore.ts +46 -0
- package/apps/octogent/apps/api/src/codexUsage.ts +278 -0
- package/apps/octogent/apps/api/src/createApiServer/codeIntelRoutes.ts +60 -0
- package/apps/octogent/apps/api/src/createApiServer/conversationRoutes.ts +128 -0
- package/apps/octogent/apps/api/src/createApiServer/deckRoutes.ts +873 -0
- package/apps/octogent/apps/api/src/createApiServer/gitParsers.ts +140 -0
- package/apps/octogent/apps/api/src/createApiServer/gitRoutes.ts +214 -0
- package/apps/octogent/apps/api/src/createApiServer/miscRoutes.ts +316 -0
- package/apps/octogent/apps/api/src/createApiServer/monitorParsers.ts +137 -0
- package/apps/octogent/apps/api/src/createApiServer/monitorRoutes.ts +95 -0
- package/apps/octogent/apps/api/src/createApiServer/requestHandler.ts +311 -0
- package/apps/octogent/apps/api/src/createApiServer/requestParsers.ts +25 -0
- package/apps/octogent/apps/api/src/createApiServer/routeHelpers.ts +97 -0
- package/apps/octogent/apps/api/src/createApiServer/security.ts +70 -0
- package/apps/octogent/apps/api/src/createApiServer/terminalParsers.ts +167 -0
- package/apps/octogent/apps/api/src/createApiServer/terminalRoutes.ts +315 -0
- package/apps/octogent/apps/api/src/createApiServer/types.ts +24 -0
- package/apps/octogent/apps/api/src/createApiServer/uiStateParsers.ts +255 -0
- package/apps/octogent/apps/api/src/createApiServer/upgradeHandler.ts +38 -0
- package/apps/octogent/apps/api/src/createApiServer/usageRoutes.ts +84 -0
- package/apps/octogent/apps/api/src/createApiServer.ts +176 -0
- package/apps/octogent/apps/api/src/deck/readDeckTentacles.ts +595 -0
- package/apps/octogent/apps/api/src/githubRepoSummary.ts +397 -0
- package/apps/octogent/apps/api/src/logging.ts +9 -0
- package/apps/octogent/apps/api/src/monitor/defaults.ts +3 -0
- package/apps/octogent/apps/api/src/monitor/index.ts +8 -0
- package/apps/octogent/apps/api/src/monitor/repository.ts +303 -0
- package/apps/octogent/apps/api/src/monitor/service.ts +349 -0
- package/apps/octogent/apps/api/src/monitor/types.ts +120 -0
- package/apps/octogent/apps/api/src/monitor/xProvider.ts +587 -0
- package/apps/octogent/apps/api/src/projectPersistence.ts +377 -0
- package/apps/octogent/apps/api/src/prompts/index.ts +10 -0
- package/apps/octogent/apps/api/src/prompts/promptResolver.ts +145 -0
- package/apps/octogent/apps/api/src/runtimeMetadata.ts +69 -0
- package/apps/octogent/apps/api/src/server.ts +80 -0
- package/apps/octogent/apps/api/src/setupState.ts +80 -0
- package/apps/octogent/apps/api/src/setupStatus.ts +174 -0
- package/apps/octogent/apps/api/src/startupPrerequisites.ts +146 -0
- package/apps/octogent/apps/api/src/terminalRuntime/channelMessaging.ts +87 -0
- package/apps/octogent/apps/api/src/terminalRuntime/claudeTranscript.ts +279 -0
- package/apps/octogent/apps/api/src/terminalRuntime/constants.ts +15 -0
- package/apps/octogent/apps/api/src/terminalRuntime/conversations.ts +492 -0
- package/apps/octogent/apps/api/src/terminalRuntime/gitOperations.ts +341 -0
- package/apps/octogent/apps/api/src/terminalRuntime/hookProcessor.ts +405 -0
- package/apps/octogent/apps/api/src/terminalRuntime/protocol.ts +46 -0
- package/apps/octogent/apps/api/src/terminalRuntime/ptyEnvironment.ts +50 -0
- package/apps/octogent/apps/api/src/terminalRuntime/registry.ts +423 -0
- package/apps/octogent/apps/api/src/terminalRuntime/sessionRuntime.ts +671 -0
- package/apps/octogent/apps/api/src/terminalRuntime/systemClients.ts +432 -0
- package/apps/octogent/apps/api/src/terminalRuntime/types.ts +157 -0
- package/apps/octogent/apps/api/src/terminalRuntime/worktreeManager.ts +135 -0
- package/apps/octogent/apps/api/src/terminalRuntime.ts +567 -0
- package/apps/octogent/apps/api/src/usageUtils.ts +16 -0
- package/apps/octogent/apps/api/src/ws-shim.d.ts +28 -0
- package/apps/octogent/apps/api/tests/agentStateDetection.test.ts +67 -0
- package/apps/octogent/apps/api/tests/claudeUsage.test.ts +583 -0
- package/apps/octogent/apps/api/tests/codexUsage.test.ts +107 -0
- package/apps/octogent/apps/api/tests/createApiServer.test.ts +3207 -0
- package/apps/octogent/apps/api/tests/githubRepoSummary.test.ts +100 -0
- package/apps/octogent/apps/api/tests/logging.test.ts +33 -0
- package/apps/octogent/apps/api/tests/monitorApi.test.ts +467 -0
- package/apps/octogent/apps/api/tests/monitorCore.test.ts +104 -0
- package/apps/octogent/apps/api/tests/promptResolver.test.ts +109 -0
- package/apps/octogent/apps/api/tests/protocol.test.ts +14 -0
- package/apps/octogent/apps/api/tests/sessionRuntime.test.ts +608 -0
- package/apps/octogent/apps/api/tests/startupPrerequisites.test.ts +70 -0
- package/apps/octogent/apps/api/tests/upgradeHandler.test.ts +40 -0
- package/apps/octogent/apps/api/tests/xMonitorProvider.test.ts +109 -0
- package/apps/octogent/apps/api/tsconfig.json +7 -0
- package/apps/octogent/apps/api/vitest.config.ts +7 -0
- package/apps/octogent/apps/web/AGENTS.md +38 -0
- package/apps/octogent/apps/web/index.html +13 -0
- package/apps/octogent/apps/web/package.json +32 -0
- package/apps/octogent/apps/web/public/octopus-favicon.svg +26 -0
- package/apps/octogent/apps/web/src/App.tsx +646 -0
- package/apps/octogent/apps/web/src/app/canvas/types.ts +34 -0
- package/apps/octogent/apps/web/src/app/codeIntelAggregation.ts +278 -0
- package/apps/octogent/apps/web/src/app/constants.ts +28 -0
- package/apps/octogent/apps/web/src/app/conversationNormalizers.ts +135 -0
- package/apps/octogent/apps/web/src/app/formatTimestamp.ts +18 -0
- package/apps/octogent/apps/web/src/app/githubMetrics.ts +76 -0
- package/apps/octogent/apps/web/src/app/githubNormalizers.ts +91 -0
- package/apps/octogent/apps/web/src/app/hooks/useAgentRuntimeStates.ts +18 -0
- package/apps/octogent/apps/web/src/app/hooks/useBackendLivenessPolling.ts +53 -0
- package/apps/octogent/apps/web/src/app/hooks/useCanvasGraphData.ts +449 -0
- package/apps/octogent/apps/web/src/app/hooks/useCanvasTransform.ts +260 -0
- package/apps/octogent/apps/web/src/app/hooks/useClaudeUsagePolling.ts +40 -0
- package/apps/octogent/apps/web/src/app/hooks/useClickOutside.ts +30 -0
- package/apps/octogent/apps/web/src/app/hooks/useCodeIntelRuntime.ts +83 -0
- package/apps/octogent/apps/web/src/app/hooks/useCodexUsagePolling.ts +35 -0
- package/apps/octogent/apps/web/src/app/hooks/useConsoleKeyboardShortcuts.ts +31 -0
- package/apps/octogent/apps/web/src/app/hooks/useConversationsRuntime.ts +377 -0
- package/apps/octogent/apps/web/src/app/hooks/useForceSimulation.ts +319 -0
- package/apps/octogent/apps/web/src/app/hooks/useGitHubPrimaryViewModel.ts +143 -0
- package/apps/octogent/apps/web/src/app/hooks/useGithubSummaryPolling.ts +28 -0
- package/apps/octogent/apps/web/src/app/hooks/useInitialColumnsHydration.ts +64 -0
- package/apps/octogent/apps/web/src/app/hooks/useMonitorRuntime.ts +220 -0
- package/apps/octogent/apps/web/src/app/hooks/usePersistedUiState.ts +536 -0
- package/apps/octogent/apps/web/src/app/hooks/usePollingData.ts +79 -0
- package/apps/octogent/apps/web/src/app/hooks/usePromptLibrary.ts +185 -0
- package/apps/octogent/apps/web/src/app/hooks/useTentacleGitLifecycle.ts +530 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalCompletionNotification.ts +94 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalMutations.ts +266 -0
- package/apps/octogent/apps/web/src/app/hooks/useTerminalStateReconciliation.ts +23 -0
- package/apps/octogent/apps/web/src/app/hooks/useUsageHeatmapPolling.ts +43 -0
- package/apps/octogent/apps/web/src/app/hooks/useWorkspaceSetup.ts +80 -0
- package/apps/octogent/apps/web/src/app/hotkeys.ts +31 -0
- package/apps/octogent/apps/web/src/app/monitorNormalizers.ts +145 -0
- package/apps/octogent/apps/web/src/app/notificationSounds.ts +164 -0
- package/apps/octogent/apps/web/src/app/terminalRuntimeStateStore.ts +261 -0
- package/apps/octogent/apps/web/src/app/terminalState.ts +21 -0
- package/apps/octogent/apps/web/src/app/types.ts +42 -0
- package/apps/octogent/apps/web/src/app/uiStateNormalizers.ts +113 -0
- package/apps/octogent/apps/web/src/app/usageNormalizers.ts +58 -0
- package/apps/octogent/apps/web/src/components/ActiveAgentsSidebar.tsx +60 -0
- package/apps/octogent/apps/web/src/components/ActivityPrimaryView.tsx +21 -0
- package/apps/octogent/apps/web/src/components/AgentStateBadge.tsx +47 -0
- package/apps/octogent/apps/web/src/components/CanvasPrimaryView.tsx +1532 -0
- package/apps/octogent/apps/web/src/components/ClearAllConversationsDialog.tsx +33 -0
- package/apps/octogent/apps/web/src/components/CodeIntelArcDiagram.tsx +245 -0
- package/apps/octogent/apps/web/src/components/CodeIntelPrimaryView.tsx +104 -0
- package/apps/octogent/apps/web/src/components/CodeIntelTreemap.tsx +138 -0
- package/apps/octogent/apps/web/src/components/ConsolePrimaryNav.tsx +31 -0
- package/apps/octogent/apps/web/src/components/ConversationsPrimaryView.tsx +243 -0
- package/apps/octogent/apps/web/src/components/DeckPrimaryView.tsx +613 -0
- package/apps/octogent/apps/web/src/components/DeleteTentacleDialog.tsx +91 -0
- package/apps/octogent/apps/web/src/components/EmptyOctopus.tsx +715 -0
- package/apps/octogent/apps/web/src/components/GitHubPrimaryView.tsx +494 -0
- package/apps/octogent/apps/web/src/components/MonitorPrimaryView.tsx +475 -0
- package/apps/octogent/apps/web/src/components/PrimaryViewRouter.tsx +99 -0
- package/apps/octogent/apps/web/src/components/PromptsPrimaryView.tsx +243 -0
- package/apps/octogent/apps/web/src/components/RuntimeStatusStrip.tsx +273 -0
- package/apps/octogent/apps/web/src/components/SettingsPrimaryView.tsx +92 -0
- package/apps/octogent/apps/web/src/components/SidebarActionPanel.tsx +124 -0
- package/apps/octogent/apps/web/src/components/SidebarConversationsList.tsx +279 -0
- package/apps/octogent/apps/web/src/components/SidebarPromptsList.tsx +116 -0
- package/apps/octogent/apps/web/src/components/TelemetryTape.tsx +106 -0
- package/apps/octogent/apps/web/src/components/TentacleGitActionsDialog.tsx +341 -0
- package/apps/octogent/apps/web/src/components/Terminal.tsx +524 -0
- package/apps/octogent/apps/web/src/components/TerminalPromptPicker.tsx +140 -0
- package/apps/octogent/apps/web/src/components/UsageHeatmap.tsx +702 -0
- package/apps/octogent/apps/web/src/components/canvas/CanvasTentaclePanel.tsx +485 -0
- package/apps/octogent/apps/web/src/components/canvas/CanvasTerminalColumn.tsx +89 -0
- package/apps/octogent/apps/web/src/components/canvas/DeleteAllTerminalsDialog.tsx +221 -0
- package/apps/octogent/apps/web/src/components/canvas/OctopusNode.tsx +307 -0
- package/apps/octogent/apps/web/src/components/canvas/SessionNode.tsx +185 -0
- package/apps/octogent/apps/web/src/components/deck/ActionCards.tsx +118 -0
- package/apps/octogent/apps/web/src/components/deck/AddTentacleForm.tsx +269 -0
- package/apps/octogent/apps/web/src/components/deck/DeckBottomActions.tsx +56 -0
- package/apps/octogent/apps/web/src/components/deck/TentaclePod.tsx +334 -0
- package/apps/octogent/apps/web/src/components/deck/WorkspaceSetupCard.tsx +105 -0
- package/apps/octogent/apps/web/src/components/deck/octopusVisuals.ts +72 -0
- package/apps/octogent/apps/web/src/components/terminalReplay.ts +62 -0
- package/apps/octogent/apps/web/src/components/terminalWheel.ts +54 -0
- package/apps/octogent/apps/web/src/components/ui/ActionButton.tsx +34 -0
- package/apps/octogent/apps/web/src/components/ui/ConfirmationDialog.tsx +86 -0
- package/apps/octogent/apps/web/src/components/ui/MarkdownContent.tsx +43 -0
- package/apps/octogent/apps/web/src/components/ui/SettingsToggle.tsx +34 -0
- package/apps/octogent/apps/web/src/components/ui/StatusBadge.tsx +24 -0
- package/apps/octogent/apps/web/src/main.tsx +17 -0
- package/apps/octogent/apps/web/src/runtime/HttpTerminalSnapshotReader.ts +87 -0
- package/apps/octogent/apps/web/src/runtime/runtimeEndpoints.ts +412 -0
- package/apps/octogent/apps/web/src/styles/chrome-and-buttons.css +272 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-activity.css +358 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-canvas.css +1843 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-code-intel.css +227 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-conversations.css +705 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-deck.css +1524 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-github.css +541 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-monitor.css +595 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-pixpack.css +81 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-prompts.css +474 -0
- package/apps/octogent/apps/web/src/styles/console-canvas-settings.css +207 -0
- package/apps/octogent/apps/web/src/styles/console-chrome-status-nav.css +441 -0
- package/apps/octogent/apps/web/src/styles/console-overrides-telemetry.css +320 -0
- package/apps/octogent/apps/web/src/styles/console-theme-tokens.css +25 -0
- package/apps/octogent/apps/web/src/styles/cortex-theme.css +412 -0
- package/apps/octogent/apps/web/src/styles/foundation.css +100 -0
- package/apps/octogent/apps/web/src/styles/sidebar-and-scrollbars.css +447 -0
- package/apps/octogent/apps/web/src/styles/terminal-and-status.css +356 -0
- package/apps/octogent/apps/web/src/styles.css +25 -0
- package/apps/octogent/apps/web/src/types/ws.d.ts +23 -0
- package/apps/octogent/apps/web/tests/CanvasPrimaryView.test.tsx +347 -0
- package/apps/octogent/apps/web/tests/HttpTerminalSnapshotReader.test.tsx +54 -0
- package/apps/octogent/apps/web/tests/RuntimeStatusStrip.test.tsx +70 -0
- package/apps/octogent/apps/web/tests/Terminal.test.tsx +87 -0
- package/apps/octogent/apps/web/tests/add-tentacle-form.test.tsx +48 -0
- package/apps/octogent/apps/web/tests/app-github-runtime.test.tsx +162 -0
- package/apps/octogent/apps/web/tests/app-monitor-runtime.test.tsx +657 -0
- package/apps/octogent/apps/web/tests/app-shell-navigation.test.tsx +109 -0
- package/apps/octogent/apps/web/tests/app-swarm-refresh.test.tsx +268 -0
- package/apps/octogent/apps/web/tests/app-ui-state-persistence.test.tsx +116 -0
- package/apps/octogent/apps/web/tests/app-workspace-setup.test.tsx +217 -0
- package/apps/octogent/apps/web/tests/canvas-tentacle-panel.test.tsx +195 -0
- package/apps/octogent/apps/web/tests/delete-all-terminals-dialog.test.tsx +76 -0
- package/apps/octogent/apps/web/tests/githubMetrics.test.tsx +52 -0
- package/apps/octogent/apps/web/tests/hotkeys.test.tsx +44 -0
- package/apps/octogent/apps/web/tests/runtimeEndpoints.test.tsx +240 -0
- package/apps/octogent/apps/web/tests/setup.ts +39 -0
- package/apps/octogent/apps/web/tests/tentacle-pod.test.tsx +62 -0
- package/apps/octogent/apps/web/tests/terminalReplay.test.ts +71 -0
- package/apps/octogent/apps/web/tests/terminalState.test.tsx +49 -0
- package/apps/octogent/apps/web/tests/terminalWheel.test.tsx +51 -0
- package/apps/octogent/apps/web/tests/test-utils/appTestHarness.ts +48 -0
- package/apps/octogent/apps/web/tests/uiPrimitives.test.tsx +31 -0
- package/apps/octogent/apps/web/tests/useAgentRuntimeStates.test.tsx +47 -0
- package/apps/octogent/apps/web/tsconfig.json +8 -0
- package/apps/octogent/apps/web/vite.api.bundle.config.mts +32 -0
- package/apps/octogent/apps/web/vite.config.ts +22 -0
- package/apps/octogent/bin/octogent +3 -0
- package/apps/octogent/biome.json +21 -0
- package/apps/octogent/docs/concepts/mental-model.md +79 -0
- package/apps/octogent/docs/concepts/runtime-and-api.md +60 -0
- package/apps/octogent/docs/concepts/tentacles.md +85 -0
- package/apps/octogent/docs/getting-started/installation.md +54 -0
- package/apps/octogent/docs/getting-started/quickstart.md +79 -0
- package/apps/octogent/docs/guides/inter-agent-messaging.md +43 -0
- package/apps/octogent/docs/guides/orchestrating-child-agents.md +49 -0
- package/apps/octogent/docs/guides/working-with-todos.md +56 -0
- package/apps/octogent/docs/index.md +40 -0
- package/apps/octogent/docs/reference/api.md +103 -0
- package/apps/octogent/docs/reference/cli.md +71 -0
- package/apps/octogent/docs/reference/experimental-features.md +28 -0
- package/apps/octogent/docs/reference/filesystem-layout.md +62 -0
- package/apps/octogent/docs/reference/troubleshooting.md +49 -0
- package/apps/octogent/package.json +35 -0
- package/apps/octogent/packages/core/AGENTS.md +31 -0
- package/apps/octogent/packages/core/package.json +12 -0
- package/apps/octogent/packages/core/src/adapters/InMemoryTerminalSnapshotReader.ts +10 -0
- package/apps/octogent/packages/core/src/application/buildTerminalList.ts +13 -0
- package/apps/octogent/packages/core/src/domain/agentRuntime.ts +18 -0
- package/apps/octogent/packages/core/src/domain/channel.ts +8 -0
- package/apps/octogent/packages/core/src/domain/completionSound.ts +14 -0
- package/apps/octogent/packages/core/src/domain/conversation.ts +48 -0
- package/apps/octogent/packages/core/src/domain/deck.ts +33 -0
- package/apps/octogent/packages/core/src/domain/git.ts +32 -0
- package/apps/octogent/packages/core/src/domain/monitor.ts +62 -0
- package/apps/octogent/packages/core/src/domain/setup.ts +27 -0
- package/apps/octogent/packages/core/src/domain/terminal.ts +17 -0
- package/apps/octogent/packages/core/src/domain/uiState.ts +22 -0
- package/apps/octogent/packages/core/src/domain/usage.ts +60 -0
- package/apps/octogent/packages/core/src/index.ts +15 -0
- package/apps/octogent/packages/core/src/ports/TerminalSnapshotReader.ts +5 -0
- package/apps/octogent/packages/core/src/util/typeCoercion.ts +20 -0
- package/apps/octogent/packages/core/tests/buildTerminalList.test.ts +75 -0
- package/apps/octogent/packages/core/tsconfig.json +7 -0
- package/apps/octogent/packages/core/tsconfig.tsbuildinfo +1 -0
- package/apps/octogent/packages/core/vitest.config.ts +7 -0
- package/apps/octogent/pnpm-lock.yaml +3212 -0
- package/apps/octogent/pnpm-workspace.yaml +3 -0
- package/apps/octogent/prompts/meta-prompt-generator.md +223 -0
- package/apps/octogent/prompts/octoboss-clean-contexts.md +30 -0
- package/apps/octogent/prompts/octoboss-reorganize-tentacles.md +29 -0
- package/apps/octogent/prompts/octoboss-reorganize-todos.md +27 -0
- package/apps/octogent/prompts/sandbox-init.md +3 -0
- package/apps/octogent/prompts/swarm-parent.md +83 -0
- package/apps/octogent/prompts/swarm-worker.md +50 -0
- package/apps/octogent/prompts/tentacle-context-init.md +1 -0
- package/apps/octogent/prompts/tentacle-planner.md +110 -0
- package/apps/octogent/prompts/tentacle-reorganize-todos.md +20 -0
- package/apps/octogent/prompts/tentacle-update-tentacle.md +18 -0
- package/apps/octogent/scripts/build-package.mjs +23 -0
- package/apps/octogent/scripts/dev.mjs +158 -0
- package/apps/octogent/scripts/smoke-public-install.mjs +271 -0
- package/apps/octogent/static/images/octogent-header.png +0 -0
- package/apps/octogent/static/images/preview_1.jpg +0 -0
- package/apps/octogent/static/images/preview_2.jpg +0 -0
- package/apps/octogent/static/images/preview_3.jpg +0 -0
- package/apps/octogent/static/images/preview_4.jpg +0 -0
- package/apps/octogent/static/images/preview_5.jpg +0 -0
- package/apps/octogent/static/images/preview_6.jpg +0 -0
- package/apps/octogent/tsconfig.base.json +16 -0
- package/bin/AGI +3 -0
- package/bin/AGI-install-app +71 -0
- package/bin/AGI-ui +16 -0
- package/bin/AGI-voice +15 -0
- package/bin/AGI-web +16 -0
- package/bin/cortex +109 -0
- package/bin/cortex-octogent +99 -0
- package/bin/import-specifier.mjs +13 -0
- package/bin/import-specifier.test.mjs +13 -0
- package/bin/octo +150 -0
- package/dist/cli.mjs +555650 -0
- package/package.json +157 -0
- package/scripts/setup-wizard.ts +390 -0
|
@@ -0,0 +1,3207 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { createConnection } from "node:net";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
const { spawnMock } = vi.hoisted(() => ({
|
|
8
|
+
spawnMock: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("node-pty", () => ({
|
|
12
|
+
spawn: spawnMock,
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
import { createApiServer } from "../src/createApiServer";
|
|
16
|
+
import type { GitHubRepoSummarySnapshot } from "../src/githubRepoSummary";
|
|
17
|
+
import { MAX_CHILDREN_PER_PARENT } from "../src/terminalRuntime";
|
|
18
|
+
import type { GitClient } from "../src/terminalRuntime";
|
|
19
|
+
|
|
20
|
+
class FakeGitClient implements GitClient {
|
|
21
|
+
private readonly worktreeStatusByCwd = new Map<
|
|
22
|
+
string,
|
|
23
|
+
{
|
|
24
|
+
branchName: string;
|
|
25
|
+
upstreamBranchName: string | null;
|
|
26
|
+
isDirty: boolean;
|
|
27
|
+
aheadCount: number;
|
|
28
|
+
behindCount: number;
|
|
29
|
+
insertedLineCount: number;
|
|
30
|
+
deletedLineCount: number;
|
|
31
|
+
hasConflicts: boolean;
|
|
32
|
+
changedFiles: string[];
|
|
33
|
+
defaultBaseBranchName: string | null;
|
|
34
|
+
}
|
|
35
|
+
>();
|
|
36
|
+
private readonly commitsByCwd = new Map<string, string[]>();
|
|
37
|
+
private readonly pushesByCwd = new Map<string, number>();
|
|
38
|
+
private readonly syncsByCwd = new Map<string, string[]>();
|
|
39
|
+
private readonly pullRequestByCwd = new Map<
|
|
40
|
+
string,
|
|
41
|
+
{
|
|
42
|
+
number: number;
|
|
43
|
+
url: string;
|
|
44
|
+
title: string;
|
|
45
|
+
baseRef: string;
|
|
46
|
+
headRef: string;
|
|
47
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
48
|
+
isDraft: boolean;
|
|
49
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
50
|
+
mergeStateStatus: string | null;
|
|
51
|
+
} | null
|
|
52
|
+
>();
|
|
53
|
+
private readonly worktrees = new Map<
|
|
54
|
+
string,
|
|
55
|
+
{ branchName: string; baseRef: string; cwd: string }
|
|
56
|
+
>();
|
|
57
|
+
private readonly branches = new Set<string>();
|
|
58
|
+
private repositoryAvailable = true;
|
|
59
|
+
private failRemoveWorktree = false;
|
|
60
|
+
private failCommit = false;
|
|
61
|
+
private failPush = false;
|
|
62
|
+
private failSync = false;
|
|
63
|
+
private failCreatePullRequest = false;
|
|
64
|
+
private failMergePullRequest = false;
|
|
65
|
+
|
|
66
|
+
assertAvailable(): void {}
|
|
67
|
+
|
|
68
|
+
isRepository(): boolean {
|
|
69
|
+
return this.repositoryAvailable;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
addWorktree({
|
|
73
|
+
cwd,
|
|
74
|
+
path,
|
|
75
|
+
branchName,
|
|
76
|
+
baseRef,
|
|
77
|
+
}: {
|
|
78
|
+
cwd: string;
|
|
79
|
+
path: string;
|
|
80
|
+
branchName: string;
|
|
81
|
+
baseRef: string;
|
|
82
|
+
}): void {
|
|
83
|
+
if (this.worktrees.has(path)) {
|
|
84
|
+
throw new Error(`Worktree already exists: ${path}`);
|
|
85
|
+
}
|
|
86
|
+
mkdirSync(path, { recursive: true });
|
|
87
|
+
this.branches.add(branchName);
|
|
88
|
+
this.worktrees.set(path, { cwd, branchName, baseRef });
|
|
89
|
+
this.worktreeStatusByCwd.set(path, {
|
|
90
|
+
branchName,
|
|
91
|
+
upstreamBranchName: null,
|
|
92
|
+
isDirty: false,
|
|
93
|
+
aheadCount: 0,
|
|
94
|
+
behindCount: 0,
|
|
95
|
+
insertedLineCount: 0,
|
|
96
|
+
deletedLineCount: 0,
|
|
97
|
+
hasConflicts: false,
|
|
98
|
+
changedFiles: [],
|
|
99
|
+
defaultBaseBranchName: "main",
|
|
100
|
+
});
|
|
101
|
+
this.pullRequestByCwd.set(path, null);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
removeWorktree({ path }: { cwd: string; path: string }): void {
|
|
105
|
+
if (this.failRemoveWorktree) {
|
|
106
|
+
throw new Error(`Unable to remove worktree: ${path}`);
|
|
107
|
+
}
|
|
108
|
+
this.worktrees.delete(path);
|
|
109
|
+
this.worktreeStatusByCwd.delete(path);
|
|
110
|
+
this.commitsByCwd.delete(path);
|
|
111
|
+
this.pushesByCwd.delete(path);
|
|
112
|
+
this.syncsByCwd.delete(path);
|
|
113
|
+
this.pullRequestByCwd.delete(path);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
removeBranch({ branchName }: { cwd: string; branchName: string }): void {
|
|
117
|
+
this.branches.delete(branchName);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
setRepositoryAvailable(available: boolean): void {
|
|
121
|
+
this.repositoryAvailable = available;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setFailRemoveWorktree(shouldFail: boolean): void {
|
|
125
|
+
this.failRemoveWorktree = shouldFail;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
setFailCommit(shouldFail: boolean): void {
|
|
129
|
+
this.failCommit = shouldFail;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
setFailPush(shouldFail: boolean): void {
|
|
133
|
+
this.failPush = shouldFail;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
setFailSync(shouldFail: boolean): void {
|
|
137
|
+
this.failSync = shouldFail;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
setFailCreatePullRequest(shouldFail: boolean): void {
|
|
141
|
+
this.failCreatePullRequest = shouldFail;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
setFailMergePullRequest(shouldFail: boolean): void {
|
|
145
|
+
this.failMergePullRequest = shouldFail;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
setWorktreeStatus(
|
|
149
|
+
cwd: string,
|
|
150
|
+
status: {
|
|
151
|
+
branchName: string;
|
|
152
|
+
upstreamBranchName: string | null;
|
|
153
|
+
isDirty: boolean;
|
|
154
|
+
aheadCount: number;
|
|
155
|
+
behindCount: number;
|
|
156
|
+
insertedLineCount: number;
|
|
157
|
+
deletedLineCount: number;
|
|
158
|
+
hasConflicts: boolean;
|
|
159
|
+
changedFiles: string[];
|
|
160
|
+
defaultBaseBranchName: string | null;
|
|
161
|
+
},
|
|
162
|
+
): void {
|
|
163
|
+
this.worktreeStatusByCwd.set(cwd, status);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
readWorktreeStatus({
|
|
167
|
+
cwd,
|
|
168
|
+
}: {
|
|
169
|
+
cwd: string;
|
|
170
|
+
}): {
|
|
171
|
+
branchName: string;
|
|
172
|
+
upstreamBranchName: string | null;
|
|
173
|
+
isDirty: boolean;
|
|
174
|
+
aheadCount: number;
|
|
175
|
+
behindCount: number;
|
|
176
|
+
insertedLineCount: number;
|
|
177
|
+
deletedLineCount: number;
|
|
178
|
+
hasConflicts: boolean;
|
|
179
|
+
changedFiles: string[];
|
|
180
|
+
defaultBaseBranchName: string | null;
|
|
181
|
+
} {
|
|
182
|
+
const status = this.worktreeStatusByCwd.get(cwd);
|
|
183
|
+
if (!status) {
|
|
184
|
+
throw new Error(`Missing fake status for ${cwd}`);
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
...status,
|
|
188
|
+
changedFiles: [...status.changedFiles],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
commitAll({ cwd, message }: { cwd: string; message: string }): void {
|
|
193
|
+
if (this.failCommit) {
|
|
194
|
+
throw new Error("Simulated commit failure");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const status = this.worktreeStatusByCwd.get(cwd);
|
|
198
|
+
if (!status) {
|
|
199
|
+
throw new Error(`Missing fake status for ${cwd}`);
|
|
200
|
+
}
|
|
201
|
+
if (!status.isDirty) {
|
|
202
|
+
throw new Error("No local changes to commit.");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const commits = this.commitsByCwd.get(cwd) ?? [];
|
|
206
|
+
commits.push(message);
|
|
207
|
+
this.commitsByCwd.set(cwd, commits);
|
|
208
|
+
this.worktreeStatusByCwd.set(cwd, {
|
|
209
|
+
...status,
|
|
210
|
+
isDirty: false,
|
|
211
|
+
changedFiles: [],
|
|
212
|
+
aheadCount: status.aheadCount + 1,
|
|
213
|
+
insertedLineCount: 0,
|
|
214
|
+
deletedLineCount: 0,
|
|
215
|
+
hasConflicts: false,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
pushCurrentBranch({ cwd }: { cwd: string }): void {
|
|
220
|
+
if (this.failPush) {
|
|
221
|
+
throw new Error("Simulated push failure");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const status = this.worktreeStatusByCwd.get(cwd);
|
|
225
|
+
if (!status) {
|
|
226
|
+
throw new Error(`Missing fake status for ${cwd}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
this.pushesByCwd.set(cwd, (this.pushesByCwd.get(cwd) ?? 0) + 1);
|
|
230
|
+
this.worktreeStatusByCwd.set(cwd, {
|
|
231
|
+
...status,
|
|
232
|
+
upstreamBranchName: status.upstreamBranchName ?? `origin/${status.branchName}`,
|
|
233
|
+
aheadCount: 0,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
syncWithBase({ cwd, baseRef }: { cwd: string; baseRef: string }): void {
|
|
238
|
+
if (this.failSync) {
|
|
239
|
+
throw new Error("Simulated sync failure");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const status = this.worktreeStatusByCwd.get(cwd);
|
|
243
|
+
if (!status) {
|
|
244
|
+
throw new Error(`Missing fake status for ${cwd}`);
|
|
245
|
+
}
|
|
246
|
+
const syncs = this.syncsByCwd.get(cwd) ?? [];
|
|
247
|
+
syncs.push(baseRef);
|
|
248
|
+
this.syncsByCwd.set(cwd, syncs);
|
|
249
|
+
this.worktreeStatusByCwd.set(cwd, {
|
|
250
|
+
...status,
|
|
251
|
+
behindCount: 0,
|
|
252
|
+
insertedLineCount: 0,
|
|
253
|
+
deletedLineCount: 0,
|
|
254
|
+
hasConflicts: false,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
setWorktreePullRequest(
|
|
259
|
+
cwd: string,
|
|
260
|
+
pullRequest: {
|
|
261
|
+
number: number;
|
|
262
|
+
url: string;
|
|
263
|
+
title: string;
|
|
264
|
+
baseRef: string;
|
|
265
|
+
headRef: string;
|
|
266
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
267
|
+
isDraft: boolean;
|
|
268
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
269
|
+
mergeStateStatus: string | null;
|
|
270
|
+
} | null,
|
|
271
|
+
): void {
|
|
272
|
+
this.pullRequestByCwd.set(cwd, pullRequest);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
readCurrentBranchPullRequest({
|
|
276
|
+
cwd,
|
|
277
|
+
}: {
|
|
278
|
+
cwd: string;
|
|
279
|
+
}): {
|
|
280
|
+
number: number;
|
|
281
|
+
url: string;
|
|
282
|
+
title: string;
|
|
283
|
+
baseRef: string;
|
|
284
|
+
headRef: string;
|
|
285
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
286
|
+
isDraft: boolean;
|
|
287
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
288
|
+
mergeStateStatus: string | null;
|
|
289
|
+
} | null {
|
|
290
|
+
const pullRequest = this.pullRequestByCwd.get(cwd);
|
|
291
|
+
if (pullRequest === undefined || pullRequest === null) {
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
...pullRequest,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
createPullRequest({
|
|
301
|
+
cwd,
|
|
302
|
+
title,
|
|
303
|
+
baseRef,
|
|
304
|
+
headRef,
|
|
305
|
+
}: {
|
|
306
|
+
cwd: string;
|
|
307
|
+
title: string;
|
|
308
|
+
body: string;
|
|
309
|
+
baseRef: string;
|
|
310
|
+
headRef: string;
|
|
311
|
+
}): {
|
|
312
|
+
number: number;
|
|
313
|
+
url: string;
|
|
314
|
+
title: string;
|
|
315
|
+
baseRef: string;
|
|
316
|
+
headRef: string;
|
|
317
|
+
state: "OPEN" | "MERGED" | "CLOSED";
|
|
318
|
+
isDraft: boolean;
|
|
319
|
+
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
320
|
+
mergeStateStatus: string | null;
|
|
321
|
+
} | null {
|
|
322
|
+
if (this.failCreatePullRequest) {
|
|
323
|
+
throw new Error("Simulated create PR failure");
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const nextNumber = (this.pullRequestByCwd.get(cwd)?.number ?? 100) + 1;
|
|
327
|
+
const pullRequest = {
|
|
328
|
+
number: nextNumber,
|
|
329
|
+
url: `https://github.com/hesamsheikh/octogent/pull/${nextNumber}`,
|
|
330
|
+
title,
|
|
331
|
+
baseRef,
|
|
332
|
+
headRef,
|
|
333
|
+
state: "OPEN" as const,
|
|
334
|
+
isDraft: false,
|
|
335
|
+
mergeable: "MERGEABLE" as const,
|
|
336
|
+
mergeStateStatus: "CLEAN",
|
|
337
|
+
};
|
|
338
|
+
this.pullRequestByCwd.set(cwd, pullRequest);
|
|
339
|
+
return pullRequest;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
mergeCurrentBranchPullRequest({
|
|
343
|
+
cwd,
|
|
344
|
+
}: {
|
|
345
|
+
cwd: string;
|
|
346
|
+
strategy: "squash" | "merge" | "rebase";
|
|
347
|
+
}): void {
|
|
348
|
+
if (this.failMergePullRequest) {
|
|
349
|
+
throw new Error("Simulated merge PR failure");
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const pullRequest = this.pullRequestByCwd.get(cwd);
|
|
353
|
+
if (!pullRequest) {
|
|
354
|
+
throw new Error("No open pull request for this branch.");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this.pullRequestByCwd.set(cwd, {
|
|
358
|
+
...pullRequest,
|
|
359
|
+
state: "MERGED",
|
|
360
|
+
mergeable: "UNKNOWN",
|
|
361
|
+
mergeStateStatus: "MERGED",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
getWorktree(path: string): { branchName: string; baseRef: string; cwd: string } | null {
|
|
366
|
+
return this.worktrees.get(path) ?? null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
hasBranch(branchName: string): boolean {
|
|
370
|
+
return this.branches.has(branchName);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
getLastCommitMessage(cwd: string): string | null {
|
|
374
|
+
const commits = this.commitsByCwd.get(cwd);
|
|
375
|
+
if (!commits || commits.length === 0) {
|
|
376
|
+
return null;
|
|
377
|
+
}
|
|
378
|
+
return commits[commits.length - 1] ?? null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
getPushCount(cwd: string): number {
|
|
382
|
+
return this.pushesByCwd.get(cwd) ?? 0;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
getSyncBaseRefs(cwd: string): string[] {
|
|
386
|
+
return [...(this.syncsByCwd.get(cwd) ?? [])];
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
getPullRequestState(cwd: string): "OPEN" | "MERGED" | "CLOSED" | null {
|
|
390
|
+
return this.pullRequestByCwd.get(cwd)?.state ?? null;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
describe("createApiServer", () => {
|
|
395
|
+
let stopServer: (() => Promise<void>) | null = null;
|
|
396
|
+
const temporaryDirectories: string[] = [];
|
|
397
|
+
|
|
398
|
+
afterEach(async () => {
|
|
399
|
+
if (stopServer) {
|
|
400
|
+
await stopServer();
|
|
401
|
+
stopServer = null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const directory of temporaryDirectories) {
|
|
405
|
+
rmSync(directory, { recursive: true, force: true });
|
|
406
|
+
}
|
|
407
|
+
temporaryDirectories.length = 0;
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const startServer = async (options: Partial<Parameters<typeof createApiServer>[0]> = {}) => {
|
|
411
|
+
const workspaceCwd =
|
|
412
|
+
options.workspaceCwd ??
|
|
413
|
+
(() => {
|
|
414
|
+
const directory = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
415
|
+
temporaryDirectories.push(directory);
|
|
416
|
+
return directory;
|
|
417
|
+
})();
|
|
418
|
+
const apiServer = createApiServer({
|
|
419
|
+
workspaceCwd,
|
|
420
|
+
gitClient: options.gitClient ?? new FakeGitClient(),
|
|
421
|
+
...options,
|
|
422
|
+
});
|
|
423
|
+
const address = await apiServer.start(0, "127.0.0.1");
|
|
424
|
+
stopServer = () => apiServer.stop();
|
|
425
|
+
return `http://${address.host}:${address.port}`;
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const toWebSocketBaseUrl = (httpBaseUrl: string) =>
|
|
429
|
+
httpBaseUrl.startsWith("https://")
|
|
430
|
+
? httpBaseUrl.replace("https://", "wss://")
|
|
431
|
+
: httpBaseUrl.replace("http://", "ws://");
|
|
432
|
+
|
|
433
|
+
const waitForRegistryDocument = async <TDocument>(
|
|
434
|
+
workspaceCwd: string,
|
|
435
|
+
predicate: (document: TDocument) => boolean,
|
|
436
|
+
): Promise<TDocument> => {
|
|
437
|
+
const registryPath = join(workspaceCwd, ".octogent", "state", "tentacles.json");
|
|
438
|
+
const timeoutAt = Date.now() + 2_000;
|
|
439
|
+
|
|
440
|
+
while (Date.now() < timeoutAt) {
|
|
441
|
+
if (existsSync(registryPath)) {
|
|
442
|
+
const document = JSON.parse(readFileSync(registryPath, "utf8")) as TDocument;
|
|
443
|
+
if (predicate(document)) {
|
|
444
|
+
return document;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
throw new Error(`Timed out waiting for registry persistence at ${registryPath}`);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const writeConversationTranscript = (
|
|
455
|
+
workspaceCwd: string,
|
|
456
|
+
sessionId: string,
|
|
457
|
+
events: unknown[],
|
|
458
|
+
) => {
|
|
459
|
+
const transcriptDirectory = join(workspaceCwd, ".octogent", "state", "transcripts");
|
|
460
|
+
mkdirSync(transcriptDirectory, { recursive: true });
|
|
461
|
+
const transcriptPath = join(transcriptDirectory, `${encodeURIComponent(sessionId)}.jsonl`);
|
|
462
|
+
writeFileSync(
|
|
463
|
+
transcriptPath,
|
|
464
|
+
`${events.map((event) => JSON.stringify(event)).join("\n")}\n`,
|
|
465
|
+
"utf8",
|
|
466
|
+
);
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
const writeClaudeTurns = (
|
|
470
|
+
workspaceCwd: string,
|
|
471
|
+
sessionId: string,
|
|
472
|
+
turns: Array<{
|
|
473
|
+
turnId: string;
|
|
474
|
+
role: string;
|
|
475
|
+
content: string;
|
|
476
|
+
startedAt: string;
|
|
477
|
+
endedAt: string;
|
|
478
|
+
}>,
|
|
479
|
+
) => {
|
|
480
|
+
const transcriptDirectory = join(workspaceCwd, ".octogent", "state", "transcripts");
|
|
481
|
+
mkdirSync(transcriptDirectory, { recursive: true });
|
|
482
|
+
const turnsPath = join(
|
|
483
|
+
transcriptDirectory,
|
|
484
|
+
`${encodeURIComponent(sessionId)}.claude-turns.json`,
|
|
485
|
+
);
|
|
486
|
+
writeFileSync(turnsPath, JSON.stringify(turns), "utf8");
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
it("returns snapshots for GET /api/terminal-snapshots", async () => {
|
|
490
|
+
const baseUrl = await startServer();
|
|
491
|
+
|
|
492
|
+
const response = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
493
|
+
method: "GET",
|
|
494
|
+
headers: {
|
|
495
|
+
Accept: "application/json",
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
expect(response.status).toBe(200);
|
|
500
|
+
await expect(response.json()).resolves.toEqual([]);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("returns session summaries for GET /api/conversations", async () => {
|
|
504
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
505
|
+
temporaryDirectories.push(workspaceCwd);
|
|
506
|
+
writeConversationTranscript(workspaceCwd, "terminal-1", [
|
|
507
|
+
{
|
|
508
|
+
type: "session_start",
|
|
509
|
+
eventId: "terminal-1:1",
|
|
510
|
+
sessionId: "terminal-1",
|
|
511
|
+
tentacleId: "terminal-1",
|
|
512
|
+
timestamp: "2026-03-05T10:00:00.000Z",
|
|
513
|
+
},
|
|
514
|
+
{
|
|
515
|
+
type: "session_end",
|
|
516
|
+
eventId: "terminal-1:5",
|
|
517
|
+
sessionId: "terminal-1",
|
|
518
|
+
tentacleId: "terminal-1",
|
|
519
|
+
reason: "pty_exit",
|
|
520
|
+
exitCode: 0,
|
|
521
|
+
signal: 0,
|
|
522
|
+
timestamp: "2026-03-05T10:00:04.000Z",
|
|
523
|
+
},
|
|
524
|
+
]);
|
|
525
|
+
writeClaudeTurns(workspaceCwd, "terminal-1", [
|
|
526
|
+
{
|
|
527
|
+
turnId: "turn-1",
|
|
528
|
+
role: "user",
|
|
529
|
+
content: "build export",
|
|
530
|
+
startedAt: "2026-03-05T10:00:01.000Z",
|
|
531
|
+
endedAt: "2026-03-05T10:00:01.000Z",
|
|
532
|
+
},
|
|
533
|
+
{
|
|
534
|
+
turnId: "turn-2",
|
|
535
|
+
role: "assistant",
|
|
536
|
+
content: "implemented",
|
|
537
|
+
startedAt: "2026-03-05T10:00:02.000Z",
|
|
538
|
+
endedAt: "2026-03-05T10:00:03.000Z",
|
|
539
|
+
},
|
|
540
|
+
]);
|
|
541
|
+
|
|
542
|
+
const baseUrl = await startServer({
|
|
543
|
+
workspaceCwd,
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
const response = await fetch(`${baseUrl}/api/conversations`, {
|
|
547
|
+
method: "GET",
|
|
548
|
+
headers: {
|
|
549
|
+
Accept: "application/json",
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
expect(response.status).toBe(200);
|
|
554
|
+
await expect(response.json()).resolves.toEqual([
|
|
555
|
+
{
|
|
556
|
+
sessionId: "terminal-1",
|
|
557
|
+
tentacleId: "terminal-1",
|
|
558
|
+
startedAt: "2026-03-05T10:00:00.000Z",
|
|
559
|
+
endedAt: "2026-03-05T10:00:04.000Z",
|
|
560
|
+
lastEventAt: "2026-03-05T10:00:04.000Z",
|
|
561
|
+
eventCount: 2,
|
|
562
|
+
turnCount: 2,
|
|
563
|
+
userTurnCount: 1,
|
|
564
|
+
assistantTurnCount: 1,
|
|
565
|
+
firstUserTurnPreview: "build export",
|
|
566
|
+
lastUserTurnPreview: "build export",
|
|
567
|
+
lastAssistantTurnPreview: "implemented",
|
|
568
|
+
},
|
|
569
|
+
]);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
it("returns assembled conversation details and export payloads", async () => {
|
|
573
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
574
|
+
temporaryDirectories.push(workspaceCwd);
|
|
575
|
+
writeConversationTranscript(workspaceCwd, "terminal-2-agent-1", [
|
|
576
|
+
{
|
|
577
|
+
type: "session_start",
|
|
578
|
+
eventId: "terminal-2-agent-1:1",
|
|
579
|
+
sessionId: "terminal-2-agent-1",
|
|
580
|
+
tentacleId: "terminal-2",
|
|
581
|
+
timestamp: "2026-03-05T11:00:00.000Z",
|
|
582
|
+
},
|
|
583
|
+
]);
|
|
584
|
+
writeClaudeTurns(workspaceCwd, "terminal-2-agent-1", [
|
|
585
|
+
{
|
|
586
|
+
turnId: "turn-1",
|
|
587
|
+
role: "user",
|
|
588
|
+
content: "summarize",
|
|
589
|
+
startedAt: "2026-03-05T11:00:01.000Z",
|
|
590
|
+
endedAt: "2026-03-05T11:00:01.000Z",
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
turnId: "turn-2",
|
|
594
|
+
role: "assistant",
|
|
595
|
+
content: "summary ready",
|
|
596
|
+
startedAt: "2026-03-05T11:00:02.000Z",
|
|
597
|
+
endedAt: "2026-03-05T11:00:03.000Z",
|
|
598
|
+
},
|
|
599
|
+
]);
|
|
600
|
+
|
|
601
|
+
const baseUrl = await startServer({
|
|
602
|
+
workspaceCwd,
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const detailResponse = await fetch(`${baseUrl}/api/conversations/terminal-2-agent-1`, {
|
|
606
|
+
method: "GET",
|
|
607
|
+
headers: {
|
|
608
|
+
Accept: "application/json",
|
|
609
|
+
},
|
|
610
|
+
});
|
|
611
|
+
expect(detailResponse.status).toBe(200);
|
|
612
|
+
await expect(detailResponse.json()).resolves.toMatchObject({
|
|
613
|
+
sessionId: "terminal-2-agent-1",
|
|
614
|
+
turnCount: 2,
|
|
615
|
+
turns: [
|
|
616
|
+
{
|
|
617
|
+
role: "user",
|
|
618
|
+
content: "summarize",
|
|
619
|
+
},
|
|
620
|
+
{
|
|
621
|
+
role: "assistant",
|
|
622
|
+
content: "summary ready",
|
|
623
|
+
},
|
|
624
|
+
],
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const jsonExportResponse = await fetch(
|
|
628
|
+
`${baseUrl}/api/conversations/terminal-2-agent-1/export?format=json`,
|
|
629
|
+
{
|
|
630
|
+
method: "GET",
|
|
631
|
+
headers: {
|
|
632
|
+
Accept: "application/json",
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
);
|
|
636
|
+
expect(jsonExportResponse.status).toBe(200);
|
|
637
|
+
await expect(jsonExportResponse.json()).resolves.toMatchObject({
|
|
638
|
+
sessionId: "terminal-2-agent-1",
|
|
639
|
+
turnCount: 2,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
const markdownExportResponse = await fetch(
|
|
643
|
+
`${baseUrl}/api/conversations/terminal-2-agent-1/export?format=md`,
|
|
644
|
+
{
|
|
645
|
+
method: "GET",
|
|
646
|
+
},
|
|
647
|
+
);
|
|
648
|
+
expect(markdownExportResponse.status).toBe(200);
|
|
649
|
+
expect(markdownExportResponse.headers.get("content-type")).toContain("text/markdown");
|
|
650
|
+
const markdownBody = await markdownExportResponse.text();
|
|
651
|
+
expect(markdownBody).toContain("## User");
|
|
652
|
+
expect(markdownBody).toContain("summarize");
|
|
653
|
+
expect(markdownBody).toContain("## Assistant");
|
|
654
|
+
expect(markdownBody).toContain("summary ready");
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it("returns 400 for unsupported conversation export format", async () => {
|
|
658
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
659
|
+
temporaryDirectories.push(workspaceCwd);
|
|
660
|
+
writeConversationTranscript(workspaceCwd, "terminal-3-agent-1", [
|
|
661
|
+
{
|
|
662
|
+
type: "session_start",
|
|
663
|
+
eventId: "terminal-3-agent-1:1",
|
|
664
|
+
sessionId: "terminal-3-agent-1",
|
|
665
|
+
tentacleId: "terminal-3",
|
|
666
|
+
timestamp: "2026-03-05T12:00:00.000Z",
|
|
667
|
+
},
|
|
668
|
+
]);
|
|
669
|
+
|
|
670
|
+
const baseUrl = await startServer({
|
|
671
|
+
workspaceCwd,
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
const response = await fetch(
|
|
675
|
+
`${baseUrl}/api/conversations/terminal-3-agent-1/export?format=txt`,
|
|
676
|
+
{
|
|
677
|
+
method: "GET",
|
|
678
|
+
headers: {
|
|
679
|
+
Accept: "application/json",
|
|
680
|
+
},
|
|
681
|
+
},
|
|
682
|
+
);
|
|
683
|
+
expect(response.status).toBe(400);
|
|
684
|
+
await expect(response.json()).resolves.toEqual({
|
|
685
|
+
error: "Unsupported conversation export format.",
|
|
686
|
+
});
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
it("rejects non-local browser origins for HTTP endpoints", async () => {
|
|
690
|
+
const baseUrl = await startServer();
|
|
691
|
+
|
|
692
|
+
const response = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
693
|
+
method: "GET",
|
|
694
|
+
headers: {
|
|
695
|
+
Accept: "application/json",
|
|
696
|
+
Origin: "https://attacker.example",
|
|
697
|
+
},
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
expect(response.status).toBe(403);
|
|
701
|
+
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
|
702
|
+
await expect(response.json()).resolves.toEqual({
|
|
703
|
+
error: "Origin not allowed.",
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("allows loopback browser origins and reflects CORS origin", async () => {
|
|
708
|
+
const baseUrl = await startServer();
|
|
709
|
+
const origin = "http://localhost:5173";
|
|
710
|
+
|
|
711
|
+
const response = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
712
|
+
method: "GET",
|
|
713
|
+
headers: {
|
|
714
|
+
Accept: "application/json",
|
|
715
|
+
Origin: origin,
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
expect(response.status).toBe(200);
|
|
720
|
+
expect(response.headers.get("access-control-allow-origin")).toBe(origin);
|
|
721
|
+
expect(response.headers.get("vary")).toBe("Origin");
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("rejects non-local CORS preflight requests", async () => {
|
|
725
|
+
const baseUrl = await startServer();
|
|
726
|
+
|
|
727
|
+
const response = await fetch(`${baseUrl}/api/terminals`, {
|
|
728
|
+
method: "OPTIONS",
|
|
729
|
+
headers: {
|
|
730
|
+
Origin: "https://attacker.example",
|
|
731
|
+
"Access-Control-Request-Method": "POST",
|
|
732
|
+
},
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
expect(response.status).toBe(403);
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
it("rejects websocket upgrades from non-local origins", async () => {
|
|
739
|
+
const baseUrl = await startServer();
|
|
740
|
+
const wsUrl = new URL(`${toWebSocketBaseUrl(baseUrl)}/api/terminals/terminal-1/ws`);
|
|
741
|
+
|
|
742
|
+
const opened = await new Promise<boolean>((resolve) => {
|
|
743
|
+
const socket = createConnection({
|
|
744
|
+
host: wsUrl.hostname,
|
|
745
|
+
port: Number.parseInt(wsUrl.port, 10),
|
|
746
|
+
});
|
|
747
|
+
let settled = false;
|
|
748
|
+
let responseHead = "";
|
|
749
|
+
|
|
750
|
+
const finish = (didOpen: boolean) => {
|
|
751
|
+
if (settled) {
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
settled = true;
|
|
755
|
+
socket.destroy();
|
|
756
|
+
resolve(didOpen);
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
socket.on("connect", () => {
|
|
760
|
+
socket.write(
|
|
761
|
+
`GET ${wsUrl.pathname} HTTP/1.1\r\nHost: ${wsUrl.host}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nOrigin: https://attacker.example\r\n\r\n`,
|
|
762
|
+
);
|
|
763
|
+
});
|
|
764
|
+
socket.on("data", (chunk) => {
|
|
765
|
+
responseHead += chunk.toString("utf8");
|
|
766
|
+
if (responseHead.includes("101 Switching Protocols")) {
|
|
767
|
+
finish(true);
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
socket.on("error", () => finish(false));
|
|
771
|
+
socket.on("close", () => finish(false));
|
|
772
|
+
setTimeout(() => finish(false), 1_000);
|
|
773
|
+
});
|
|
774
|
+
|
|
775
|
+
expect(opened).toBe(false);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it("returns 405 for unsupported methods on /api/terminal-snapshots", async () => {
|
|
779
|
+
const baseUrl = await startServer();
|
|
780
|
+
|
|
781
|
+
const response = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
782
|
+
method: "POST",
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
expect(response.status).toBe(405);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
it("sanitizes unexpected internal errors from API responses", async () => {
|
|
789
|
+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
790
|
+
const baseUrl = await startServer();
|
|
791
|
+
|
|
792
|
+
const response = await fetch(`${baseUrl}/api/terminals/%E0%A4%A`, {
|
|
793
|
+
method: "DELETE",
|
|
794
|
+
headers: {
|
|
795
|
+
Accept: "application/json",
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
expect(response.status).toBe(500);
|
|
800
|
+
await expect(response.json()).resolves.toEqual({
|
|
801
|
+
error: "Internal server error",
|
|
802
|
+
});
|
|
803
|
+
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it("returns codex usage snapshot for GET /api/codex/usage", async () => {
|
|
807
|
+
const codexSnapshot = {
|
|
808
|
+
status: "ok",
|
|
809
|
+
source: "oauth-api",
|
|
810
|
+
fetchedAt: "2026-02-25T12:00:00.000Z",
|
|
811
|
+
planType: "pro",
|
|
812
|
+
primaryUsedPercent: 12,
|
|
813
|
+
secondaryUsedPercent: 28,
|
|
814
|
+
creditsBalance: 88.5,
|
|
815
|
+
creditsUnlimited: false,
|
|
816
|
+
} as const;
|
|
817
|
+
|
|
818
|
+
const baseUrl = await startServer({
|
|
819
|
+
readCodexUsageSnapshot: async () => codexSnapshot,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
const response = await fetch(`${baseUrl}/api/codex/usage`, {
|
|
823
|
+
method: "GET",
|
|
824
|
+
headers: {
|
|
825
|
+
Accept: "application/json",
|
|
826
|
+
},
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
expect(response.status).toBe(200);
|
|
830
|
+
await expect(response.json()).resolves.toEqual(codexSnapshot);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it("returns claude usage snapshot for GET /api/claude/usage", async () => {
|
|
834
|
+
const claudeSnapshot = {
|
|
835
|
+
status: "ok",
|
|
836
|
+
source: "oauth-api",
|
|
837
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
838
|
+
planType: "pro",
|
|
839
|
+
primaryUsedPercent: 11,
|
|
840
|
+
primaryResetAt: "2026-03-03T15:00:00.000Z",
|
|
841
|
+
secondaryUsedPercent: 27,
|
|
842
|
+
secondaryResetAt: "2026-03-05T00:00:00.000Z",
|
|
843
|
+
sonnetUsedPercent: 19,
|
|
844
|
+
sonnetResetAt: "2026-03-05T00:00:00.000Z",
|
|
845
|
+
} as const;
|
|
846
|
+
|
|
847
|
+
const baseUrl = await startServer({
|
|
848
|
+
readClaudeUsageSnapshot: async () => claudeSnapshot,
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
const response = await fetch(`${baseUrl}/api/claude/usage`, {
|
|
852
|
+
method: "GET",
|
|
853
|
+
headers: {
|
|
854
|
+
Accept: "application/json",
|
|
855
|
+
},
|
|
856
|
+
});
|
|
857
|
+
|
|
858
|
+
expect(response.status).toBe(200);
|
|
859
|
+
await expect(response.json()).resolves.toEqual(claudeSnapshot);
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("returns oauth claude usage snapshot for GET /api/claude/usage/oauth", async () => {
|
|
863
|
+
const claudeSnapshot = {
|
|
864
|
+
status: "ok",
|
|
865
|
+
source: "oauth-api",
|
|
866
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
867
|
+
primaryUsedPercent: 11,
|
|
868
|
+
secondaryUsedPercent: 27,
|
|
869
|
+
} as const;
|
|
870
|
+
|
|
871
|
+
const baseUrl = await startServer({
|
|
872
|
+
readClaudeOauthUsageSnapshot: async () => claudeSnapshot,
|
|
873
|
+
});
|
|
874
|
+
|
|
875
|
+
const response = await fetch(`${baseUrl}/api/claude/usage/oauth`, {
|
|
876
|
+
method: "GET",
|
|
877
|
+
headers: {
|
|
878
|
+
Accept: "application/json",
|
|
879
|
+
},
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
expect(response.status).toBe(200);
|
|
883
|
+
await expect(response.json()).resolves.toEqual(claudeSnapshot);
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
it("returns cli claude usage snapshot for GET /api/claude/usage/cli", async () => {
|
|
887
|
+
const claudeSnapshot = {
|
|
888
|
+
status: "ok",
|
|
889
|
+
source: "cli-pty",
|
|
890
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
891
|
+
primaryUsedPercent: 9,
|
|
892
|
+
secondaryUsedPercent: 22,
|
|
893
|
+
sonnetUsedPercent: 14,
|
|
894
|
+
} as const;
|
|
895
|
+
|
|
896
|
+
const baseUrl = await startServer({
|
|
897
|
+
readClaudeCliUsageSnapshot: async () => claudeSnapshot,
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const response = await fetch(`${baseUrl}/api/claude/usage/cli`, {
|
|
901
|
+
method: "GET",
|
|
902
|
+
headers: {
|
|
903
|
+
Accept: "application/json",
|
|
904
|
+
},
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
expect(response.status).toBe(200);
|
|
908
|
+
await expect(response.json()).resolves.toEqual(claudeSnapshot);
|
|
909
|
+
});
|
|
910
|
+
|
|
911
|
+
it("returns github summary for GET /api/github/summary", async () => {
|
|
912
|
+
const githubSummary: GitHubRepoSummarySnapshot = {
|
|
913
|
+
status: "ok",
|
|
914
|
+
fetchedAt: "2026-02-27T12:00:00.000Z",
|
|
915
|
+
source: "gh-cli",
|
|
916
|
+
repo: "hesamsheikh/octogent",
|
|
917
|
+
stargazerCount: 42,
|
|
918
|
+
openIssueCount: 7,
|
|
919
|
+
openPullRequestCount: 3,
|
|
920
|
+
commitsPerDay: [
|
|
921
|
+
{ date: "2026-02-25", count: 4 },
|
|
922
|
+
{ date: "2026-02-26", count: 6 },
|
|
923
|
+
{ date: "2026-02-27", count: 8 },
|
|
924
|
+
],
|
|
925
|
+
recentCommits: [
|
|
926
|
+
{
|
|
927
|
+
hash: "d8f2d9b7aa9f53f8fa254d8e0f3a13270435e321",
|
|
928
|
+
shortHash: "d8f2d9b",
|
|
929
|
+
subject: "tighten monitor polling backoff strategy",
|
|
930
|
+
authorName: "Hesam Sheikh",
|
|
931
|
+
authorEmail: "hesam@example.com",
|
|
932
|
+
authoredAt: "2026-02-27T10:12:00.000Z",
|
|
933
|
+
body: "Reduce the backoff multiplier from 2x to 1.5x to improve\nresponsiveness when rate limits recover.",
|
|
934
|
+
filesChanged: 3,
|
|
935
|
+
insertions: 42,
|
|
936
|
+
deletions: 7,
|
|
937
|
+
},
|
|
938
|
+
],
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
const baseUrl = await startServer({
|
|
942
|
+
readGithubRepoSummary: async () => githubSummary,
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
const response = await fetch(`${baseUrl}/api/github/summary`, {
|
|
946
|
+
method: "GET",
|
|
947
|
+
headers: {
|
|
948
|
+
Accept: "application/json",
|
|
949
|
+
},
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
expect(response.status).toBe(200);
|
|
953
|
+
await expect(response.json()).resolves.toEqual(githubSummary);
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
it("returns 405 for unsupported methods on /api/codex/usage", async () => {
|
|
957
|
+
const baseUrl = await startServer({
|
|
958
|
+
readCodexUsageSnapshot: async () => ({
|
|
959
|
+
status: "unavailable",
|
|
960
|
+
source: "none",
|
|
961
|
+
fetchedAt: "2026-02-25T12:00:00.000Z",
|
|
962
|
+
}),
|
|
963
|
+
});
|
|
964
|
+
|
|
965
|
+
const response = await fetch(`${baseUrl}/api/codex/usage`, {
|
|
966
|
+
method: "POST",
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
expect(response.status).toBe(405);
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
it("returns 405 for unsupported methods on /api/claude/usage", async () => {
|
|
973
|
+
const baseUrl = await startServer({
|
|
974
|
+
readClaudeUsageSnapshot: async () => ({
|
|
975
|
+
status: "unavailable",
|
|
976
|
+
source: "none",
|
|
977
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
978
|
+
}),
|
|
979
|
+
});
|
|
980
|
+
|
|
981
|
+
const response = await fetch(`${baseUrl}/api/claude/usage`, {
|
|
982
|
+
method: "POST",
|
|
983
|
+
});
|
|
984
|
+
|
|
985
|
+
expect(response.status).toBe(405);
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
it("returns 405 for unsupported methods on /api/claude/usage/oauth", async () => {
|
|
989
|
+
const baseUrl = await startServer({
|
|
990
|
+
readClaudeOauthUsageSnapshot: async () => ({
|
|
991
|
+
status: "unavailable",
|
|
992
|
+
source: "none",
|
|
993
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
994
|
+
}),
|
|
995
|
+
});
|
|
996
|
+
|
|
997
|
+
const response = await fetch(`${baseUrl}/api/claude/usage/oauth`, {
|
|
998
|
+
method: "POST",
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(response.status).toBe(405);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("returns 405 for unsupported methods on /api/claude/usage/cli", async () => {
|
|
1005
|
+
const baseUrl = await startServer({
|
|
1006
|
+
readClaudeCliUsageSnapshot: async () => ({
|
|
1007
|
+
status: "unavailable",
|
|
1008
|
+
source: "none",
|
|
1009
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
1010
|
+
}),
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
const response = await fetch(`${baseUrl}/api/claude/usage/cli`, {
|
|
1014
|
+
method: "POST",
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
expect(response.status).toBe(405);
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it("POST /api/hooks/session-start invalidates claude usage cache", async () => {
|
|
1021
|
+
let callCount = 0;
|
|
1022
|
+
const readClaudeUsageSnapshot = async () => {
|
|
1023
|
+
callCount++;
|
|
1024
|
+
return {
|
|
1025
|
+
status: "ok" as const,
|
|
1026
|
+
source: "oauth-api" as const,
|
|
1027
|
+
fetchedAt: "2026-03-03T12:00:00.000Z",
|
|
1028
|
+
planType: "pro",
|
|
1029
|
+
primaryUsedPercent: callCount * 10,
|
|
1030
|
+
secondaryUsedPercent: 50,
|
|
1031
|
+
sonnetUsedPercent: 30,
|
|
1032
|
+
};
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1035
|
+
const invalidateCalls: number[] = [];
|
|
1036
|
+
const invalidateClaudeUsageCache = () => {
|
|
1037
|
+
invalidateCalls.push(Date.now());
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
const baseUrl = await startServer({
|
|
1041
|
+
readClaudeUsageSnapshot,
|
|
1042
|
+
invalidateClaudeUsageCache,
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
// First GET — callCount becomes 1
|
|
1046
|
+
const first = await fetch(`${baseUrl}/api/claude/usage`, {
|
|
1047
|
+
method: "GET",
|
|
1048
|
+
headers: { Accept: "application/json" },
|
|
1049
|
+
});
|
|
1050
|
+
expect(first.status).toBe(200);
|
|
1051
|
+
const firstBody = (await first.json()) as { primaryUsedPercent: number };
|
|
1052
|
+
expect(firstBody.primaryUsedPercent).toBe(10);
|
|
1053
|
+
|
|
1054
|
+
// POST hook — should invalidate and warm cache
|
|
1055
|
+
const hookResponse = await fetch(`${baseUrl}/api/hooks/session-start`, {
|
|
1056
|
+
method: "POST",
|
|
1057
|
+
headers: { "Content-Type": "application/json" },
|
|
1058
|
+
body: JSON.stringify({ session_id: "test-session" }),
|
|
1059
|
+
});
|
|
1060
|
+
expect(hookResponse.status).toBe(200);
|
|
1061
|
+
expect(invalidateCalls.length).toBe(1);
|
|
1062
|
+
|
|
1063
|
+
// Next GET triggers a fresh read (callCount incremented again)
|
|
1064
|
+
const second = await fetch(`${baseUrl}/api/claude/usage`, {
|
|
1065
|
+
method: "GET",
|
|
1066
|
+
headers: { Accept: "application/json" },
|
|
1067
|
+
});
|
|
1068
|
+
expect(second.status).toBe(200);
|
|
1069
|
+
const secondBody = (await second.json()) as { primaryUsedPercent: number };
|
|
1070
|
+
// callCount > 2 confirms the warm call + this GET both invoked the reader
|
|
1071
|
+
expect(secondBody.primaryUsedPercent).toBeGreaterThan(10);
|
|
1072
|
+
});
|
|
1073
|
+
|
|
1074
|
+
it("POST /api/hooks/user-prompt-submit auto-renames generated default terminal names", async () => {
|
|
1075
|
+
const baseUrl = await startServer();
|
|
1076
|
+
|
|
1077
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1078
|
+
method: "POST",
|
|
1079
|
+
headers: {
|
|
1080
|
+
Accept: "application/json",
|
|
1081
|
+
},
|
|
1082
|
+
});
|
|
1083
|
+
expect(createResponse.status).toBe(201);
|
|
1084
|
+
|
|
1085
|
+
const hookResponse = await fetch(
|
|
1086
|
+
`${baseUrl}/api/hooks/user-prompt-submit?octogent_session=terminal-1`,
|
|
1087
|
+
{
|
|
1088
|
+
method: "POST",
|
|
1089
|
+
headers: { "Content-Type": "application/json" },
|
|
1090
|
+
body: JSON.stringify({ prompt: "Investigate flaky CI failures" }),
|
|
1091
|
+
},
|
|
1092
|
+
);
|
|
1093
|
+
expect(hookResponse.status).toBe(200);
|
|
1094
|
+
|
|
1095
|
+
const secondHookResponse = await fetch(
|
|
1096
|
+
`${baseUrl}/api/hooks/user-prompt-submit?octogent_session=terminal-1`,
|
|
1097
|
+
{
|
|
1098
|
+
method: "POST",
|
|
1099
|
+
headers: { "Content-Type": "application/json" },
|
|
1100
|
+
body: JSON.stringify({ prompt: "Something else later" }),
|
|
1101
|
+
},
|
|
1102
|
+
);
|
|
1103
|
+
expect(secondHookResponse.status).toBe(200);
|
|
1104
|
+
|
|
1105
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1106
|
+
method: "GET",
|
|
1107
|
+
headers: {
|
|
1108
|
+
Accept: "application/json",
|
|
1109
|
+
},
|
|
1110
|
+
});
|
|
1111
|
+
expect(listResponse.status).toBe(200);
|
|
1112
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
1113
|
+
expect.arrayContaining([
|
|
1114
|
+
expect.objectContaining({
|
|
1115
|
+
terminalId: "terminal-1",
|
|
1116
|
+
tentacleName: "Investigate flaky CI failures",
|
|
1117
|
+
}),
|
|
1118
|
+
]),
|
|
1119
|
+
);
|
|
1120
|
+
});
|
|
1121
|
+
|
|
1122
|
+
it("POST /api/hooks/user-prompt-submit preserves explicit terminal names", async () => {
|
|
1123
|
+
const baseUrl = await startServer();
|
|
1124
|
+
|
|
1125
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1126
|
+
method: "POST",
|
|
1127
|
+
headers: {
|
|
1128
|
+
Accept: "application/json",
|
|
1129
|
+
"Content-Type": "application/json",
|
|
1130
|
+
},
|
|
1131
|
+
body: JSON.stringify({ name: "reviewer" }),
|
|
1132
|
+
});
|
|
1133
|
+
expect(createResponse.status).toBe(201);
|
|
1134
|
+
|
|
1135
|
+
const hookResponse = await fetch(
|
|
1136
|
+
`${baseUrl}/api/hooks/user-prompt-submit?octogent_session=terminal-1`,
|
|
1137
|
+
{
|
|
1138
|
+
method: "POST",
|
|
1139
|
+
headers: { "Content-Type": "application/json" },
|
|
1140
|
+
body: JSON.stringify({ prompt: "Investigate flaky CI failures" }),
|
|
1141
|
+
},
|
|
1142
|
+
);
|
|
1143
|
+
expect(hookResponse.status).toBe(200);
|
|
1144
|
+
|
|
1145
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1146
|
+
method: "GET",
|
|
1147
|
+
headers: {
|
|
1148
|
+
Accept: "application/json",
|
|
1149
|
+
},
|
|
1150
|
+
});
|
|
1151
|
+
expect(listResponse.status).toBe(200);
|
|
1152
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
1153
|
+
expect.arrayContaining([
|
|
1154
|
+
expect.objectContaining({
|
|
1155
|
+
terminalId: "terminal-1",
|
|
1156
|
+
tentacleName: "reviewer",
|
|
1157
|
+
}),
|
|
1158
|
+
]),
|
|
1159
|
+
);
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
it("infers generated terminal names from older registry entries", async () => {
|
|
1163
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1164
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1165
|
+
const registryPath = join(workspaceCwd, ".octogent", "state", "tentacles.json");
|
|
1166
|
+
mkdirSync(join(workspaceCwd, ".octogent", "state"), { recursive: true });
|
|
1167
|
+
writeFileSync(
|
|
1168
|
+
registryPath,
|
|
1169
|
+
`${JSON.stringify(
|
|
1170
|
+
{
|
|
1171
|
+
version: 3,
|
|
1172
|
+
terminals: [
|
|
1173
|
+
{
|
|
1174
|
+
terminalId: "terminal-1",
|
|
1175
|
+
tentacleId: "terminal-1",
|
|
1176
|
+
tentacleName: "Octogent Terminal 1",
|
|
1177
|
+
createdAt: "2026-04-10T10:00:00.000Z",
|
|
1178
|
+
workspaceMode: "shared",
|
|
1179
|
+
},
|
|
1180
|
+
],
|
|
1181
|
+
uiState: {},
|
|
1182
|
+
},
|
|
1183
|
+
null,
|
|
1184
|
+
2,
|
|
1185
|
+
)}\n`,
|
|
1186
|
+
"utf8",
|
|
1187
|
+
);
|
|
1188
|
+
|
|
1189
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1190
|
+
|
|
1191
|
+
const hookResponse = await fetch(
|
|
1192
|
+
`${baseUrl}/api/hooks/user-prompt-submit?octogent_session=terminal-1`,
|
|
1193
|
+
{
|
|
1194
|
+
method: "POST",
|
|
1195
|
+
headers: { "Content-Type": "application/json" },
|
|
1196
|
+
body: JSON.stringify({ prompt: "Investigate flaky CI failures" }),
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
expect(hookResponse.status).toBe(200);
|
|
1200
|
+
|
|
1201
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1202
|
+
method: "GET",
|
|
1203
|
+
headers: {
|
|
1204
|
+
Accept: "application/json",
|
|
1205
|
+
},
|
|
1206
|
+
});
|
|
1207
|
+
expect(listResponse.status).toBe(200);
|
|
1208
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
1209
|
+
expect.arrayContaining([
|
|
1210
|
+
expect.objectContaining({
|
|
1211
|
+
terminalId: "terminal-1",
|
|
1212
|
+
tentacleName: "Investigate flaky CI failures",
|
|
1213
|
+
}),
|
|
1214
|
+
]),
|
|
1215
|
+
);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
it("returns 405 for unsupported methods on /api/github/summary", async () => {
|
|
1219
|
+
const baseUrl = await startServer({
|
|
1220
|
+
readGithubRepoSummary: async () => ({
|
|
1221
|
+
status: "unavailable",
|
|
1222
|
+
fetchedAt: "2026-02-27T12:00:00.000Z",
|
|
1223
|
+
source: "none",
|
|
1224
|
+
message: "GitHub CLI not available.",
|
|
1225
|
+
}),
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
const response = await fetch(`${baseUrl}/api/github/summary`, {
|
|
1229
|
+
method: "POST",
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
expect(response.status).toBe(405);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it("returns 405 for unsupported methods on /api/ui-state", async () => {
|
|
1236
|
+
const baseUrl = await startServer();
|
|
1237
|
+
|
|
1238
|
+
const response = await fetch(`${baseUrl}/api/ui-state`, {
|
|
1239
|
+
method: "POST",
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
expect(response.status).toBe(405);
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
it("reports file-backed workspace setup status and updates it through setup actions", async () => {
|
|
1246
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1247
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1248
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1249
|
+
|
|
1250
|
+
const initialResponse = await fetch(`${baseUrl}/api/setup`, {
|
|
1251
|
+
headers: { Accept: "application/json" },
|
|
1252
|
+
});
|
|
1253
|
+
expect(initialResponse.status).toBe(200);
|
|
1254
|
+
const initialPayload = (await initialResponse.json()) as {
|
|
1255
|
+
isFirstRun: boolean;
|
|
1256
|
+
shouldShowSetupCard: boolean;
|
|
1257
|
+
hasAnyTentacles: boolean;
|
|
1258
|
+
steps: Array<{ id: string; complete: boolean }>;
|
|
1259
|
+
};
|
|
1260
|
+
expect(existsSync(join(workspaceCwd, ".octogent"))).toBe(false);
|
|
1261
|
+
expect(existsSync(join(workspaceCwd, ".gitignore"))).toBe(false);
|
|
1262
|
+
expect(initialPayload.isFirstRun).toBe(true);
|
|
1263
|
+
expect(initialPayload.shouldShowSetupCard).toBe(true);
|
|
1264
|
+
expect(initialPayload.hasAnyTentacles).toBe(false);
|
|
1265
|
+
expect(initialPayload.steps).toEqual(
|
|
1266
|
+
expect.arrayContaining([
|
|
1267
|
+
expect.objectContaining({ id: "initialize-workspace", complete: false }),
|
|
1268
|
+
expect.objectContaining({ id: "ensure-gitignore", complete: false }),
|
|
1269
|
+
expect.objectContaining({ id: "create-tentacles", complete: false }),
|
|
1270
|
+
]),
|
|
1271
|
+
);
|
|
1272
|
+
|
|
1273
|
+
const initializeResponse = await fetch(`${baseUrl}/api/setup/steps/initialize-workspace`, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: { Accept: "application/json" },
|
|
1276
|
+
});
|
|
1277
|
+
expect(initializeResponse.status).toBe(200);
|
|
1278
|
+
expect(existsSync(join(workspaceCwd, ".octogent", "project.json"))).toBe(true);
|
|
1279
|
+
expect(existsSync(join(workspaceCwd, ".octogent", "tentacles"))).toBe(true);
|
|
1280
|
+
expect(existsSync(join(workspaceCwd, ".octogent", "worktrees"))).toBe(true);
|
|
1281
|
+
|
|
1282
|
+
const gitignoreResponse = await fetch(`${baseUrl}/api/setup/steps/ensure-gitignore`, {
|
|
1283
|
+
method: "POST",
|
|
1284
|
+
headers: { Accept: "application/json" },
|
|
1285
|
+
});
|
|
1286
|
+
expect(gitignoreResponse.status).toBe(200);
|
|
1287
|
+
expect(readFileSync(join(workspaceCwd, ".gitignore"), "utf8")).toContain(".octogent");
|
|
1288
|
+
|
|
1289
|
+
const createTentacleResponse = await fetch(`${baseUrl}/api/deck/tentacles`, {
|
|
1290
|
+
method: "POST",
|
|
1291
|
+
headers: {
|
|
1292
|
+
Accept: "application/json",
|
|
1293
|
+
"Content-Type": "application/json",
|
|
1294
|
+
},
|
|
1295
|
+
body: JSON.stringify({
|
|
1296
|
+
name: "docs",
|
|
1297
|
+
description: "Docs and guides",
|
|
1298
|
+
}),
|
|
1299
|
+
});
|
|
1300
|
+
expect(createTentacleResponse.status).toBe(201);
|
|
1301
|
+
|
|
1302
|
+
const finalResponse = await fetch(`${baseUrl}/api/setup`, {
|
|
1303
|
+
headers: { Accept: "application/json" },
|
|
1304
|
+
});
|
|
1305
|
+
expect(finalResponse.status).toBe(200);
|
|
1306
|
+
const finalPayload = (await finalResponse.json()) as {
|
|
1307
|
+
isFirstRun: boolean;
|
|
1308
|
+
hasAnyTentacles: boolean;
|
|
1309
|
+
tentacleCount: number;
|
|
1310
|
+
steps: Array<{ id: string; complete: boolean }>;
|
|
1311
|
+
};
|
|
1312
|
+
expect(finalPayload.isFirstRun).toBe(false);
|
|
1313
|
+
expect(finalPayload.hasAnyTentacles).toBe(true);
|
|
1314
|
+
expect(finalPayload.tentacleCount).toBe(1);
|
|
1315
|
+
expect(finalPayload.steps).toEqual(
|
|
1316
|
+
expect.arrayContaining([
|
|
1317
|
+
expect.objectContaining({ id: "initialize-workspace", complete: true }),
|
|
1318
|
+
expect.objectContaining({ id: "ensure-gitignore", complete: true }),
|
|
1319
|
+
expect.objectContaining({ id: "create-tentacles", complete: true }),
|
|
1320
|
+
]),
|
|
1321
|
+
);
|
|
1322
|
+
});
|
|
1323
|
+
|
|
1324
|
+
it("returns 413 when create tentacle body exceeds size limit", async () => {
|
|
1325
|
+
const baseUrl = await startServer();
|
|
1326
|
+
|
|
1327
|
+
const response = await fetch(`${baseUrl}/api/terminals`, {
|
|
1328
|
+
method: "POST",
|
|
1329
|
+
headers: {
|
|
1330
|
+
Accept: "application/json",
|
|
1331
|
+
"Content-Type": "application/json",
|
|
1332
|
+
},
|
|
1333
|
+
body: JSON.stringify({
|
|
1334
|
+
name: "x".repeat(1024 * 1024 + 1),
|
|
1335
|
+
}),
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
expect(response.status).toBe(413);
|
|
1339
|
+
await expect(response.json()).resolves.toEqual({
|
|
1340
|
+
error: "Request body too large.",
|
|
1341
|
+
});
|
|
1342
|
+
});
|
|
1343
|
+
|
|
1344
|
+
it("returns 413 when ui-state patch body exceeds size limit", async () => {
|
|
1345
|
+
const baseUrl = await startServer();
|
|
1346
|
+
|
|
1347
|
+
const response = await fetch(`${baseUrl}/api/ui-state`, {
|
|
1348
|
+
method: "PATCH",
|
|
1349
|
+
headers: {
|
|
1350
|
+
Accept: "application/json",
|
|
1351
|
+
"Content-Type": "application/json",
|
|
1352
|
+
},
|
|
1353
|
+
body: JSON.stringify({
|
|
1354
|
+
minimizedTerminalIds: ["terminal-1"],
|
|
1355
|
+
blob: "x".repeat(1024 * 1024 + 1),
|
|
1356
|
+
}),
|
|
1357
|
+
});
|
|
1358
|
+
|
|
1359
|
+
expect(response.status).toBe(413);
|
|
1360
|
+
await expect(response.json()).resolves.toEqual({
|
|
1361
|
+
error: "Request body too large.",
|
|
1362
|
+
});
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
it("lists Claude skills from the project skills folder", async () => {
|
|
1366
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1367
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1368
|
+
const projectSkillDir = join(workspaceCwd, ".claude", "skills", "docs-writer");
|
|
1369
|
+
mkdirSync(projectSkillDir, { recursive: true });
|
|
1370
|
+
writeFileSync(
|
|
1371
|
+
join(projectSkillDir, "SKILL.md"),
|
|
1372
|
+
[
|
|
1373
|
+
"---",
|
|
1374
|
+
"name: docs-writer",
|
|
1375
|
+
"description: Helps keep docs aligned with product changes.",
|
|
1376
|
+
"---",
|
|
1377
|
+
"",
|
|
1378
|
+
"# Docs Writer",
|
|
1379
|
+
"",
|
|
1380
|
+
"Writes and updates docs.",
|
|
1381
|
+
"",
|
|
1382
|
+
].join("\n"),
|
|
1383
|
+
"utf8",
|
|
1384
|
+
);
|
|
1385
|
+
|
|
1386
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1387
|
+
const response = await fetch(`${baseUrl}/api/deck/skills`, {
|
|
1388
|
+
headers: { Accept: "application/json" },
|
|
1389
|
+
});
|
|
1390
|
+
|
|
1391
|
+
expect(response.status).toBe(200);
|
|
1392
|
+
await expect(response.json()).resolves.toEqual(
|
|
1393
|
+
expect.arrayContaining([
|
|
1394
|
+
expect.objectContaining({
|
|
1395
|
+
name: "docs-writer",
|
|
1396
|
+
description: "Helps keep docs aligned with product changes.",
|
|
1397
|
+
source: "project",
|
|
1398
|
+
}),
|
|
1399
|
+
]),
|
|
1400
|
+
);
|
|
1401
|
+
});
|
|
1402
|
+
|
|
1403
|
+
it("ignores a root project skills SKILL.md file and only lists folder-based skills", async () => {
|
|
1404
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1405
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1406
|
+
const skillsDir = join(workspaceCwd, ".claude", "skills");
|
|
1407
|
+
mkdirSync(skillsDir, { recursive: true });
|
|
1408
|
+
writeFileSync(
|
|
1409
|
+
join(skillsDir, "SKILL.md"),
|
|
1410
|
+
[
|
|
1411
|
+
"---",
|
|
1412
|
+
"name: not-a-real-skill",
|
|
1413
|
+
"description: Should not be listed.",
|
|
1414
|
+
"---",
|
|
1415
|
+
"",
|
|
1416
|
+
"# Root Marker",
|
|
1417
|
+
"",
|
|
1418
|
+
].join("\n"),
|
|
1419
|
+
"utf8",
|
|
1420
|
+
);
|
|
1421
|
+
mkdirSync(join(skillsDir, "docs-writer"), { recursive: true });
|
|
1422
|
+
writeFileSync(
|
|
1423
|
+
join(skillsDir, "docs-writer", "SKILL.md"),
|
|
1424
|
+
[
|
|
1425
|
+
"---",
|
|
1426
|
+
"name: docs-writer",
|
|
1427
|
+
"description: Helps keep docs aligned with product changes.",
|
|
1428
|
+
"---",
|
|
1429
|
+
"",
|
|
1430
|
+
].join("\n"),
|
|
1431
|
+
"utf8",
|
|
1432
|
+
);
|
|
1433
|
+
|
|
1434
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1435
|
+
const response = await fetch(`${baseUrl}/api/deck/skills`, {
|
|
1436
|
+
headers: { Accept: "application/json" },
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
expect(response.status).toBe(200);
|
|
1440
|
+
await expect(response.json()).resolves.toEqual([
|
|
1441
|
+
{
|
|
1442
|
+
name: "docs-writer",
|
|
1443
|
+
description: "Helps keep docs aligned with product changes.",
|
|
1444
|
+
source: "project",
|
|
1445
|
+
},
|
|
1446
|
+
]);
|
|
1447
|
+
});
|
|
1448
|
+
|
|
1449
|
+
it("creates tentacles with suggested skills and appends the managed context block", async () => {
|
|
1450
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1451
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1452
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1453
|
+
|
|
1454
|
+
const response = await fetch(`${baseUrl}/api/deck/tentacles`, {
|
|
1455
|
+
method: "POST",
|
|
1456
|
+
headers: {
|
|
1457
|
+
Accept: "application/json",
|
|
1458
|
+
"Content-Type": "application/json",
|
|
1459
|
+
},
|
|
1460
|
+
body: JSON.stringify({
|
|
1461
|
+
name: "docs",
|
|
1462
|
+
description: "Docs and guides",
|
|
1463
|
+
suggestedSkills: ["release-helper", "docs-writer"],
|
|
1464
|
+
}),
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
expect(response.status).toBe(201);
|
|
1468
|
+
await expect(response.json()).resolves.toEqual(
|
|
1469
|
+
expect.objectContaining({
|
|
1470
|
+
tentacleId: "docs",
|
|
1471
|
+
suggestedSkills: ["docs-writer", "release-helper"],
|
|
1472
|
+
}),
|
|
1473
|
+
);
|
|
1474
|
+
|
|
1475
|
+
const context = readFileSync(
|
|
1476
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs", "CONTEXT.md"),
|
|
1477
|
+
"utf8",
|
|
1478
|
+
);
|
|
1479
|
+
expect(context).toContain("## Suggested Skills");
|
|
1480
|
+
expect(context).toContain("You can use these skills if you need to.");
|
|
1481
|
+
expect(context).toContain("- `docs-writer`");
|
|
1482
|
+
expect(context).toContain("- `release-helper`");
|
|
1483
|
+
|
|
1484
|
+
const listResponse = await fetch(`${baseUrl}/api/deck/tentacles`, {
|
|
1485
|
+
headers: { Accept: "application/json" },
|
|
1486
|
+
});
|
|
1487
|
+
expect(listResponse.status).toBe(200);
|
|
1488
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
1489
|
+
expect.arrayContaining([
|
|
1490
|
+
expect.objectContaining({
|
|
1491
|
+
tentacleId: "docs",
|
|
1492
|
+
suggestedSkills: ["docs-writer", "release-helper"],
|
|
1493
|
+
}),
|
|
1494
|
+
]),
|
|
1495
|
+
);
|
|
1496
|
+
});
|
|
1497
|
+
|
|
1498
|
+
it("updates tentacle suggested skills and removes the managed context block when cleared", async () => {
|
|
1499
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1500
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1501
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
1502
|
+
|
|
1503
|
+
const createResponse = await fetch(`${baseUrl}/api/deck/tentacles`, {
|
|
1504
|
+
method: "POST",
|
|
1505
|
+
headers: {
|
|
1506
|
+
Accept: "application/json",
|
|
1507
|
+
"Content-Type": "application/json",
|
|
1508
|
+
},
|
|
1509
|
+
body: JSON.stringify({
|
|
1510
|
+
name: "docs",
|
|
1511
|
+
description: "Docs and guides",
|
|
1512
|
+
}),
|
|
1513
|
+
});
|
|
1514
|
+
expect(createResponse.status).toBe(201);
|
|
1515
|
+
|
|
1516
|
+
const updateResponse = await fetch(`${baseUrl}/api/deck/tentacles/docs/skills`, {
|
|
1517
|
+
method: "PATCH",
|
|
1518
|
+
headers: {
|
|
1519
|
+
Accept: "application/json",
|
|
1520
|
+
"Content-Type": "application/json",
|
|
1521
|
+
},
|
|
1522
|
+
body: JSON.stringify({
|
|
1523
|
+
suggestedSkills: ["code-review-specialist"],
|
|
1524
|
+
}),
|
|
1525
|
+
});
|
|
1526
|
+
|
|
1527
|
+
expect(updateResponse.status).toBe(200);
|
|
1528
|
+
await expect(updateResponse.json()).resolves.toEqual(
|
|
1529
|
+
expect.objectContaining({
|
|
1530
|
+
tentacleId: "docs",
|
|
1531
|
+
suggestedSkills: ["code-review-specialist"],
|
|
1532
|
+
}),
|
|
1533
|
+
);
|
|
1534
|
+
|
|
1535
|
+
const contextPath = join(workspaceCwd, ".octogent", "tentacles", "docs", "CONTEXT.md");
|
|
1536
|
+
expect(readFileSync(contextPath, "utf8")).toContain("- `code-review-specialist`");
|
|
1537
|
+
|
|
1538
|
+
const clearResponse = await fetch(`${baseUrl}/api/deck/tentacles/docs/skills`, {
|
|
1539
|
+
method: "PATCH",
|
|
1540
|
+
headers: {
|
|
1541
|
+
Accept: "application/json",
|
|
1542
|
+
"Content-Type": "application/json",
|
|
1543
|
+
},
|
|
1544
|
+
body: JSON.stringify({
|
|
1545
|
+
suggestedSkills: [],
|
|
1546
|
+
}),
|
|
1547
|
+
});
|
|
1548
|
+
|
|
1549
|
+
expect(clearResponse.status).toBe(200);
|
|
1550
|
+
await expect(clearResponse.json()).resolves.toEqual(
|
|
1551
|
+
expect.objectContaining({
|
|
1552
|
+
tentacleId: "docs",
|
|
1553
|
+
suggestedSkills: [],
|
|
1554
|
+
}),
|
|
1555
|
+
);
|
|
1556
|
+
expect(readFileSync(contextPath, "utf8")).not.toContain("## Suggested Skills");
|
|
1557
|
+
expect(readFileSync(contextPath, "utf8")).not.toContain("octogent:suggested-skills:start");
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
it("returns 400 for unsupported tentacle completion sound values", async () => {
|
|
1561
|
+
const baseUrl = await startServer();
|
|
1562
|
+
|
|
1563
|
+
const response = await fetch(`${baseUrl}/api/ui-state`, {
|
|
1564
|
+
method: "PATCH",
|
|
1565
|
+
headers: {
|
|
1566
|
+
Accept: "application/json",
|
|
1567
|
+
"Content-Type": "application/json",
|
|
1568
|
+
},
|
|
1569
|
+
body: JSON.stringify({
|
|
1570
|
+
terminalCompletionSound: "laser-zap",
|
|
1571
|
+
}),
|
|
1572
|
+
});
|
|
1573
|
+
|
|
1574
|
+
expect(response.status).toBe(400);
|
|
1575
|
+
await expect(response.json()).resolves.toEqual({
|
|
1576
|
+
error: "terminalCompletionSound must be one of the supported sound identifiers.",
|
|
1577
|
+
});
|
|
1578
|
+
});
|
|
1579
|
+
|
|
1580
|
+
it("restores ui state across API restarts using persisted registry", async () => {
|
|
1581
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1582
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1583
|
+
|
|
1584
|
+
const firstBaseUrl = await startServer({
|
|
1585
|
+
workspaceCwd,
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
const createResponse = await fetch(`${firstBaseUrl}/api/terminals`, {
|
|
1589
|
+
method: "POST",
|
|
1590
|
+
headers: {
|
|
1591
|
+
Accept: "application/json",
|
|
1592
|
+
},
|
|
1593
|
+
});
|
|
1594
|
+
expect(createResponse.status).toBe(201);
|
|
1595
|
+
|
|
1596
|
+
const patchResponse = await fetch(`${firstBaseUrl}/api/ui-state`, {
|
|
1597
|
+
method: "PATCH",
|
|
1598
|
+
headers: {
|
|
1599
|
+
Accept: "application/json",
|
|
1600
|
+
"Content-Type": "application/json",
|
|
1601
|
+
},
|
|
1602
|
+
body: JSON.stringify({
|
|
1603
|
+
isAgentsSidebarVisible: false,
|
|
1604
|
+
sidebarWidth: 380,
|
|
1605
|
+
isActiveAgentsSectionExpanded: false,
|
|
1606
|
+
isRuntimeStatusStripVisible: false,
|
|
1607
|
+
isMonitorVisible: false,
|
|
1608
|
+
isBottomTelemetryVisible: false,
|
|
1609
|
+
isCodexUsageVisible: false,
|
|
1610
|
+
isClaudeUsageVisible: false,
|
|
1611
|
+
isClaudeUsageSectionExpanded: false,
|
|
1612
|
+
isCodexUsageSectionExpanded: false,
|
|
1613
|
+
terminalCompletionSound: "double-beep",
|
|
1614
|
+
minimizedTerminalIds: ["terminal-1"],
|
|
1615
|
+
terminalWidths: {
|
|
1616
|
+
"terminal-1": 420,
|
|
1617
|
+
},
|
|
1618
|
+
}),
|
|
1619
|
+
});
|
|
1620
|
+
expect(patchResponse.status).toBe(200);
|
|
1621
|
+
await expect(patchResponse.json()).resolves.toEqual({
|
|
1622
|
+
isAgentsSidebarVisible: false,
|
|
1623
|
+
sidebarWidth: 380,
|
|
1624
|
+
isActiveAgentsSectionExpanded: false,
|
|
1625
|
+
isRuntimeStatusStripVisible: false,
|
|
1626
|
+
isMonitorVisible: false,
|
|
1627
|
+
isBottomTelemetryVisible: false,
|
|
1628
|
+
isCodexUsageVisible: false,
|
|
1629
|
+
isClaudeUsageVisible: false,
|
|
1630
|
+
isClaudeUsageSectionExpanded: false,
|
|
1631
|
+
isCodexUsageSectionExpanded: false,
|
|
1632
|
+
terminalCompletionSound: "double-beep",
|
|
1633
|
+
minimizedTerminalIds: ["terminal-1"],
|
|
1634
|
+
terminalWidths: {
|
|
1635
|
+
"terminal-1": 420,
|
|
1636
|
+
},
|
|
1637
|
+
});
|
|
1638
|
+
|
|
1639
|
+
if (stopServer) {
|
|
1640
|
+
await stopServer();
|
|
1641
|
+
stopServer = null;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
const secondBaseUrl = await startServer({
|
|
1645
|
+
workspaceCwd,
|
|
1646
|
+
});
|
|
1647
|
+
|
|
1648
|
+
const getResponse = await fetch(`${secondBaseUrl}/api/ui-state`, {
|
|
1649
|
+
method: "GET",
|
|
1650
|
+
headers: {
|
|
1651
|
+
Accept: "application/json",
|
|
1652
|
+
},
|
|
1653
|
+
});
|
|
1654
|
+
|
|
1655
|
+
expect(getResponse.status).toBe(200);
|
|
1656
|
+
await expect(getResponse.json()).resolves.toEqual({
|
|
1657
|
+
isAgentsSidebarVisible: false,
|
|
1658
|
+
sidebarWidth: 380,
|
|
1659
|
+
isActiveAgentsSectionExpanded: false,
|
|
1660
|
+
isRuntimeStatusStripVisible: false,
|
|
1661
|
+
isMonitorVisible: false,
|
|
1662
|
+
isBottomTelemetryVisible: false,
|
|
1663
|
+
isCodexUsageVisible: false,
|
|
1664
|
+
isClaudeUsageVisible: false,
|
|
1665
|
+
isClaudeUsageSectionExpanded: false,
|
|
1666
|
+
isCodexUsageSectionExpanded: false,
|
|
1667
|
+
terminalCompletionSound: "double-beep",
|
|
1668
|
+
minimizedTerminalIds: ["terminal-1"],
|
|
1669
|
+
terminalWidths: {
|
|
1670
|
+
"terminal-1": 420,
|
|
1671
|
+
},
|
|
1672
|
+
});
|
|
1673
|
+
});
|
|
1674
|
+
|
|
1675
|
+
it("creates new tentacles with unique incremental ids", async () => {
|
|
1676
|
+
const baseUrl = await startServer();
|
|
1677
|
+
|
|
1678
|
+
const createFirstResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1679
|
+
method: "POST",
|
|
1680
|
+
headers: {
|
|
1681
|
+
Accept: "application/json",
|
|
1682
|
+
"Content-Type": "application/json",
|
|
1683
|
+
},
|
|
1684
|
+
body: JSON.stringify({ name: "planner" }),
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1687
|
+
expect(createFirstResponse.status).toBe(201);
|
|
1688
|
+
await expect(createFirstResponse.json()).resolves.toEqual(
|
|
1689
|
+
expect.objectContaining({
|
|
1690
|
+
terminalId: "terminal-1",
|
|
1691
|
+
label: "terminal-1",
|
|
1692
|
+
state: "live",
|
|
1693
|
+
tentacleId: "terminal-1",
|
|
1694
|
+
tentacleName: "planner",
|
|
1695
|
+
workspaceMode: "shared",
|
|
1696
|
+
}),
|
|
1697
|
+
);
|
|
1698
|
+
|
|
1699
|
+
const createSecondResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1700
|
+
method: "POST",
|
|
1701
|
+
headers: {
|
|
1702
|
+
Accept: "application/json",
|
|
1703
|
+
},
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
expect(createSecondResponse.status).toBe(201);
|
|
1707
|
+
await expect(createSecondResponse.json()).resolves.toEqual(
|
|
1708
|
+
expect.objectContaining({
|
|
1709
|
+
terminalId: "terminal-2",
|
|
1710
|
+
label: "terminal-2",
|
|
1711
|
+
state: "live",
|
|
1712
|
+
tentacleId: "terminal-2",
|
|
1713
|
+
tentacleName: "Octogent Terminal 1",
|
|
1714
|
+
workspaceMode: "shared",
|
|
1715
|
+
}),
|
|
1716
|
+
);
|
|
1717
|
+
|
|
1718
|
+
const renameResponse = await fetch(`${baseUrl}/api/terminals/terminal-2`, {
|
|
1719
|
+
method: "PATCH",
|
|
1720
|
+
headers: {
|
|
1721
|
+
Accept: "application/json",
|
|
1722
|
+
"Content-Type": "application/json",
|
|
1723
|
+
},
|
|
1724
|
+
body: JSON.stringify({ name: "reviewer" }),
|
|
1725
|
+
});
|
|
1726
|
+
|
|
1727
|
+
expect(renameResponse.status).toBe(200);
|
|
1728
|
+
await expect(renameResponse.json()).resolves.toEqual(
|
|
1729
|
+
expect.objectContaining({
|
|
1730
|
+
tentacleId: "terminal-2",
|
|
1731
|
+
tentacleName: "reviewer",
|
|
1732
|
+
}),
|
|
1733
|
+
);
|
|
1734
|
+
|
|
1735
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1736
|
+
method: "GET",
|
|
1737
|
+
headers: {
|
|
1738
|
+
Accept: "application/json",
|
|
1739
|
+
},
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
expect(listResponse.status).toBe(200);
|
|
1743
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
1744
|
+
expect.arrayContaining([
|
|
1745
|
+
expect.objectContaining({
|
|
1746
|
+
terminalId: "terminal-1",
|
|
1747
|
+
tentacleId: "terminal-1",
|
|
1748
|
+
tentacleName: "planner",
|
|
1749
|
+
workspaceMode: "shared",
|
|
1750
|
+
}),
|
|
1751
|
+
expect.objectContaining({
|
|
1752
|
+
terminalId: "terminal-2",
|
|
1753
|
+
tentacleId: "terminal-2",
|
|
1754
|
+
tentacleName: "reviewer",
|
|
1755
|
+
workspaceMode: "shared",
|
|
1756
|
+
}),
|
|
1757
|
+
]),
|
|
1758
|
+
);
|
|
1759
|
+
});
|
|
1760
|
+
|
|
1761
|
+
it("reuses the minimum available tentacle number after deletions", async () => {
|
|
1762
|
+
const baseUrl = await startServer();
|
|
1763
|
+
|
|
1764
|
+
const createFirstResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1765
|
+
method: "POST",
|
|
1766
|
+
headers: {
|
|
1767
|
+
Accept: "application/json",
|
|
1768
|
+
},
|
|
1769
|
+
});
|
|
1770
|
+
expect(createFirstResponse.status).toBe(201);
|
|
1771
|
+
|
|
1772
|
+
const createSecondResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1773
|
+
method: "POST",
|
|
1774
|
+
headers: {
|
|
1775
|
+
Accept: "application/json",
|
|
1776
|
+
},
|
|
1777
|
+
});
|
|
1778
|
+
expect(createSecondResponse.status).toBe(201);
|
|
1779
|
+
|
|
1780
|
+
const deleteFirstResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
1781
|
+
method: "DELETE",
|
|
1782
|
+
headers: {
|
|
1783
|
+
Accept: "application/json",
|
|
1784
|
+
},
|
|
1785
|
+
});
|
|
1786
|
+
expect(deleteFirstResponse.status).toBe(204);
|
|
1787
|
+
|
|
1788
|
+
const createThirdResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1789
|
+
method: "POST",
|
|
1790
|
+
headers: {
|
|
1791
|
+
Accept: "application/json",
|
|
1792
|
+
},
|
|
1793
|
+
});
|
|
1794
|
+
expect(createThirdResponse.status).toBe(201);
|
|
1795
|
+
await expect(createThirdResponse.json()).resolves.toEqual(
|
|
1796
|
+
expect.objectContaining({
|
|
1797
|
+
tentacleId: "terminal-1",
|
|
1798
|
+
}),
|
|
1799
|
+
);
|
|
1800
|
+
});
|
|
1801
|
+
|
|
1802
|
+
it("ignores stale persisted nextTentacleNumber values and starts from the minimum available id", async () => {
|
|
1803
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1804
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1805
|
+
const registryPath = join(workspaceCwd, ".octogent", "state", "tentacles.json");
|
|
1806
|
+
mkdirSync(join(workspaceCwd, ".octogent", "state"), { recursive: true });
|
|
1807
|
+
writeFileSync(
|
|
1808
|
+
registryPath,
|
|
1809
|
+
`${JSON.stringify(
|
|
1810
|
+
{
|
|
1811
|
+
version: 2,
|
|
1812
|
+
nextTentacleNumber: 19,
|
|
1813
|
+
tentacles: [],
|
|
1814
|
+
},
|
|
1815
|
+
null,
|
|
1816
|
+
2,
|
|
1817
|
+
)}\n`,
|
|
1818
|
+
"utf8",
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1821
|
+
const baseUrl = await startServer({
|
|
1822
|
+
workspaceCwd,
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1826
|
+
method: "POST",
|
|
1827
|
+
headers: {
|
|
1828
|
+
Accept: "application/json",
|
|
1829
|
+
},
|
|
1830
|
+
});
|
|
1831
|
+
expect(createResponse.status).toBe(201);
|
|
1832
|
+
await expect(createResponse.json()).resolves.toEqual(
|
|
1833
|
+
expect.objectContaining({
|
|
1834
|
+
tentacleId: "terminal-1",
|
|
1835
|
+
}),
|
|
1836
|
+
);
|
|
1837
|
+
});
|
|
1838
|
+
|
|
1839
|
+
it("skips tentacle ids that already have an existing worktree directory", async () => {
|
|
1840
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1841
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1842
|
+
mkdirSync(join(workspaceCwd, ".octogent", "worktrees", "terminal-1"), {
|
|
1843
|
+
recursive: true,
|
|
1844
|
+
});
|
|
1845
|
+
|
|
1846
|
+
const baseUrl = await startServer({
|
|
1847
|
+
workspaceCwd,
|
|
1848
|
+
});
|
|
1849
|
+
|
|
1850
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1851
|
+
method: "POST",
|
|
1852
|
+
headers: {
|
|
1853
|
+
Accept: "application/json",
|
|
1854
|
+
},
|
|
1855
|
+
});
|
|
1856
|
+
expect(createResponse.status).toBe(201);
|
|
1857
|
+
await expect(createResponse.json()).resolves.toEqual(
|
|
1858
|
+
expect.objectContaining({
|
|
1859
|
+
tentacleId: "terminal-2",
|
|
1860
|
+
}),
|
|
1861
|
+
);
|
|
1862
|
+
});
|
|
1863
|
+
|
|
1864
|
+
it("persists tentacle metadata without runtime bootstrap flags", async () => {
|
|
1865
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1866
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1867
|
+
const baseUrl = await startServer({
|
|
1868
|
+
workspaceCwd,
|
|
1869
|
+
});
|
|
1870
|
+
|
|
1871
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1872
|
+
method: "POST",
|
|
1873
|
+
headers: {
|
|
1874
|
+
Accept: "application/json",
|
|
1875
|
+
"Content-Type": "application/json",
|
|
1876
|
+
},
|
|
1877
|
+
body: JSON.stringify({ name: "planner" }),
|
|
1878
|
+
});
|
|
1879
|
+
expect(createResponse.status).toBe(201);
|
|
1880
|
+
|
|
1881
|
+
const registryDocument = await waitForRegistryDocument<{
|
|
1882
|
+
terminals: Array<{
|
|
1883
|
+
terminalId: string;
|
|
1884
|
+
tentacleId: string;
|
|
1885
|
+
workspaceMode: "shared" | "worktree";
|
|
1886
|
+
}>;
|
|
1887
|
+
}>(workspaceCwd, (document) =>
|
|
1888
|
+
document.terminals.some(
|
|
1889
|
+
(terminal) =>
|
|
1890
|
+
terminal.terminalId === "terminal-1" &&
|
|
1891
|
+
terminal.tentacleId === "terminal-1" &&
|
|
1892
|
+
terminal.workspaceMode === "shared",
|
|
1893
|
+
),
|
|
1894
|
+
);
|
|
1895
|
+
expect(registryDocument.terminals).toEqual(
|
|
1896
|
+
expect.arrayContaining([
|
|
1897
|
+
expect.objectContaining({
|
|
1898
|
+
terminalId: "terminal-1",
|
|
1899
|
+
tentacleId: "terminal-1",
|
|
1900
|
+
workspaceMode: "shared",
|
|
1901
|
+
}),
|
|
1902
|
+
]),
|
|
1903
|
+
);
|
|
1904
|
+
});
|
|
1905
|
+
|
|
1906
|
+
it("marks auto-started prompted terminals as active immediately", async () => {
|
|
1907
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1908
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1909
|
+
const baseUrl = await startServer({
|
|
1910
|
+
workspaceCwd,
|
|
1911
|
+
});
|
|
1912
|
+
|
|
1913
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1914
|
+
method: "POST",
|
|
1915
|
+
headers: {
|
|
1916
|
+
Accept: "application/json",
|
|
1917
|
+
"Content-Type": "application/json",
|
|
1918
|
+
},
|
|
1919
|
+
body: JSON.stringify({ name: "planner", initialPrompt: "Start working." }),
|
|
1920
|
+
});
|
|
1921
|
+
expect(createResponse.status).toBe(201);
|
|
1922
|
+
|
|
1923
|
+
const snapshotsResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1924
|
+
headers: { Accept: "application/json" },
|
|
1925
|
+
});
|
|
1926
|
+
expect(snapshotsResponse.status).toBe(200);
|
|
1927
|
+
await expect(snapshotsResponse.json()).resolves.toEqual(
|
|
1928
|
+
expect.arrayContaining([
|
|
1929
|
+
expect.objectContaining({
|
|
1930
|
+
terminalId: "terminal-1",
|
|
1931
|
+
hasUserPrompt: true,
|
|
1932
|
+
}),
|
|
1933
|
+
]),
|
|
1934
|
+
);
|
|
1935
|
+
});
|
|
1936
|
+
|
|
1937
|
+
it("injects a default tentacle context prompt for tentacle terminals", async () => {
|
|
1938
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
1939
|
+
temporaryDirectories.push(workspaceCwd);
|
|
1940
|
+
const tentacleDir = join(workspaceCwd, ".octogent", "tentacles", "docs");
|
|
1941
|
+
const relativeTentacleDir = ".octogent/tentacles/docs";
|
|
1942
|
+
const promptsDir = join(process.cwd(), "..", "..", "prompts");
|
|
1943
|
+
mkdirSync(tentacleDir, { recursive: true });
|
|
1944
|
+
writeFileSync(join(tentacleDir, "CONTEXT.md"), "# Docs\n\nDocumentation team.\n", "utf8");
|
|
1945
|
+
writeFileSync(join(tentacleDir, "todo.md"), "# Todo\n", "utf8");
|
|
1946
|
+
const baseUrl = await startServer({
|
|
1947
|
+
workspaceCwd,
|
|
1948
|
+
promptsDir,
|
|
1949
|
+
});
|
|
1950
|
+
|
|
1951
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
1952
|
+
method: "POST",
|
|
1953
|
+
headers: {
|
|
1954
|
+
Accept: "application/json",
|
|
1955
|
+
"Content-Type": "application/json",
|
|
1956
|
+
},
|
|
1957
|
+
body: JSON.stringify({ tentacleId: "docs", workspaceMode: "shared" }),
|
|
1958
|
+
});
|
|
1959
|
+
expect(createResponse.status).toBe(201);
|
|
1960
|
+
await expect(createResponse.json()).resolves.toEqual(
|
|
1961
|
+
expect.objectContaining({
|
|
1962
|
+
terminalId: "terminal-1",
|
|
1963
|
+
tentacleId: "docs",
|
|
1964
|
+
}),
|
|
1965
|
+
);
|
|
1966
|
+
|
|
1967
|
+
const registryDocument = await waitForRegistryDocument<{
|
|
1968
|
+
terminals: Array<{
|
|
1969
|
+
terminalId: string;
|
|
1970
|
+
initialInputDraft?: string;
|
|
1971
|
+
}>;
|
|
1972
|
+
}>(workspaceCwd, (document) =>
|
|
1973
|
+
document.terminals.some(
|
|
1974
|
+
(terminal) =>
|
|
1975
|
+
terminal.terminalId === "terminal-1" &&
|
|
1976
|
+
terminal.initialInputDraft ===
|
|
1977
|
+
`You are working on the Docs section. For tool-list items, context, and docs, check ${relativeTentacleDir}.`,
|
|
1978
|
+
),
|
|
1979
|
+
);
|
|
1980
|
+
expect(registryDocument.terminals).toEqual(
|
|
1981
|
+
expect.arrayContaining([
|
|
1982
|
+
expect.objectContaining({
|
|
1983
|
+
terminalId: "terminal-1",
|
|
1984
|
+
initialInputDraft: `You are working on the Docs section. For tool-list items, context, and docs, check ${relativeTentacleDir}.`,
|
|
1985
|
+
}),
|
|
1986
|
+
]),
|
|
1987
|
+
);
|
|
1988
|
+
|
|
1989
|
+
const snapshotsResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
1990
|
+
headers: { Accept: "application/json" },
|
|
1991
|
+
});
|
|
1992
|
+
expect(snapshotsResponse.status).toBe(200);
|
|
1993
|
+
await expect(snapshotsResponse.json()).resolves.toEqual(
|
|
1994
|
+
expect.arrayContaining([
|
|
1995
|
+
expect.objectContaining({
|
|
1996
|
+
terminalId: "terminal-1",
|
|
1997
|
+
hasUserPrompt: false,
|
|
1998
|
+
}),
|
|
1999
|
+
]),
|
|
2000
|
+
);
|
|
2001
|
+
});
|
|
2002
|
+
|
|
2003
|
+
it("creates isolated worktree terminals with dedicated cwd", async () => {
|
|
2004
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2005
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2006
|
+
const gitClient = new FakeGitClient();
|
|
2007
|
+
const baseUrl = await startServer({
|
|
2008
|
+
workspaceCwd,
|
|
2009
|
+
gitClient,
|
|
2010
|
+
});
|
|
2011
|
+
|
|
2012
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2013
|
+
method: "POST",
|
|
2014
|
+
headers: {
|
|
2015
|
+
Accept: "application/json",
|
|
2016
|
+
"Content-Type": "application/json",
|
|
2017
|
+
},
|
|
2018
|
+
body: JSON.stringify({
|
|
2019
|
+
name: "planner",
|
|
2020
|
+
workspaceMode: "worktree",
|
|
2021
|
+
}),
|
|
2022
|
+
});
|
|
2023
|
+
expect(createResponse.status).toBe(201);
|
|
2024
|
+
await expect(createResponse.json()).resolves.toEqual(
|
|
2025
|
+
expect.objectContaining({
|
|
2026
|
+
tentacleId: "terminal-1",
|
|
2027
|
+
tentacleName: "planner",
|
|
2028
|
+
workspaceMode: "worktree",
|
|
2029
|
+
}),
|
|
2030
|
+
);
|
|
2031
|
+
|
|
2032
|
+
const expectedWorktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2033
|
+
expect(gitClient.getWorktree(expectedWorktreePath)).toEqual(
|
|
2034
|
+
expect.objectContaining({
|
|
2035
|
+
cwd: workspaceCwd,
|
|
2036
|
+
branchName: "octogent/terminal-1",
|
|
2037
|
+
baseRef: "HEAD",
|
|
2038
|
+
}),
|
|
2039
|
+
);
|
|
2040
|
+
|
|
2041
|
+
const registryDocument = await waitForRegistryDocument<{
|
|
2042
|
+
terminals: Array<{
|
|
2043
|
+
terminalId: string;
|
|
2044
|
+
tentacleId: string;
|
|
2045
|
+
workspaceMode: "shared" | "worktree";
|
|
2046
|
+
}>;
|
|
2047
|
+
}>(workspaceCwd, (document) =>
|
|
2048
|
+
document.terminals.some(
|
|
2049
|
+
(terminal) =>
|
|
2050
|
+
terminal.terminalId === "terminal-1" &&
|
|
2051
|
+
terminal.tentacleId === "terminal-1" &&
|
|
2052
|
+
terminal.workspaceMode === "worktree",
|
|
2053
|
+
),
|
|
2054
|
+
);
|
|
2055
|
+
expect(registryDocument.terminals).toEqual(
|
|
2056
|
+
expect.arrayContaining([
|
|
2057
|
+
expect.objectContaining({
|
|
2058
|
+
terminalId: "terminal-1",
|
|
2059
|
+
tentacleId: "terminal-1",
|
|
2060
|
+
workspaceMode: "worktree",
|
|
2061
|
+
}),
|
|
2062
|
+
]),
|
|
2063
|
+
);
|
|
2064
|
+
});
|
|
2065
|
+
|
|
2066
|
+
it("returns git status for worktree tentacles", async () => {
|
|
2067
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2068
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2069
|
+
const gitClient = new FakeGitClient();
|
|
2070
|
+
const baseUrl = await startServer({
|
|
2071
|
+
workspaceCwd,
|
|
2072
|
+
gitClient,
|
|
2073
|
+
});
|
|
2074
|
+
|
|
2075
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2076
|
+
method: "POST",
|
|
2077
|
+
headers: {
|
|
2078
|
+
Accept: "application/json",
|
|
2079
|
+
"Content-Type": "application/json",
|
|
2080
|
+
},
|
|
2081
|
+
body: JSON.stringify({
|
|
2082
|
+
workspaceMode: "worktree",
|
|
2083
|
+
}),
|
|
2084
|
+
});
|
|
2085
|
+
expect(createResponse.status).toBe(201);
|
|
2086
|
+
|
|
2087
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2088
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2089
|
+
branchName: "octogent/terminal-1",
|
|
2090
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2091
|
+
isDirty: true,
|
|
2092
|
+
aheadCount: 2,
|
|
2093
|
+
behindCount: 1,
|
|
2094
|
+
insertedLineCount: 0,
|
|
2095
|
+
deletedLineCount: 0,
|
|
2096
|
+
hasConflicts: false,
|
|
2097
|
+
changedFiles: ["apps/web/src/App.tsx", "README.md"],
|
|
2098
|
+
defaultBaseBranchName: "main",
|
|
2099
|
+
});
|
|
2100
|
+
|
|
2101
|
+
const statusResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/status`, {
|
|
2102
|
+
method: "GET",
|
|
2103
|
+
headers: {
|
|
2104
|
+
Accept: "application/json",
|
|
2105
|
+
},
|
|
2106
|
+
});
|
|
2107
|
+
expect(statusResponse.status).toBe(200);
|
|
2108
|
+
await expect(statusResponse.json()).resolves.toEqual({
|
|
2109
|
+
tentacleId: "terminal-1",
|
|
2110
|
+
workspaceMode: "worktree",
|
|
2111
|
+
branchName: "octogent/terminal-1",
|
|
2112
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2113
|
+
isDirty: true,
|
|
2114
|
+
aheadCount: 2,
|
|
2115
|
+
behindCount: 1,
|
|
2116
|
+
insertedLineCount: 0,
|
|
2117
|
+
deletedLineCount: 0,
|
|
2118
|
+
hasConflicts: false,
|
|
2119
|
+
changedFiles: ["apps/web/src/App.tsx", "README.md"],
|
|
2120
|
+
defaultBaseBranchName: "main",
|
|
2121
|
+
});
|
|
2122
|
+
});
|
|
2123
|
+
|
|
2124
|
+
it("returns 409 for git status on shared tentacles", async () => {
|
|
2125
|
+
const baseUrl = await startServer();
|
|
2126
|
+
|
|
2127
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2128
|
+
method: "POST",
|
|
2129
|
+
headers: {
|
|
2130
|
+
Accept: "application/json",
|
|
2131
|
+
},
|
|
2132
|
+
});
|
|
2133
|
+
expect(createResponse.status).toBe(201);
|
|
2134
|
+
|
|
2135
|
+
const statusResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/status`, {
|
|
2136
|
+
method: "GET",
|
|
2137
|
+
headers: {
|
|
2138
|
+
Accept: "application/json",
|
|
2139
|
+
},
|
|
2140
|
+
});
|
|
2141
|
+
expect(statusResponse.status).toBe(409);
|
|
2142
|
+
await expect(statusResponse.json()).resolves.toEqual({
|
|
2143
|
+
error: "Git lifecycle actions are only available for worktree terminals.",
|
|
2144
|
+
});
|
|
2145
|
+
});
|
|
2146
|
+
|
|
2147
|
+
it("commits pending worktree changes with a required message", async () => {
|
|
2148
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2149
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2150
|
+
const gitClient = new FakeGitClient();
|
|
2151
|
+
const baseUrl = await startServer({
|
|
2152
|
+
workspaceCwd,
|
|
2153
|
+
gitClient,
|
|
2154
|
+
});
|
|
2155
|
+
|
|
2156
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2157
|
+
method: "POST",
|
|
2158
|
+
headers: {
|
|
2159
|
+
Accept: "application/json",
|
|
2160
|
+
"Content-Type": "application/json",
|
|
2161
|
+
},
|
|
2162
|
+
body: JSON.stringify({
|
|
2163
|
+
workspaceMode: "worktree",
|
|
2164
|
+
}),
|
|
2165
|
+
});
|
|
2166
|
+
expect(createResponse.status).toBe(201);
|
|
2167
|
+
|
|
2168
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2169
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2170
|
+
branchName: "octogent/terminal-1",
|
|
2171
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2172
|
+
isDirty: true,
|
|
2173
|
+
aheadCount: 0,
|
|
2174
|
+
behindCount: 0,
|
|
2175
|
+
insertedLineCount: 0,
|
|
2176
|
+
deletedLineCount: 0,
|
|
2177
|
+
hasConflicts: false,
|
|
2178
|
+
changedFiles: ["apps/web/src/App.tsx"],
|
|
2179
|
+
defaultBaseBranchName: "main",
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
const commitResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/commit`, {
|
|
2183
|
+
method: "POST",
|
|
2184
|
+
headers: {
|
|
2185
|
+
Accept: "application/json",
|
|
2186
|
+
"Content-Type": "application/json",
|
|
2187
|
+
},
|
|
2188
|
+
body: JSON.stringify({
|
|
2189
|
+
message: "feat: add worktree git actions",
|
|
2190
|
+
}),
|
|
2191
|
+
});
|
|
2192
|
+
expect(commitResponse.status).toBe(200);
|
|
2193
|
+
expect(gitClient.getLastCommitMessage(worktreePath)).toBe("feat: add worktree git actions");
|
|
2194
|
+
await expect(commitResponse.json()).resolves.toEqual({
|
|
2195
|
+
tentacleId: "terminal-1",
|
|
2196
|
+
workspaceMode: "worktree",
|
|
2197
|
+
branchName: "octogent/terminal-1",
|
|
2198
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2199
|
+
isDirty: false,
|
|
2200
|
+
aheadCount: 1,
|
|
2201
|
+
behindCount: 0,
|
|
2202
|
+
insertedLineCount: 0,
|
|
2203
|
+
deletedLineCount: 0,
|
|
2204
|
+
hasConflicts: false,
|
|
2205
|
+
changedFiles: [],
|
|
2206
|
+
defaultBaseBranchName: "main",
|
|
2207
|
+
});
|
|
2208
|
+
});
|
|
2209
|
+
|
|
2210
|
+
it("returns 400 for commit when message is empty", async () => {
|
|
2211
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2212
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2213
|
+
const gitClient = new FakeGitClient();
|
|
2214
|
+
const baseUrl = await startServer({
|
|
2215
|
+
workspaceCwd,
|
|
2216
|
+
gitClient,
|
|
2217
|
+
});
|
|
2218
|
+
|
|
2219
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2220
|
+
method: "POST",
|
|
2221
|
+
headers: {
|
|
2222
|
+
Accept: "application/json",
|
|
2223
|
+
"Content-Type": "application/json",
|
|
2224
|
+
},
|
|
2225
|
+
body: JSON.stringify({
|
|
2226
|
+
workspaceMode: "worktree",
|
|
2227
|
+
}),
|
|
2228
|
+
});
|
|
2229
|
+
expect(createResponse.status).toBe(201);
|
|
2230
|
+
|
|
2231
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2232
|
+
const commitResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/commit`, {
|
|
2233
|
+
method: "POST",
|
|
2234
|
+
headers: {
|
|
2235
|
+
Accept: "application/json",
|
|
2236
|
+
"Content-Type": "application/json",
|
|
2237
|
+
},
|
|
2238
|
+
body: JSON.stringify({
|
|
2239
|
+
message: " ",
|
|
2240
|
+
}),
|
|
2241
|
+
});
|
|
2242
|
+
expect(commitResponse.status).toBe(400);
|
|
2243
|
+
expect(gitClient.getLastCommitMessage(worktreePath)).toBeNull();
|
|
2244
|
+
await expect(commitResponse.json()).resolves.toEqual({
|
|
2245
|
+
error: "Commit message cannot be empty.",
|
|
2246
|
+
});
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it("pushes worktree branch and updates ahead count", async () => {
|
|
2250
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2251
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2252
|
+
const gitClient = new FakeGitClient();
|
|
2253
|
+
const baseUrl = await startServer({
|
|
2254
|
+
workspaceCwd,
|
|
2255
|
+
gitClient,
|
|
2256
|
+
});
|
|
2257
|
+
|
|
2258
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2259
|
+
method: "POST",
|
|
2260
|
+
headers: {
|
|
2261
|
+
Accept: "application/json",
|
|
2262
|
+
"Content-Type": "application/json",
|
|
2263
|
+
},
|
|
2264
|
+
body: JSON.stringify({
|
|
2265
|
+
workspaceMode: "worktree",
|
|
2266
|
+
}),
|
|
2267
|
+
});
|
|
2268
|
+
expect(createResponse.status).toBe(201);
|
|
2269
|
+
|
|
2270
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2271
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2272
|
+
branchName: "octogent/terminal-1",
|
|
2273
|
+
upstreamBranchName: null,
|
|
2274
|
+
isDirty: false,
|
|
2275
|
+
aheadCount: 3,
|
|
2276
|
+
behindCount: 0,
|
|
2277
|
+
insertedLineCount: 0,
|
|
2278
|
+
deletedLineCount: 0,
|
|
2279
|
+
hasConflicts: false,
|
|
2280
|
+
changedFiles: [],
|
|
2281
|
+
defaultBaseBranchName: "main",
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
const pushResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/push`, {
|
|
2285
|
+
method: "POST",
|
|
2286
|
+
headers: {
|
|
2287
|
+
Accept: "application/json",
|
|
2288
|
+
},
|
|
2289
|
+
});
|
|
2290
|
+
expect(pushResponse.status).toBe(200);
|
|
2291
|
+
expect(gitClient.getPushCount(worktreePath)).toBe(1);
|
|
2292
|
+
await expect(pushResponse.json()).resolves.toEqual({
|
|
2293
|
+
tentacleId: "terminal-1",
|
|
2294
|
+
workspaceMode: "worktree",
|
|
2295
|
+
branchName: "octogent/terminal-1",
|
|
2296
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2297
|
+
isDirty: false,
|
|
2298
|
+
aheadCount: 0,
|
|
2299
|
+
behindCount: 0,
|
|
2300
|
+
insertedLineCount: 0,
|
|
2301
|
+
deletedLineCount: 0,
|
|
2302
|
+
hasConflicts: false,
|
|
2303
|
+
changedFiles: [],
|
|
2304
|
+
defaultBaseBranchName: "main",
|
|
2305
|
+
});
|
|
2306
|
+
});
|
|
2307
|
+
|
|
2308
|
+
it("syncs worktree branch with base ref", async () => {
|
|
2309
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2310
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2311
|
+
const gitClient = new FakeGitClient();
|
|
2312
|
+
const baseUrl = await startServer({
|
|
2313
|
+
workspaceCwd,
|
|
2314
|
+
gitClient,
|
|
2315
|
+
});
|
|
2316
|
+
|
|
2317
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2318
|
+
method: "POST",
|
|
2319
|
+
headers: {
|
|
2320
|
+
Accept: "application/json",
|
|
2321
|
+
"Content-Type": "application/json",
|
|
2322
|
+
},
|
|
2323
|
+
body: JSON.stringify({
|
|
2324
|
+
workspaceMode: "worktree",
|
|
2325
|
+
}),
|
|
2326
|
+
});
|
|
2327
|
+
expect(createResponse.status).toBe(201);
|
|
2328
|
+
|
|
2329
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2330
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2331
|
+
branchName: "octogent/terminal-1",
|
|
2332
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2333
|
+
isDirty: false,
|
|
2334
|
+
aheadCount: 0,
|
|
2335
|
+
behindCount: 4,
|
|
2336
|
+
insertedLineCount: 0,
|
|
2337
|
+
deletedLineCount: 0,
|
|
2338
|
+
hasConflicts: false,
|
|
2339
|
+
changedFiles: [],
|
|
2340
|
+
defaultBaseBranchName: "main",
|
|
2341
|
+
});
|
|
2342
|
+
|
|
2343
|
+
const syncResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/sync`, {
|
|
2344
|
+
method: "POST",
|
|
2345
|
+
headers: {
|
|
2346
|
+
Accept: "application/json",
|
|
2347
|
+
"Content-Type": "application/json",
|
|
2348
|
+
},
|
|
2349
|
+
body: JSON.stringify({
|
|
2350
|
+
baseRef: "main",
|
|
2351
|
+
}),
|
|
2352
|
+
});
|
|
2353
|
+
expect(syncResponse.status).toBe(200);
|
|
2354
|
+
expect(gitClient.getSyncBaseRefs(worktreePath)).toEqual(["main"]);
|
|
2355
|
+
await expect(syncResponse.json()).resolves.toEqual({
|
|
2356
|
+
tentacleId: "terminal-1",
|
|
2357
|
+
workspaceMode: "worktree",
|
|
2358
|
+
branchName: "octogent/terminal-1",
|
|
2359
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2360
|
+
isDirty: false,
|
|
2361
|
+
aheadCount: 0,
|
|
2362
|
+
behindCount: 0,
|
|
2363
|
+
insertedLineCount: 0,
|
|
2364
|
+
deletedLineCount: 0,
|
|
2365
|
+
hasConflicts: false,
|
|
2366
|
+
changedFiles: [],
|
|
2367
|
+
defaultBaseBranchName: "main",
|
|
2368
|
+
});
|
|
2369
|
+
});
|
|
2370
|
+
|
|
2371
|
+
it("returns PR status for worktree tentacles", async () => {
|
|
2372
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2373
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2374
|
+
const gitClient = new FakeGitClient();
|
|
2375
|
+
const baseUrl = await startServer({
|
|
2376
|
+
workspaceCwd,
|
|
2377
|
+
gitClient,
|
|
2378
|
+
});
|
|
2379
|
+
|
|
2380
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2381
|
+
method: "POST",
|
|
2382
|
+
headers: {
|
|
2383
|
+
Accept: "application/json",
|
|
2384
|
+
"Content-Type": "application/json",
|
|
2385
|
+
},
|
|
2386
|
+
body: JSON.stringify({
|
|
2387
|
+
workspaceMode: "worktree",
|
|
2388
|
+
}),
|
|
2389
|
+
});
|
|
2390
|
+
expect(createResponse.status).toBe(201);
|
|
2391
|
+
|
|
2392
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2393
|
+
gitClient.setWorktreePullRequest(worktreePath, {
|
|
2394
|
+
number: 142,
|
|
2395
|
+
url: "https://github.com/hesamsheikh/octogent/pull/142",
|
|
2396
|
+
title: "feat: worktree git lifecycle menu",
|
|
2397
|
+
baseRef: "main",
|
|
2398
|
+
headRef: "octogent/terminal-1",
|
|
2399
|
+
state: "OPEN",
|
|
2400
|
+
isDraft: false,
|
|
2401
|
+
mergeable: "MERGEABLE",
|
|
2402
|
+
mergeStateStatus: "CLEAN",
|
|
2403
|
+
});
|
|
2404
|
+
|
|
2405
|
+
const prStatusResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/pr`, {
|
|
2406
|
+
method: "GET",
|
|
2407
|
+
headers: {
|
|
2408
|
+
Accept: "application/json",
|
|
2409
|
+
},
|
|
2410
|
+
});
|
|
2411
|
+
expect(prStatusResponse.status).toBe(200);
|
|
2412
|
+
await expect(prStatusResponse.json()).resolves.toEqual({
|
|
2413
|
+
tentacleId: "terminal-1",
|
|
2414
|
+
workspaceMode: "worktree",
|
|
2415
|
+
status: "open",
|
|
2416
|
+
number: 142,
|
|
2417
|
+
url: "https://github.com/hesamsheikh/octogent/pull/142",
|
|
2418
|
+
title: "feat: worktree git lifecycle menu",
|
|
2419
|
+
baseRef: "main",
|
|
2420
|
+
headRef: "octogent/terminal-1",
|
|
2421
|
+
isDraft: false,
|
|
2422
|
+
mergeable: "MERGEABLE",
|
|
2423
|
+
mergeStateStatus: "CLEAN",
|
|
2424
|
+
});
|
|
2425
|
+
});
|
|
2426
|
+
|
|
2427
|
+
it("creates PR for worktree tentacles and returns PR snapshot", async () => {
|
|
2428
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2429
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2430
|
+
const gitClient = new FakeGitClient();
|
|
2431
|
+
const baseUrl = await startServer({
|
|
2432
|
+
workspaceCwd,
|
|
2433
|
+
gitClient,
|
|
2434
|
+
});
|
|
2435
|
+
|
|
2436
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2437
|
+
method: "POST",
|
|
2438
|
+
headers: {
|
|
2439
|
+
Accept: "application/json",
|
|
2440
|
+
"Content-Type": "application/json",
|
|
2441
|
+
},
|
|
2442
|
+
body: JSON.stringify({
|
|
2443
|
+
workspaceMode: "worktree",
|
|
2444
|
+
}),
|
|
2445
|
+
});
|
|
2446
|
+
expect(createResponse.status).toBe(201);
|
|
2447
|
+
|
|
2448
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2449
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2450
|
+
branchName: "octogent/terminal-1",
|
|
2451
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2452
|
+
isDirty: false,
|
|
2453
|
+
aheadCount: 0,
|
|
2454
|
+
behindCount: 0,
|
|
2455
|
+
insertedLineCount: 0,
|
|
2456
|
+
deletedLineCount: 0,
|
|
2457
|
+
hasConflicts: false,
|
|
2458
|
+
changedFiles: [],
|
|
2459
|
+
defaultBaseBranchName: "main",
|
|
2460
|
+
});
|
|
2461
|
+
|
|
2462
|
+
const createPrResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/pr`, {
|
|
2463
|
+
method: "POST",
|
|
2464
|
+
headers: {
|
|
2465
|
+
Accept: "application/json",
|
|
2466
|
+
"Content-Type": "application/json",
|
|
2467
|
+
},
|
|
2468
|
+
body: JSON.stringify({
|
|
2469
|
+
title: "feat: expose worktree lifecycle actions",
|
|
2470
|
+
body: "Adds PR controls in the tentacle header.",
|
|
2471
|
+
baseRef: "main",
|
|
2472
|
+
}),
|
|
2473
|
+
});
|
|
2474
|
+
expect(createPrResponse.status).toBe(200);
|
|
2475
|
+
await expect(createPrResponse.json()).resolves.toEqual({
|
|
2476
|
+
tentacleId: "terminal-1",
|
|
2477
|
+
workspaceMode: "worktree",
|
|
2478
|
+
status: "open",
|
|
2479
|
+
number: 101,
|
|
2480
|
+
url: "https://github.com/hesamsheikh/octogent/pull/101",
|
|
2481
|
+
title: "feat: expose worktree lifecycle actions",
|
|
2482
|
+
baseRef: "main",
|
|
2483
|
+
headRef: "octogent/terminal-1",
|
|
2484
|
+
isDraft: false,
|
|
2485
|
+
mergeable: "MERGEABLE",
|
|
2486
|
+
mergeStateStatus: "CLEAN",
|
|
2487
|
+
});
|
|
2488
|
+
});
|
|
2489
|
+
|
|
2490
|
+
it("returns 409 when creating a PR and an open PR already exists for the branch", async () => {
|
|
2491
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2492
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2493
|
+
const gitClient = new FakeGitClient();
|
|
2494
|
+
const baseUrl = await startServer({
|
|
2495
|
+
workspaceCwd,
|
|
2496
|
+
gitClient,
|
|
2497
|
+
});
|
|
2498
|
+
|
|
2499
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2500
|
+
method: "POST",
|
|
2501
|
+
headers: {
|
|
2502
|
+
Accept: "application/json",
|
|
2503
|
+
"Content-Type": "application/json",
|
|
2504
|
+
},
|
|
2505
|
+
body: JSON.stringify({
|
|
2506
|
+
workspaceMode: "worktree",
|
|
2507
|
+
}),
|
|
2508
|
+
});
|
|
2509
|
+
expect(createResponse.status).toBe(201);
|
|
2510
|
+
|
|
2511
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2512
|
+
gitClient.setWorktreeStatus(worktreePath, {
|
|
2513
|
+
branchName: "octogent/terminal-1",
|
|
2514
|
+
upstreamBranchName: "origin/octogent/terminal-1",
|
|
2515
|
+
isDirty: false,
|
|
2516
|
+
aheadCount: 0,
|
|
2517
|
+
behindCount: 0,
|
|
2518
|
+
insertedLineCount: 0,
|
|
2519
|
+
deletedLineCount: 0,
|
|
2520
|
+
hasConflicts: false,
|
|
2521
|
+
changedFiles: [],
|
|
2522
|
+
defaultBaseBranchName: "main",
|
|
2523
|
+
});
|
|
2524
|
+
gitClient.setWorktreePullRequest(worktreePath, {
|
|
2525
|
+
number: 142,
|
|
2526
|
+
url: "https://github.com/hesamsheikh/octogent/pull/142",
|
|
2527
|
+
title: "feat: existing worktree lifecycle PR",
|
|
2528
|
+
baseRef: "main",
|
|
2529
|
+
headRef: "octogent/terminal-1",
|
|
2530
|
+
state: "OPEN",
|
|
2531
|
+
isDraft: false,
|
|
2532
|
+
mergeable: "MERGEABLE",
|
|
2533
|
+
mergeStateStatus: "CLEAN",
|
|
2534
|
+
});
|
|
2535
|
+
|
|
2536
|
+
const createPrResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/pr`, {
|
|
2537
|
+
method: "POST",
|
|
2538
|
+
headers: {
|
|
2539
|
+
Accept: "application/json",
|
|
2540
|
+
"Content-Type": "application/json",
|
|
2541
|
+
},
|
|
2542
|
+
body: JSON.stringify({
|
|
2543
|
+
title: "feat: should not create duplicate PR",
|
|
2544
|
+
body: "Should fail because the branch already has an open PR.",
|
|
2545
|
+
baseRef: "main",
|
|
2546
|
+
}),
|
|
2547
|
+
});
|
|
2548
|
+
expect(createPrResponse.status).toBe(409);
|
|
2549
|
+
await expect(createPrResponse.json()).resolves.toEqual({
|
|
2550
|
+
error: "An open pull request already exists for this branch.",
|
|
2551
|
+
});
|
|
2552
|
+
|
|
2553
|
+
expect(gitClient.getPullRequestState(worktreePath)).toBe("OPEN");
|
|
2554
|
+
});
|
|
2555
|
+
|
|
2556
|
+
it("merges the current branch PR for worktree tentacles", async () => {
|
|
2557
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2558
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2559
|
+
const gitClient = new FakeGitClient();
|
|
2560
|
+
const baseUrl = await startServer({
|
|
2561
|
+
workspaceCwd,
|
|
2562
|
+
gitClient,
|
|
2563
|
+
});
|
|
2564
|
+
|
|
2565
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2566
|
+
method: "POST",
|
|
2567
|
+
headers: {
|
|
2568
|
+
Accept: "application/json",
|
|
2569
|
+
"Content-Type": "application/json",
|
|
2570
|
+
},
|
|
2571
|
+
body: JSON.stringify({
|
|
2572
|
+
workspaceMode: "worktree",
|
|
2573
|
+
}),
|
|
2574
|
+
});
|
|
2575
|
+
expect(createResponse.status).toBe(201);
|
|
2576
|
+
|
|
2577
|
+
const worktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2578
|
+
gitClient.setWorktreePullRequest(worktreePath, {
|
|
2579
|
+
number: 190,
|
|
2580
|
+
url: "https://github.com/hesamsheikh/octogent/pull/190",
|
|
2581
|
+
title: "feat: ship worktree lifecycle",
|
|
2582
|
+
baseRef: "main",
|
|
2583
|
+
headRef: "octogent/terminal-1",
|
|
2584
|
+
state: "OPEN",
|
|
2585
|
+
isDraft: false,
|
|
2586
|
+
mergeable: "MERGEABLE",
|
|
2587
|
+
mergeStateStatus: "CLEAN",
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
const mergeResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/pr/merge`, {
|
|
2591
|
+
method: "POST",
|
|
2592
|
+
headers: {
|
|
2593
|
+
Accept: "application/json",
|
|
2594
|
+
},
|
|
2595
|
+
});
|
|
2596
|
+
expect(mergeResponse.status).toBe(200);
|
|
2597
|
+
expect(gitClient.getPullRequestState(worktreePath)).toBe("MERGED");
|
|
2598
|
+
await expect(mergeResponse.json()).resolves.toEqual({
|
|
2599
|
+
tentacleId: "terminal-1",
|
|
2600
|
+
workspaceMode: "worktree",
|
|
2601
|
+
status: "merged",
|
|
2602
|
+
number: 190,
|
|
2603
|
+
url: "https://github.com/hesamsheikh/octogent/pull/190",
|
|
2604
|
+
title: "feat: ship worktree lifecycle",
|
|
2605
|
+
baseRef: "main",
|
|
2606
|
+
headRef: "octogent/terminal-1",
|
|
2607
|
+
isDraft: false,
|
|
2608
|
+
mergeable: "UNKNOWN",
|
|
2609
|
+
mergeStateStatus: "MERGED",
|
|
2610
|
+
});
|
|
2611
|
+
});
|
|
2612
|
+
|
|
2613
|
+
it("returns 409 for PR actions on shared tentacles", async () => {
|
|
2614
|
+
const baseUrl = await startServer();
|
|
2615
|
+
|
|
2616
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2617
|
+
method: "POST",
|
|
2618
|
+
headers: {
|
|
2619
|
+
Accept: "application/json",
|
|
2620
|
+
},
|
|
2621
|
+
});
|
|
2622
|
+
expect(createResponse.status).toBe(201);
|
|
2623
|
+
|
|
2624
|
+
const prStatusResponse = await fetch(`${baseUrl}/api/tentacles/terminal-1/git/pr`, {
|
|
2625
|
+
method: "GET",
|
|
2626
|
+
headers: {
|
|
2627
|
+
Accept: "application/json",
|
|
2628
|
+
},
|
|
2629
|
+
});
|
|
2630
|
+
expect(prStatusResponse.status).toBe(409);
|
|
2631
|
+
await expect(prStatusResponse.json()).resolves.toEqual({
|
|
2632
|
+
error: "Git lifecycle actions are only available for worktree terminals.",
|
|
2633
|
+
});
|
|
2634
|
+
});
|
|
2635
|
+
|
|
2636
|
+
it("removes isolated worktree metadata when deleting a worktree tentacle", async () => {
|
|
2637
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2638
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2639
|
+
const gitClient = new FakeGitClient();
|
|
2640
|
+
const baseUrl = await startServer({
|
|
2641
|
+
workspaceCwd,
|
|
2642
|
+
gitClient,
|
|
2643
|
+
});
|
|
2644
|
+
|
|
2645
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2646
|
+
method: "POST",
|
|
2647
|
+
headers: {
|
|
2648
|
+
Accept: "application/json",
|
|
2649
|
+
"Content-Type": "application/json",
|
|
2650
|
+
},
|
|
2651
|
+
body: JSON.stringify({
|
|
2652
|
+
workspaceMode: "worktree",
|
|
2653
|
+
}),
|
|
2654
|
+
});
|
|
2655
|
+
expect(createResponse.status).toBe(201);
|
|
2656
|
+
|
|
2657
|
+
const expectedWorktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2658
|
+
expect(gitClient.getWorktree(expectedWorktreePath)).toEqual(
|
|
2659
|
+
expect.objectContaining({
|
|
2660
|
+
cwd: workspaceCwd,
|
|
2661
|
+
branchName: "octogent/terminal-1",
|
|
2662
|
+
}),
|
|
2663
|
+
);
|
|
2664
|
+
|
|
2665
|
+
const deleteResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
2666
|
+
method: "DELETE",
|
|
2667
|
+
headers: {
|
|
2668
|
+
Accept: "application/json",
|
|
2669
|
+
},
|
|
2670
|
+
});
|
|
2671
|
+
expect(deleteResponse.status).toBe(204);
|
|
2672
|
+
expect(gitClient.getWorktree(expectedWorktreePath)).toBeNull();
|
|
2673
|
+
expect(gitClient.hasBranch("octogent/terminal-1")).toBe(false);
|
|
2674
|
+
});
|
|
2675
|
+
|
|
2676
|
+
it("returns 409 and keeps tentacle state when worktree deletion fails", async () => {
|
|
2677
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2678
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2679
|
+
const gitClient = new FakeGitClient();
|
|
2680
|
+
const baseUrl = await startServer({
|
|
2681
|
+
workspaceCwd,
|
|
2682
|
+
gitClient,
|
|
2683
|
+
});
|
|
2684
|
+
|
|
2685
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2686
|
+
method: "POST",
|
|
2687
|
+
headers: {
|
|
2688
|
+
Accept: "application/json",
|
|
2689
|
+
"Content-Type": "application/json",
|
|
2690
|
+
},
|
|
2691
|
+
body: JSON.stringify({
|
|
2692
|
+
workspaceMode: "worktree",
|
|
2693
|
+
}),
|
|
2694
|
+
});
|
|
2695
|
+
expect(createResponse.status).toBe(201);
|
|
2696
|
+
|
|
2697
|
+
const expectedWorktreePath = join(workspaceCwd, ".octogent", "worktrees", "terminal-1");
|
|
2698
|
+
gitClient.setFailRemoveWorktree(true);
|
|
2699
|
+
|
|
2700
|
+
const deleteResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
2701
|
+
method: "DELETE",
|
|
2702
|
+
headers: {
|
|
2703
|
+
Accept: "application/json",
|
|
2704
|
+
},
|
|
2705
|
+
});
|
|
2706
|
+
expect(deleteResponse.status).toBe(409);
|
|
2707
|
+
await expect(deleteResponse.json()).resolves.toEqual({
|
|
2708
|
+
error: expect.stringContaining("Unable to remove worktree for terminal-1"),
|
|
2709
|
+
});
|
|
2710
|
+
expect(gitClient.getWorktree(expectedWorktreePath)).toEqual(
|
|
2711
|
+
expect.objectContaining({
|
|
2712
|
+
cwd: workspaceCwd,
|
|
2713
|
+
branchName: "octogent/terminal-1",
|
|
2714
|
+
}),
|
|
2715
|
+
);
|
|
2716
|
+
|
|
2717
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
2718
|
+
method: "GET",
|
|
2719
|
+
headers: {
|
|
2720
|
+
Accept: "application/json",
|
|
2721
|
+
},
|
|
2722
|
+
});
|
|
2723
|
+
expect(listResponse.status).toBe(200);
|
|
2724
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
2725
|
+
expect.arrayContaining([
|
|
2726
|
+
expect.objectContaining({
|
|
2727
|
+
terminalId: "terminal-1",
|
|
2728
|
+
tentacleId: "terminal-1",
|
|
2729
|
+
}),
|
|
2730
|
+
]),
|
|
2731
|
+
);
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
it("returns 400 when workspace mode is invalid", async () => {
|
|
2735
|
+
const baseUrl = await startServer();
|
|
2736
|
+
|
|
2737
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2738
|
+
method: "POST",
|
|
2739
|
+
headers: {
|
|
2740
|
+
Accept: "application/json",
|
|
2741
|
+
"Content-Type": "application/json",
|
|
2742
|
+
},
|
|
2743
|
+
body: JSON.stringify({
|
|
2744
|
+
workspaceMode: "invalid-mode",
|
|
2745
|
+
}),
|
|
2746
|
+
});
|
|
2747
|
+
|
|
2748
|
+
expect(createResponse.status).toBe(400);
|
|
2749
|
+
await expect(createResponse.json()).resolves.toEqual({
|
|
2750
|
+
error: "Terminal workspace mode must be either 'shared' or 'worktree'.",
|
|
2751
|
+
});
|
|
2752
|
+
});
|
|
2753
|
+
|
|
2754
|
+
it("refreshes builtin prompts from promptsDir on server start", async () => {
|
|
2755
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2756
|
+
const projectStateDir = mkdtempSync(join(tmpdir(), "octogent-state-test-"));
|
|
2757
|
+
const promptsDir = mkdtempSync(join(tmpdir(), "octogent-prompts-test-"));
|
|
2758
|
+
temporaryDirectories.push(workspaceCwd, projectStateDir, promptsDir);
|
|
2759
|
+
|
|
2760
|
+
mkdirSync(join(projectStateDir, "prompts", "core"), { recursive: true });
|
|
2761
|
+
writeFileSync(
|
|
2762
|
+
join(projectStateDir, "prompts", "core", "swarm-parent.md"),
|
|
2763
|
+
"stale prompt with {{workerBranches}}\n",
|
|
2764
|
+
"utf8",
|
|
2765
|
+
);
|
|
2766
|
+
writeFileSync(
|
|
2767
|
+
join(promptsDir, "swarm-parent.md"),
|
|
2768
|
+
"fresh prompt with {{workerSpawnCommands}}\n",
|
|
2769
|
+
"utf8",
|
|
2770
|
+
);
|
|
2771
|
+
|
|
2772
|
+
const baseUrl = await startServer({
|
|
2773
|
+
workspaceCwd,
|
|
2774
|
+
projectStateDir,
|
|
2775
|
+
promptsDir,
|
|
2776
|
+
});
|
|
2777
|
+
|
|
2778
|
+
const response = await fetch(`${baseUrl}/api/prompts/swarm-parent`, {
|
|
2779
|
+
method: "GET",
|
|
2780
|
+
headers: {
|
|
2781
|
+
Accept: "application/json",
|
|
2782
|
+
},
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
expect(response.status).toBe(200);
|
|
2786
|
+
await expect(response.json()).resolves.toEqual({
|
|
2787
|
+
name: "swarm-parent",
|
|
2788
|
+
source: "builtin",
|
|
2789
|
+
content: "fresh prompt with {{workerSpawnCommands}}",
|
|
2790
|
+
});
|
|
2791
|
+
});
|
|
2792
|
+
|
|
2793
|
+
it("reads builtin prompts from the live promptsDir after server start", async () => {
|
|
2794
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2795
|
+
const projectStateDir = mkdtempSync(join(tmpdir(), "octogent-state-test-"));
|
|
2796
|
+
const promptsDir = mkdtempSync(join(tmpdir(), "octogent-prompts-test-"));
|
|
2797
|
+
temporaryDirectories.push(workspaceCwd, projectStateDir, promptsDir);
|
|
2798
|
+
|
|
2799
|
+
writeFileSync(join(promptsDir, "tentacle-update-tentacle.md"), "version one\n", "utf8");
|
|
2800
|
+
|
|
2801
|
+
const baseUrl = await startServer({
|
|
2802
|
+
workspaceCwd,
|
|
2803
|
+
projectStateDir,
|
|
2804
|
+
promptsDir,
|
|
2805
|
+
});
|
|
2806
|
+
|
|
2807
|
+
writeFileSync(join(promptsDir, "tentacle-update-tentacle.md"), "version two\n", "utf8");
|
|
2808
|
+
|
|
2809
|
+
const response = await fetch(`${baseUrl}/api/prompts/tentacle-update-tentacle`, {
|
|
2810
|
+
method: "GET",
|
|
2811
|
+
headers: {
|
|
2812
|
+
Accept: "application/json",
|
|
2813
|
+
},
|
|
2814
|
+
});
|
|
2815
|
+
|
|
2816
|
+
expect(response.status).toBe(200);
|
|
2817
|
+
await expect(response.json()).resolves.toEqual({
|
|
2818
|
+
name: "tentacle-update-tentacle",
|
|
2819
|
+
source: "builtin",
|
|
2820
|
+
content: "version two",
|
|
2821
|
+
});
|
|
2822
|
+
});
|
|
2823
|
+
|
|
2824
|
+
it("returns 400 when creating worktree tentacle outside a git repository", async () => {
|
|
2825
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2826
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2827
|
+
const gitClient = new FakeGitClient();
|
|
2828
|
+
gitClient.setRepositoryAvailable(false);
|
|
2829
|
+
const baseUrl = await startServer({
|
|
2830
|
+
workspaceCwd,
|
|
2831
|
+
gitClient,
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2835
|
+
method: "POST",
|
|
2836
|
+
headers: {
|
|
2837
|
+
Accept: "application/json",
|
|
2838
|
+
"Content-Type": "application/json",
|
|
2839
|
+
},
|
|
2840
|
+
body: JSON.stringify({
|
|
2841
|
+
workspaceMode: "worktree",
|
|
2842
|
+
}),
|
|
2843
|
+
});
|
|
2844
|
+
expect(createResponse.status).toBe(400);
|
|
2845
|
+
await expect(createResponse.json()).resolves.toEqual({
|
|
2846
|
+
error: "Worktree terminals require a git repository at the workspace root.",
|
|
2847
|
+
});
|
|
2848
|
+
|
|
2849
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
2850
|
+
method: "GET",
|
|
2851
|
+
headers: {
|
|
2852
|
+
Accept: "application/json",
|
|
2853
|
+
},
|
|
2854
|
+
});
|
|
2855
|
+
expect(listResponse.status).toBe(200);
|
|
2856
|
+
await expect(listResponse.json()).resolves.toEqual([]);
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
it("returns 400 when tentacle name is empty after trimming", async () => {
|
|
2860
|
+
const baseUrl = await startServer();
|
|
2861
|
+
|
|
2862
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2863
|
+
method: "POST",
|
|
2864
|
+
headers: {
|
|
2865
|
+
Accept: "application/json",
|
|
2866
|
+
"Content-Type": "application/json",
|
|
2867
|
+
},
|
|
2868
|
+
body: JSON.stringify({ name: " " }),
|
|
2869
|
+
});
|
|
2870
|
+
|
|
2871
|
+
expect(createResponse.status).toBe(400);
|
|
2872
|
+
|
|
2873
|
+
const validCreateResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
2874
|
+
method: "POST",
|
|
2875
|
+
headers: {
|
|
2876
|
+
Accept: "application/json",
|
|
2877
|
+
},
|
|
2878
|
+
});
|
|
2879
|
+
expect(validCreateResponse.status).toBe(201);
|
|
2880
|
+
|
|
2881
|
+
const renameResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
2882
|
+
method: "PATCH",
|
|
2883
|
+
headers: {
|
|
2884
|
+
Accept: "application/json",
|
|
2885
|
+
"Content-Type": "application/json",
|
|
2886
|
+
},
|
|
2887
|
+
body: JSON.stringify({ name: " " }),
|
|
2888
|
+
});
|
|
2889
|
+
|
|
2890
|
+
expect(renameResponse.status).toBe(400);
|
|
2891
|
+
});
|
|
2892
|
+
|
|
2893
|
+
it("spawns a shared-workspace todo agent for an individual item", async () => {
|
|
2894
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2895
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2896
|
+
mkdirSync(join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge"), {
|
|
2897
|
+
recursive: true,
|
|
2898
|
+
});
|
|
2899
|
+
writeFileSync(
|
|
2900
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "CONTEXT.md"),
|
|
2901
|
+
"# Docs & Knowledge\n",
|
|
2902
|
+
"utf8",
|
|
2903
|
+
);
|
|
2904
|
+
writeFileSync(
|
|
2905
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "todo.md"),
|
|
2906
|
+
"# Todo\n\n- [ ] Audit docs\n- [ ] Consolidate principles\n",
|
|
2907
|
+
"utf8",
|
|
2908
|
+
);
|
|
2909
|
+
|
|
2910
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
2911
|
+
|
|
2912
|
+
const solveResponse = await fetch(`${baseUrl}/api/deck/tentacles/docs-knowledge/todo/solve`, {
|
|
2913
|
+
method: "POST",
|
|
2914
|
+
headers: {
|
|
2915
|
+
Accept: "application/json",
|
|
2916
|
+
"Content-Type": "application/json",
|
|
2917
|
+
},
|
|
2918
|
+
body: JSON.stringify({ itemIndex: 0 }),
|
|
2919
|
+
});
|
|
2920
|
+
|
|
2921
|
+
expect(solveResponse.status).toBe(201);
|
|
2922
|
+
await expect(solveResponse.json()).resolves.toEqual({
|
|
2923
|
+
terminalId: "docs-knowledge-todo-0",
|
|
2924
|
+
tentacleId: "docs-knowledge",
|
|
2925
|
+
itemIndex: 0,
|
|
2926
|
+
workspaceMode: "shared",
|
|
2927
|
+
});
|
|
2928
|
+
|
|
2929
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
2930
|
+
method: "GET",
|
|
2931
|
+
headers: {
|
|
2932
|
+
Accept: "application/json",
|
|
2933
|
+
},
|
|
2934
|
+
});
|
|
2935
|
+
|
|
2936
|
+
expect(listResponse.status).toBe(200);
|
|
2937
|
+
await expect(listResponse.json()).resolves.toEqual([
|
|
2938
|
+
expect.objectContaining({
|
|
2939
|
+
terminalId: "docs-knowledge-todo-0",
|
|
2940
|
+
tentacleId: "docs-knowledge",
|
|
2941
|
+
tentacleName: "Docs & Knowledge",
|
|
2942
|
+
workspaceMode: "shared",
|
|
2943
|
+
}),
|
|
2944
|
+
]);
|
|
2945
|
+
});
|
|
2946
|
+
|
|
2947
|
+
it("auto-renames todo agents from the todo item context on first prompt submit", async () => {
|
|
2948
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
2949
|
+
temporaryDirectories.push(workspaceCwd);
|
|
2950
|
+
mkdirSync(join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge"), {
|
|
2951
|
+
recursive: true,
|
|
2952
|
+
});
|
|
2953
|
+
writeFileSync(
|
|
2954
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "CONTEXT.md"),
|
|
2955
|
+
"# Docs & Knowledge\n",
|
|
2956
|
+
"utf8",
|
|
2957
|
+
);
|
|
2958
|
+
writeFileSync(
|
|
2959
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "todo.md"),
|
|
2960
|
+
"# Todo\n\n- [ ] Audit docs\n- [ ] Consolidate principles\n",
|
|
2961
|
+
"utf8",
|
|
2962
|
+
);
|
|
2963
|
+
|
|
2964
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
2965
|
+
|
|
2966
|
+
const solveResponse = await fetch(`${baseUrl}/api/deck/tentacles/docs-knowledge/todo/solve`, {
|
|
2967
|
+
method: "POST",
|
|
2968
|
+
headers: {
|
|
2969
|
+
Accept: "application/json",
|
|
2970
|
+
"Content-Type": "application/json",
|
|
2971
|
+
},
|
|
2972
|
+
body: JSON.stringify({ itemIndex: 0 }),
|
|
2973
|
+
});
|
|
2974
|
+
expect(solveResponse.status).toBe(201);
|
|
2975
|
+
|
|
2976
|
+
const hookResponse = await fetch(
|
|
2977
|
+
`${baseUrl}/api/hooks/user-prompt-submit?octogent_session=docs-knowledge-todo-0`,
|
|
2978
|
+
{
|
|
2979
|
+
method: "POST",
|
|
2980
|
+
headers: { "Content-Type": "application/json" },
|
|
2981
|
+
body: JSON.stringify({ prompt: "Generic worker prompt body" }),
|
|
2982
|
+
},
|
|
2983
|
+
);
|
|
2984
|
+
expect(hookResponse.status).toBe(200);
|
|
2985
|
+
|
|
2986
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
2987
|
+
method: "GET",
|
|
2988
|
+
headers: {
|
|
2989
|
+
Accept: "application/json",
|
|
2990
|
+
},
|
|
2991
|
+
});
|
|
2992
|
+
|
|
2993
|
+
expect(listResponse.status).toBe(200);
|
|
2994
|
+
await expect(listResponse.json()).resolves.toEqual([
|
|
2995
|
+
expect.objectContaining({
|
|
2996
|
+
terminalId: "docs-knowledge-todo-0",
|
|
2997
|
+
tentacleId: "docs-knowledge",
|
|
2998
|
+
tentacleName: "Audit docs",
|
|
2999
|
+
workspaceMode: "shared",
|
|
3000
|
+
}),
|
|
3001
|
+
]);
|
|
3002
|
+
});
|
|
3003
|
+
|
|
3004
|
+
it("limits swarm prompts to the top-priority items that fit under the child cap", async () => {
|
|
3005
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
3006
|
+
temporaryDirectories.push(workspaceCwd);
|
|
3007
|
+
mkdirSync(join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge"), {
|
|
3008
|
+
recursive: true,
|
|
3009
|
+
});
|
|
3010
|
+
writeFileSync(
|
|
3011
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "CONTEXT.md"),
|
|
3012
|
+
"# Docs & Knowledge\n",
|
|
3013
|
+
"utf8",
|
|
3014
|
+
);
|
|
3015
|
+
const todoItems = Array.from(
|
|
3016
|
+
{ length: MAX_CHILDREN_PER_PARENT + 4 },
|
|
3017
|
+
(_, index) => `- [ ] item ${index}`,
|
|
3018
|
+
).join("\n");
|
|
3019
|
+
writeFileSync(
|
|
3020
|
+
join(workspaceCwd, ".octogent", "tentacles", "docs-knowledge", "todo.md"),
|
|
3021
|
+
`# Todo\n\n${todoItems}\n`,
|
|
3022
|
+
"utf8",
|
|
3023
|
+
);
|
|
3024
|
+
|
|
3025
|
+
const baseUrl = await startServer({ workspaceCwd });
|
|
3026
|
+
|
|
3027
|
+
const swarmResponse = await fetch(`${baseUrl}/api/deck/tentacles/docs-knowledge/swarm`, {
|
|
3028
|
+
method: "POST",
|
|
3029
|
+
headers: {
|
|
3030
|
+
Accept: "application/json",
|
|
3031
|
+
"Content-Type": "application/json",
|
|
3032
|
+
},
|
|
3033
|
+
body: JSON.stringify({}),
|
|
3034
|
+
});
|
|
3035
|
+
|
|
3036
|
+
expect(swarmResponse.status).toBe(201);
|
|
3037
|
+
await expect(swarmResponse.json()).resolves.toEqual({
|
|
3038
|
+
tentacleId: "docs-knowledge",
|
|
3039
|
+
parentTerminalId: "docs-knowledge-swarm-parent",
|
|
3040
|
+
workers: Array.from({ length: MAX_CHILDREN_PER_PARENT }, (_, index) => ({
|
|
3041
|
+
terminalId: `docs-knowledge-swarm-${index}`,
|
|
3042
|
+
todoIndex: index,
|
|
3043
|
+
todoText: `item ${index}`,
|
|
3044
|
+
})),
|
|
3045
|
+
});
|
|
3046
|
+
|
|
3047
|
+
const promptTemplate = readFileSync(
|
|
3048
|
+
join(process.cwd(), "..", "..", "prompts", "swarm-parent.md"),
|
|
3049
|
+
"utf8",
|
|
3050
|
+
);
|
|
3051
|
+
expect(promptTemplate).toContain(
|
|
3052
|
+
"Treat the listed workers as the highest-priority items and proceed without asking the user whether to batch, reprioritize, or raise the limit.",
|
|
3053
|
+
);
|
|
3054
|
+
});
|
|
3055
|
+
|
|
3056
|
+
it("deletes a tentacle and removes it from snapshots", async () => {
|
|
3057
|
+
const baseUrl = await startServer();
|
|
3058
|
+
|
|
3059
|
+
const createResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
3060
|
+
method: "POST",
|
|
3061
|
+
headers: {
|
|
3062
|
+
Accept: "application/json",
|
|
3063
|
+
},
|
|
3064
|
+
});
|
|
3065
|
+
expect(createResponse.status).toBe(201);
|
|
3066
|
+
|
|
3067
|
+
const deleteResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
3068
|
+
method: "DELETE",
|
|
3069
|
+
headers: {
|
|
3070
|
+
Accept: "application/json",
|
|
3071
|
+
},
|
|
3072
|
+
});
|
|
3073
|
+
expect(deleteResponse.status).toBe(204);
|
|
3074
|
+
|
|
3075
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
3076
|
+
method: "GET",
|
|
3077
|
+
headers: {
|
|
3078
|
+
Accept: "application/json",
|
|
3079
|
+
},
|
|
3080
|
+
});
|
|
3081
|
+
expect(listResponse.status).toBe(200);
|
|
3082
|
+
await expect(listResponse.json()).resolves.toEqual([]);
|
|
3083
|
+
|
|
3084
|
+
const missingResponse = await fetch(`${baseUrl}/api/terminals/terminal-1`, {
|
|
3085
|
+
method: "DELETE",
|
|
3086
|
+
headers: {
|
|
3087
|
+
Accept: "application/json",
|
|
3088
|
+
},
|
|
3089
|
+
});
|
|
3090
|
+
expect(missingResponse.status).toBe(204);
|
|
3091
|
+
});
|
|
3092
|
+
|
|
3093
|
+
it("deletes descendant terminals when deleting a parent terminal", async () => {
|
|
3094
|
+
const baseUrl = await startServer();
|
|
3095
|
+
|
|
3096
|
+
const createParentResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
3097
|
+
method: "POST",
|
|
3098
|
+
headers: {
|
|
3099
|
+
Accept: "application/json",
|
|
3100
|
+
"Content-Type": "application/json",
|
|
3101
|
+
},
|
|
3102
|
+
body: JSON.stringify({ terminalId: "parent-terminal" }),
|
|
3103
|
+
});
|
|
3104
|
+
expect(createParentResponse.status).toBe(201);
|
|
3105
|
+
|
|
3106
|
+
const createChildResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
3107
|
+
method: "POST",
|
|
3108
|
+
headers: {
|
|
3109
|
+
Accept: "application/json",
|
|
3110
|
+
"Content-Type": "application/json",
|
|
3111
|
+
},
|
|
3112
|
+
body: JSON.stringify({
|
|
3113
|
+
terminalId: "child-terminal",
|
|
3114
|
+
parentTerminalId: "parent-terminal",
|
|
3115
|
+
}),
|
|
3116
|
+
});
|
|
3117
|
+
expect(createChildResponse.status).toBe(201);
|
|
3118
|
+
|
|
3119
|
+
const createGrandchildResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
3120
|
+
method: "POST",
|
|
3121
|
+
headers: {
|
|
3122
|
+
Accept: "application/json",
|
|
3123
|
+
"Content-Type": "application/json",
|
|
3124
|
+
},
|
|
3125
|
+
body: JSON.stringify({
|
|
3126
|
+
terminalId: "grandchild-terminal",
|
|
3127
|
+
parentTerminalId: "child-terminal",
|
|
3128
|
+
}),
|
|
3129
|
+
});
|
|
3130
|
+
expect(createGrandchildResponse.status).toBe(201);
|
|
3131
|
+
|
|
3132
|
+
const createSiblingResponse = await fetch(`${baseUrl}/api/terminals`, {
|
|
3133
|
+
method: "POST",
|
|
3134
|
+
headers: {
|
|
3135
|
+
Accept: "application/json",
|
|
3136
|
+
"Content-Type": "application/json",
|
|
3137
|
+
},
|
|
3138
|
+
body: JSON.stringify({ terminalId: "unrelated-terminal" }),
|
|
3139
|
+
});
|
|
3140
|
+
expect(createSiblingResponse.status).toBe(201);
|
|
3141
|
+
|
|
3142
|
+
const deleteResponse = await fetch(`${baseUrl}/api/terminals/parent-terminal`, {
|
|
3143
|
+
method: "DELETE",
|
|
3144
|
+
headers: {
|
|
3145
|
+
Accept: "application/json",
|
|
3146
|
+
},
|
|
3147
|
+
});
|
|
3148
|
+
expect(deleteResponse.status).toBe(204);
|
|
3149
|
+
|
|
3150
|
+
const listResponse = await fetch(`${baseUrl}/api/terminal-snapshots`, {
|
|
3151
|
+
method: "GET",
|
|
3152
|
+
headers: {
|
|
3153
|
+
Accept: "application/json",
|
|
3154
|
+
},
|
|
3155
|
+
});
|
|
3156
|
+
expect(listResponse.status).toBe(200);
|
|
3157
|
+
await expect(listResponse.json()).resolves.toEqual([
|
|
3158
|
+
expect.objectContaining({ terminalId: "unrelated-terminal" }),
|
|
3159
|
+
]);
|
|
3160
|
+
});
|
|
3161
|
+
|
|
3162
|
+
it("restores tentacles across API restarts using persisted registry", async () => {
|
|
3163
|
+
const workspaceCwd = mkdtempSync(join(tmpdir(), "octogent-api-test-"));
|
|
3164
|
+
temporaryDirectories.push(workspaceCwd);
|
|
3165
|
+
|
|
3166
|
+
const firstBaseUrl = await startServer({
|
|
3167
|
+
workspaceCwd,
|
|
3168
|
+
});
|
|
3169
|
+
|
|
3170
|
+
const createResponse = await fetch(`${firstBaseUrl}/api/terminals`, {
|
|
3171
|
+
method: "POST",
|
|
3172
|
+
headers: {
|
|
3173
|
+
Accept: "application/json",
|
|
3174
|
+
"Content-Type": "application/json",
|
|
3175
|
+
},
|
|
3176
|
+
body: JSON.stringify({ name: "planner" }),
|
|
3177
|
+
});
|
|
3178
|
+
expect(createResponse.status).toBe(201);
|
|
3179
|
+
|
|
3180
|
+
if (stopServer) {
|
|
3181
|
+
await stopServer();
|
|
3182
|
+
stopServer = null;
|
|
3183
|
+
}
|
|
3184
|
+
|
|
3185
|
+
const secondBaseUrl = await startServer({
|
|
3186
|
+
workspaceCwd,
|
|
3187
|
+
});
|
|
3188
|
+
|
|
3189
|
+
const listResponse = await fetch(`${secondBaseUrl}/api/terminal-snapshots`, {
|
|
3190
|
+
method: "GET",
|
|
3191
|
+
headers: {
|
|
3192
|
+
Accept: "application/json",
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
|
|
3196
|
+
expect(listResponse.status).toBe(200);
|
|
3197
|
+
await expect(listResponse.json()).resolves.toEqual(
|
|
3198
|
+
expect.arrayContaining([
|
|
3199
|
+
expect.objectContaining({
|
|
3200
|
+
terminalId: "terminal-1",
|
|
3201
|
+
tentacleId: "terminal-1",
|
|
3202
|
+
tentacleName: "planner",
|
|
3203
|
+
}),
|
|
3204
|
+
]),
|
|
3205
|
+
);
|
|
3206
|
+
});
|
|
3207
|
+
});
|