mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c8c1c528f | |||
| bf8b57ba12 |
@@ -1,5 +1,5 @@
|
||||
# Workflow that runs python tests
|
||||
name: Run Python Tests
|
||||
# Workflow that runs python unit tests
|
||||
name: Run Python Unit Tests
|
||||
|
||||
# The jobs in this workflow are required, so they must run at all times
|
||||
# * Always run on "main"
|
||||
@@ -16,9 +16,9 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Run python tests on Linux
|
||||
# Run python unit tests on Linux
|
||||
test-on-linux:
|
||||
name: Python Tests on Linux
|
||||
name: Python Unit Tests on Linux
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
env:
|
||||
INSTALL_DOCKER: '0' # Set to '0' to skip Docker installation
|
||||
@@ -51,8 +51,6 @@ jobs:
|
||||
run: poetry run pytest --forked -n auto -svv ./tests/unit
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: TEST_RUNTIME=cli poetry run pytest -svv tests/runtime/test_bash.py
|
||||
- name: Run E2E Tests
|
||||
run: poetry run pytest -svv tests/e2e
|
||||
|
||||
# Run specific Windows python tests
|
||||
test-on-windows:
|
||||
@@ -51,7 +51,8 @@ Giving GitHub repository access to OpenHands also allows you to work on GitHub i
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
On your repository, label an issue with `openhands` or add a message starting with
|
||||
`@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a pull request if it determines that the issue has been successfully resolved.
|
||||
@@ -64,8 +65,6 @@ To get OpenHands to work on pull requests, mention `@openhands` in the comments
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
**Important Note**: The `@openhands` mention functionality in pull requests only works if the pull request is both *to* and *from* a repository that you have added through the interface. This is because OpenHands needs appropriate permissions to access both repositories.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: GitLab Integration
|
||||
description: This guide walks you through the process of installing OpenHands Cloud for your GitLab repositories. Once
|
||||
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
|
||||
set up, it will allow OpenHands to work with your GitLab repository.
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
@@ -25,33 +25,6 @@ OpenHands requests an API-scoped token during OAuth authentication. By default,
|
||||
To restrict the agent's permissions, you can define a custom secret `GITLAB_TOKEN`, which will override the default token assigned to the agent.
|
||||
While the high-permission API token is still requested and used for other components of the application (e.g. opening merge requests), the agent will not have access to it.
|
||||
|
||||
## Working on GitLab Issues and Merge Requests Using Openhands
|
||||
|
||||
<Note>
|
||||
This feature works for personal projects and is available for group projects with a
|
||||
[Premium or Ultimate tier subscription](https://docs.gitlab.com/user/project/integrations/webhooks/#group-webhooks).
|
||||
|
||||
A webhook is automatically installed within a few minutes after the owner/maintainer of the project or group logs into
|
||||
OpenHands Cloud. If you decide to delete the webhook, then re-installing will require the support of All Hands AI but we are planning to improve this in a future release.
|
||||
</Note>
|
||||
|
||||
Giving GitLab repository access to OpenHands also allows you to work on GitLab issues and merge requests directly.
|
||||
|
||||
### Working with Issues
|
||||
|
||||
On your repository, label an issue with `openhands` or add a message starting with `@openhands`. OpenHands will:
|
||||
1. Comment on the issue to let you know it is working on it.
|
||||
- You can click on the link to track the progress on OpenHands Cloud.
|
||||
2. Open a merge request if it determines that the issue has been successfully resolved.
|
||||
3. Comment on the issue with a summary of the performed tasks and a link to the PR.
|
||||
|
||||
### Working with Merge Requests
|
||||
|
||||
To get OpenHands to work on merge requests, mention `@openhands` in the comments to:
|
||||
- Ask questions
|
||||
- Request updates
|
||||
- Get code explanations
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn about the Cloud UI](/usage/cloud/cloud-ui).
|
||||
|
||||
@@ -129,159 +129,4 @@ describe("ActionSuggestions", () => {
|
||||
expect(createPRPrompt).toContain("meaningful branch name");
|
||||
expect(createPRPrompt).not.toContain("SAME branch name");
|
||||
});
|
||||
|
||||
it("should use correct provider name based on conversation git_provider, not user authenticated providers", async () => {
|
||||
// Test case for GitHub repository
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-github",
|
||||
title: "GitHub Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "github",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
// Mock user having both GitHub and Bitbucket tokens
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
github: "github-token",
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
expect(prButton).toBeInTheDocument();
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// The suggestion should mention GitHub, not Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitHub")
|
||||
);
|
||||
expect(onSuggestionsClick).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use GitLab terminology when git_provider is gitlab", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-gitlab",
|
||||
title: "GitLab Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "gitlab",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
gitlab: "gitlab-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention GitLab and "merge request" instead of "pull request"
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("GitLab")
|
||||
);
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("merge request")
|
||||
);
|
||||
});
|
||||
|
||||
it("should use Bitbucket terminology when git_provider is bitbucket", async () => {
|
||||
const getConversationSpy = vi.spyOn(OpenHands, "getConversation");
|
||||
getConversationSpy.mockResolvedValue({
|
||||
conversation_id: "test-bitbucket",
|
||||
title: "Bitbucket Test",
|
||||
selected_repository: "test-repo",
|
||||
git_provider: "bitbucket",
|
||||
selected_branch: "main",
|
||||
last_updated_at: new Date().toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
status: "RUNNING",
|
||||
runtime_status: "STATUS$READY",
|
||||
url: null,
|
||||
session_api_key: null,
|
||||
});
|
||||
|
||||
const getSettingsSpy = vi.spyOn(OpenHands, "getSettings");
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {
|
||||
bitbucket: "bitbucket-token",
|
||||
},
|
||||
});
|
||||
|
||||
const onSuggestionsClick = vi.fn();
|
||||
render(<ActionSuggestions onSuggestionsClick={onSuggestionsClick} />, {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
const buttons = await screen.findAllByTestId("suggestion");
|
||||
const prButton = buttons.find((button) =>
|
||||
button.textContent?.includes("Push & Create PR"),
|
||||
);
|
||||
|
||||
if (prButton) {
|
||||
prButton.click();
|
||||
}
|
||||
|
||||
// Should mention Bitbucket
|
||||
expect(onSuggestionsClick).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Bitbucket")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+12
-10
@@ -20,7 +20,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.10.0",
|
||||
@@ -4905,9 +4905,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
"integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="
|
||||
"version": "1.0.0-beta.19",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
|
||||
"integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/pluginutils": {
|
||||
"version": "5.2.0",
|
||||
@@ -6648,14 +6649,15 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
"integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz",
|
||||
"integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.28.0",
|
||||
"@babel/core": "^7.27.4",
|
||||
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
|
||||
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.27",
|
||||
"@rolldown/pluginutils": "1.0.0-beta.19",
|
||||
"@types/babel__core": "^7.20.5",
|
||||
"react-refresh": "^0.17.0"
|
||||
},
|
||||
@@ -6663,7 +6665,7 @@
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react/node_modules/react-refresh": {
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.83.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"axios": "^1.10.0",
|
||||
|
||||
@@ -489,24 +489,6 @@ class OpenHands {
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the GitHub user installation IDs
|
||||
* @returns List of GitHub installation IDs
|
||||
*/
|
||||
static async getGitHubUserInstallationIds(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/github/installations");
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the BitBucket workspaces
|
||||
* @returns List of BitBucket workspaces
|
||||
*/
|
||||
static async getBitBucketWorkspaces(): Promise<string[]> {
|
||||
const { data } = await openHands.get<string[]>("/bitbucket/installations");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -19,11 +19,8 @@ export function ActionSuggestions({
|
||||
const [hasPullRequest, setHasPullRequest] = React.useState(false);
|
||||
|
||||
const providersAreSet = providers.length > 0;
|
||||
|
||||
// Use the git_provider from the conversation, not the user's authenticated providers
|
||||
const currentGitProvider = conversation?.git_provider;
|
||||
const isGitLab = currentGitProvider === "gitlab";
|
||||
const isBitbucket = currentGitProvider === "bitbucket";
|
||||
const isGitLab = providers.includes("gitlab");
|
||||
const isBitbucket = providers.includes("bitbucket");
|
||||
|
||||
const pr = isGitLab ? "merge request" : "pull request";
|
||||
const prShort = isGitLab ? "MR" : "PR";
|
||||
|
||||
@@ -10,9 +10,6 @@ import { BrandButton } from "../settings/brand-button";
|
||||
import { useSearchRepositories } from "#/hooks/query/use-search-repositories";
|
||||
import { useDebounce } from "#/hooks/use-debounce";
|
||||
import { sanitizeQuery } from "#/utils/sanitize-query";
|
||||
import { useUserProviders } from "#/hooks/use-user-providers";
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SettingsDropdownInput } from "../settings/settings-dropdown-input";
|
||||
import {
|
||||
RepositoryDropdown,
|
||||
RepositoryLoadingState,
|
||||
@@ -35,10 +32,8 @@ export function RepositorySelectionForm({
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<Branch | null>(
|
||||
null,
|
||||
);
|
||||
const [selectedProvider, setSelectedProvider] = React.useState<Provider | null>(null);
|
||||
// Add a ref to track if the branch was manually cleared by the user
|
||||
const branchManuallyClearedRef = React.useRef<boolean>(false);
|
||||
const { providers } = useUserProviders();
|
||||
const {
|
||||
data: repositories,
|
||||
isLoading: isLoadingRepositories,
|
||||
@@ -61,13 +56,6 @@ export function RepositorySelectionForm({
|
||||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const { data: searchedRepos } = useSearchRepositories(debouncedSearchQuery);
|
||||
|
||||
// Auto-select provider if there's only one
|
||||
React.useEffect(() => {
|
||||
if (providers.length === 1 && !selectedProvider) {
|
||||
setSelectedProvider(providers[0]);
|
||||
}
|
||||
}, [providers, selectedProvider]);
|
||||
|
||||
// Auto-select main or master branch if it exists, but only if the branch wasn't manually cleared
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
@@ -95,10 +83,8 @@ export function RepositorySelectionForm({
|
||||
const isCreatingConversation =
|
||||
isPending || isSuccess || isCreatingConversationElsewhere;
|
||||
|
||||
// Use all repositories without filtering by provider for now
|
||||
const allRepositories = repositories?.concat(searchedRepos || []);
|
||||
|
||||
const repositoriesItems = (allRepositories || []).map((repo) => ({
|
||||
const repositoriesItems = allRepositories?.map((repo) => ({
|
||||
key: repo.id,
|
||||
label: decodeURIComponent(repo.full_name),
|
||||
}));
|
||||
@@ -108,14 +94,6 @@ export function RepositorySelectionForm({
|
||||
label: branch.name,
|
||||
}));
|
||||
|
||||
// Create provider dropdown items
|
||||
const providerItems = React.useMemo(() => {
|
||||
return providers.map(provider => ({
|
||||
key: provider,
|
||||
label: provider.charAt(0).toUpperCase() + provider.slice(1), // Capitalize first letter
|
||||
}));
|
||||
}, [providers]);
|
||||
|
||||
const handleRepoSelection = (key: React.Key | null) => {
|
||||
const selectedRepo = allRepositories?.find((repo) => repo.id === key);
|
||||
if (selectedRepo) onRepoSelection(selectedRepo);
|
||||
@@ -124,14 +102,6 @@ export function RepositorySelectionForm({
|
||||
branchManuallyClearedRef.current = false; // Reset the flag when repo changes
|
||||
};
|
||||
|
||||
const handleProviderSelection = (key: React.Key | null) => {
|
||||
const provider = key as Provider | null;
|
||||
setSelectedProvider(provider);
|
||||
setSelectedRepository(null); // Reset repository selection when provider changes
|
||||
setSelectedBranch(null); // Reset branch selection when provider changes
|
||||
onRepoSelection(null); // Reset parent component's selected repo
|
||||
};
|
||||
|
||||
const handleBranchSelection = (key: React.Key | null) => {
|
||||
const selectedBranchObj = branches?.find((branch) => branch.name === key);
|
||||
setSelectedBranch(selectedBranchObj || null);
|
||||
@@ -163,26 +133,6 @@ export function RepositorySelectionForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Render the provider dropdown
|
||||
const renderProviderSelector = () => {
|
||||
// Only render if there are multiple providers
|
||||
if (providers.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsDropdownInput
|
||||
testId="provider-dropdown"
|
||||
name="provider-dropdown"
|
||||
placeholder="Select Provider"
|
||||
items={providerItems}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={handleProviderSelection}
|
||||
selectedKey={selectedProvider || undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Render the appropriate UI based on the loading/error state
|
||||
const renderRepositorySelector = () => {
|
||||
if (isLoadingRepositories) {
|
||||
@@ -193,15 +143,11 @@ export function RepositorySelectionForm({
|
||||
return <RepositoryErrorState />;
|
||||
}
|
||||
|
||||
// For now, don't disable the repo dropdown based on provider selection
|
||||
const isDisabled = false;
|
||||
|
||||
return (
|
||||
<RepositoryDropdown
|
||||
items={repositoriesItems || []}
|
||||
onSelectionChange={handleRepoSelection}
|
||||
onInputChange={handleRepoInputChange}
|
||||
isDisabled={isDisabled}
|
||||
defaultFilter={(textValue, inputValue) => {
|
||||
if (!inputValue) return true;
|
||||
|
||||
@@ -249,8 +195,8 @@ export function RepositorySelectionForm({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{renderProviderSelector()}
|
||||
{renderRepositorySelector()}
|
||||
|
||||
{renderBranchSelector()}
|
||||
|
||||
<BrandButton
|
||||
|
||||
@@ -8,7 +8,6 @@ export interface RepositoryDropdownProps {
|
||||
onSelectionChange: (key: React.Key | null) => void;
|
||||
onInputChange: (value: string) => void;
|
||||
defaultFilter?: (textValue: string, inputValue: string) => boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export function RepositoryDropdown({
|
||||
@@ -16,7 +15,6 @@ export function RepositoryDropdown({
|
||||
onSelectionChange,
|
||||
onInputChange,
|
||||
defaultFilter,
|
||||
isDisabled = false,
|
||||
}: RepositoryDropdownProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -24,13 +22,12 @@ export function RepositoryDropdown({
|
||||
<SettingsDropdownInput
|
||||
testId="repo-dropdown"
|
||||
name="repo-dropdown"
|
||||
placeholder={isDisabled ? t("Please select a provider first") : t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
placeholder={t(I18nKey.REPOSITORY$SELECT_REPO)}
|
||||
items={items}
|
||||
wrapperClassName="max-w-[500px]"
|
||||
onSelectionChange={onSelectionChange}
|
||||
onInputChange={onInputChange}
|
||||
defaultFilter={defaultFilter}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+9
-16
@@ -1,27 +1,20 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
export function MicroagentManagementAddMicroagentButton() {
|
||||
interface MicroagentManagementAddMicroagentButtonProps {
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function MicroagentManagementAddMicroagentButton({
|
||||
onClick,
|
||||
}: MicroagentManagementAddMicroagentButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { addMicroagentModalVisible } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm font-normal text-[#8480FF] cursor-pointer"
|
||||
onClick={handleClick}
|
||||
className="text-sm font-normal text-[#8480FF] cursor-pointer outline-none border-none"
|
||||
onClick={onClick}
|
||||
>
|
||||
{t(I18nKey.COMMON$ADD_MICROAGENT)}
|
||||
</button>
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { FaCircleInfo } from "react-icons/fa6";
|
||||
import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
|
||||
import { ModalBody } from "#/components/shared/modals/modal-body";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BadgeInput } from "#/components/shared/inputs/badge-input";
|
||||
|
||||
interface MicroagentManagementAddMicroagentModalProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MicroagentManagementAddMicroagentModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: MicroagentManagementAddMicroagentModalProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [triggers, setTriggers] = useState<string[]>([]);
|
||||
|
||||
const { selectedRepository } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const modalTitle = selectedRepository
|
||||
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
|
||||
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
|
||||
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start rounded-[12px] p-6 min-w-[611px]">
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-white text-xl font-medium">{modalTitle}</h2>
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-overview#microagents-overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" onClick={onCancel} className="cursor-pointer">
|
||||
<XIcon width={24} height={24} color="#F9FBFE" />
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-white text-sm font-normal">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION)}
|
||||
</span>
|
||||
</div>
|
||||
<form
|
||||
data-testid="add-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<label
|
||||
htmlFor="query-input"
|
||||
className="flex flex-col gap-2 w-full text-sm font-normal"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$WHAT_TO_DO)}
|
||||
<textarea
|
||||
required
|
||||
data-testid="query-input"
|
||||
name="query-input"
|
||||
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
|
||||
rows={6}
|
||||
className={cn(
|
||||
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt resize-none",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-[11px] font-normal text-white leading-[16px]">
|
||||
<span className="font-semibold">
|
||||
{t(I18nKey.COMMON$FOR_EXAMPLE)}:
|
||||
</span>
|
||||
<span className="underline">
|
||||
{t(I18nKey.COMMON$TEST_DB_MIGRATION)}
|
||||
</span>
|
||||
<span className="underline">{t(I18nKey.COMMON$RUN_TEST)}</span>
|
||||
<span className="underline">{t(I18nKey.COMMON$RUN_APP)}</span>
|
||||
<span className="underline">
|
||||
{t(I18nKey.COMMON$LEARN_FILE_STRUCTURE)}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="trigger-input"
|
||||
className="flex flex-col gap-2.5 w-full text-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$ADD_TRIGGERS)}
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/prompting/microagents-keyword"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<FaCircleInfo className="text-primary" />
|
||||
</a>
|
||||
</div>
|
||||
<BadgeInput
|
||||
name="trigger-input"
|
||||
value={triggers}
|
||||
placeholder={t("MICROAGENT$TYPE_TRIGGER_SPACE")}
|
||||
onChange={setTriggers}
|
||||
/>
|
||||
<span className="text-xs text-[#ffffff80] font-normal">
|
||||
{t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS,
|
||||
)}
|
||||
</span>
|
||||
</label>
|
||||
</form>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 w-full"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
data-testid="cancel-button"
|
||||
>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
data-testid="confirm-button"
|
||||
>
|
||||
{t(I18nKey.MICROAGENT$LAUNCH)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</ModalBody>
|
||||
</ModalBackdrop>
|
||||
);
|
||||
}
|
||||
+3
-3
@@ -1,5 +1,5 @@
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
@@ -13,10 +13,10 @@ export function MicroagentManagementMain() {
|
||||
if (!selectedMicroagent) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col h-full items-center justify-center">
|
||||
<div className="text-[#F9FBFE] text-xl font-bold pb-4">
|
||||
<div className="text-[#F9FBFE] text-[20px] font-bold pb-4">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT)}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal text-center max-w-[455px]">
|
||||
<div className="text-white text-[14px] font-normal text-center max-w-[455px]">
|
||||
{t(
|
||||
I18nKey.MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES,
|
||||
)}
|
||||
|
||||
+2
-2
@@ -22,10 +22,10 @@ export function MicroagentManagementMicroagentCard({
|
||||
<div className="text-white text-[16px] font-semibold">
|
||||
{microagent.name}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal">
|
||||
<div className="text-white text-[14px] font-normal">
|
||||
{microagent.repositoryUrl}
|
||||
</div>
|
||||
<div className="text-white text-sm font-normal">
|
||||
<div className="text-white text-[14px] font-normal">
|
||||
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ export function MicroagentManagementMicroagents() {
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-end pb-4">
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
<MicroagentManagementAddMicroagentButton onClick={() => {}} />
|
||||
</div>
|
||||
{microagents.map((microagent) => (
|
||||
<div key={microagent.id} className="pb-4">
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ export function MicroagentManagementRepoMicroagent({
|
||||
<div className="text-white text-base font-normal">
|
||||
{repoMicroagent.repositoryName}
|
||||
</div>
|
||||
<MicroagentManagementAddMicroagentButton />
|
||||
<MicroagentManagementAddMicroagentButton onClick={() => {}} />
|
||||
</div>
|
||||
{numberOfMicroagents === 0 && (
|
||||
<MicroagentManagementLearnThisRepo
|
||||
|
||||
+1
-1
@@ -10,7 +10,7 @@ export function MicroagentManagementSidebarHeader() {
|
||||
<h1 className="text-white text-[28px] font-bold">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIPTION)}
|
||||
</h1>
|
||||
<p className="text-white text-sm font-normal leading-[20px] pt-2">
|
||||
<p className="text-white text-[14px] font-normal leading-[20px] pt-2">
|
||||
{t(I18nKey.MICROAGENT_MANAGEMENT$USE_MICROAGENTS)}
|
||||
<QuestionCircleIcon className="inline-block ml-1" />
|
||||
</p>
|
||||
|
||||
@@ -32,26 +32,32 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
|
||||
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
|
||||
</p>
|
||||
</div>
|
||||
{!isEditing && (
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div className="flex items-center">
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-blue-400 hover:underline mr-3"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t(I18nKey.COMMON$DOCUMENTATION)}
|
||||
</a>
|
||||
<BrandButton
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
>
|
||||
{isEditing
|
||||
? t(I18nKey.SETTINGS$MCP_CANCEL)
|
||||
: t(I18nKey.SETTINGS$MCP_EDIT_CONFIGURATION)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{isEditing ? (
|
||||
<MCPJsonEditor
|
||||
mcpConfig={mcpConfig}
|
||||
onChange={handleConfigChange}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
/>
|
||||
<MCPJsonEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-6">
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { MCPConfig } from "#/types/settings";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { BrandButton } from "../brand-button";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface MCPJsonEditorProps {
|
||||
mcpConfig?: MCPConfig;
|
||||
onChange: (config: MCPConfig) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function MCPJsonEditor({
|
||||
mcpConfig,
|
||||
onChange,
|
||||
onCancel,
|
||||
}: MCPJsonEditorProps) {
|
||||
export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [configText, setConfigText] = useState(() =>
|
||||
mcpConfig
|
||||
@@ -71,31 +65,11 @@ export function MCPJsonEditor({
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-2 text-sm text-gray-400">
|
||||
<Trans
|
||||
i18nKey={I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION}
|
||||
components={{
|
||||
a: (
|
||||
<a
|
||||
href="https://docs.all-hands.dev/usage/mcp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
<div className="mb-2 text-sm text-gray-400">
|
||||
{t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)}
|
||||
</div>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full h-64 resize-y p-2 rounded-sm text-sm font-mono",
|
||||
"bg-tertiary border border-[#717888]",
|
||||
"placeholder:italic placeholder:text-tertiary-alt",
|
||||
"focus:outline-none focus:ring-1 focus:ring-primary",
|
||||
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
|
||||
)}
|
||||
className="w-full h-64 p-2 text-sm font-mono bg-base-tertiary rounded-md focus:border-blue-500 focus:outline-hidden"
|
||||
value={configText}
|
||||
onChange={handleTextChange}
|
||||
spellCheck="false"
|
||||
@@ -113,12 +87,9 @@ export function MCPJsonEditor({
|
||||
}
|
||||
</code>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<BrandButton type="button" variant="secondary" onClick={onCancel}>
|
||||
{t(I18nKey.BUTTON$CANCEL)}
|
||||
</BrandButton>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<BrandButton type="button" variant="primary" onClick={handleSave}>
|
||||
{t(I18nKey.SETTINGS$MCP_CONFIRM_CHANGES)}
|
||||
{t(I18nKey.SETTINGS$MCP_APPLY_CHANGES)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { FaX } from "react-icons/fa6";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { BrandBadge } from "../badge";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
|
||||
interface BadgeInputProps {
|
||||
name?: string;
|
||||
@@ -49,15 +49,14 @@ export function BadgeInput({
|
||||
>
|
||||
{value.map((badge, index) => (
|
||||
<div key={index}>
|
||||
<BrandBadge className="flex items-center gap-0.5 py-1 px-2.5 text-sm text-[#0D0F11] font-semibold leading-[16px]">
|
||||
<BrandBadge className="flex items-center gap-0.5">
|
||||
{badge}
|
||||
<button
|
||||
data-testid="remove-button"
|
||||
type="button"
|
||||
onClick={() => removeBadge(index)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<XIcon width={14} height={14} color="#000000" />
|
||||
<FaX className="w-3 h-3 text-black" />
|
||||
</button>
|
||||
</BrandBadge>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
|
||||
export const useAppInstallations = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["installations", providers, config?.GITHUB_CLIENT_ID],
|
||||
queryFn: OpenHands.getGitHubUserInstallationIds,
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
providers.includes("github") &&
|
||||
!!config?.GITHUB_CLIENT_ID &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConfig } from "./use-config";
|
||||
import { useIsAuthed } from "./use-is-authed";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { useUserProviders } from "../use-user-providers";
|
||||
|
||||
export const useBitbucketWorkspaces = () => {
|
||||
const { data: config } = useConfig();
|
||||
const { data: userIsAuthenticated } = useIsAuthed();
|
||||
const { providers } = useUserProviders();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["workspaces", providers],
|
||||
queryFn: OpenHands.getBitBucketWorkspaces,
|
||||
enabled:
|
||||
userIsAuthenticated &&
|
||||
providers.includes("bitbucket") &&
|
||||
config?.APP_MODE === "saas",
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -50,7 +50,8 @@ export enum I18nKey {
|
||||
SETTINGS$NAV_MCP = "SETTINGS$NAV_MCP",
|
||||
SETTINGS$MCP_CONFIGURATION = "SETTINGS$MCP_CONFIGURATION",
|
||||
SETTINGS$MCP_EDIT_CONFIGURATION = "SETTINGS$MCP_EDIT_CONFIGURATION",
|
||||
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_CHANGES",
|
||||
SETTINGS$MCP_CANCEL = "SETTINGS$MCP_CANCEL",
|
||||
SETTINGS$MCP_APPLY_CHANGES = "SETTINGS$MCP_APPLY_CHANGES",
|
||||
SETTINGS$MCP_CONFIG_DESCRIPTION = "SETTINGS$MCP_CONFIG_DESCRIPTION",
|
||||
SETTINGS$MCP_CONFIG_ERROR = "SETTINGS$MCP_CONFIG_ERROR",
|
||||
SETTINGS$MCP_CONFIG_EXAMPLE = "SETTINGS$MCP_CONFIG_EXAMPLE",
|
||||
@@ -578,6 +579,7 @@ export enum I18nKey {
|
||||
BITBUCKET$TOKEN_LINK_TEXT = "BITBUCKET$TOKEN_LINK_TEXT",
|
||||
BITBUCKET$INSTRUCTIONS_LINK_TEXT = "BITBUCKET$INSTRUCTIONS_LINK_TEXT",
|
||||
GITLAB$OR_SEE = "GITLAB$OR_SEE",
|
||||
COMMON$DOCUMENTATION = "COMMON$DOCUMENTATION",
|
||||
AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED = "AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED",
|
||||
DIFF_VIEWER$LOADING = "DIFF_VIEWER$LOADING",
|
||||
DIFF_VIEWER$GETTING_LATEST_CHANGES = "DIFF_VIEWER$GETTING_LATEST_CHANGES",
|
||||
@@ -696,16 +698,4 @@ export enum I18nKey {
|
||||
MICROAGENT_MANAGEMENT$LEARN_THIS_REPO = "MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
|
||||
MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT = "MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT",
|
||||
MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES = "MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO = "MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO",
|
||||
MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT = "MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
|
||||
MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION = "MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION",
|
||||
MICROAGENT_MANAGEMENT$WHAT_TO_DO = "MICROAGENT_MANAGEMENT$WHAT_TO_DO",
|
||||
MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO = "MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO",
|
||||
MICROAGENT_MANAGEMENT$ADD_TRIGGERS = "MICROAGENT_MANAGEMENT$ADD_TRIGGERS",
|
||||
MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS = "MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS",
|
||||
COMMON$FOR_EXAMPLE = "COMMON$FOR_EXAMPLE",
|
||||
COMMON$TEST_DB_MIGRATION = "COMMON$TEST_DB_MIGRATION",
|
||||
COMMON$RUN_TEST = "COMMON$RUN_TEST",
|
||||
COMMON$RUN_APP = "COMMON$RUN_APP",
|
||||
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
|
||||
}
|
||||
|
||||
@@ -799,37 +799,53 @@
|
||||
"de": "Konfiguration bearbeiten",
|
||||
"uk": "Редагувати налаштування"
|
||||
},
|
||||
"SETTINGS$MCP_CONFIRM_CHANGES": {
|
||||
"en": "Confirm Changes",
|
||||
"ja": "変更を確定",
|
||||
"zh-CN": "确认更改",
|
||||
"zh-TW": "確認變更",
|
||||
"ko-KR": "변경 사항 확인",
|
||||
"no": "Bekreft endringer",
|
||||
"it": "Conferma modifiche",
|
||||
"pt": "Confirmar alterações",
|
||||
"es": "Confirmar cambios",
|
||||
"ar": "تأكيد التغييرات",
|
||||
"fr": "Confirmer les modifications",
|
||||
"tr": "Değişiklikleri Onayla",
|
||||
"de": "Änderungen bestätigen",
|
||||
"uk": "Підтвердити зміни"
|
||||
"SETTINGS$MCP_CANCEL": {
|
||||
"en": "Cancel",
|
||||
"ja": "キャンセル",
|
||||
"zh-CN": "取消",
|
||||
"zh-TW": "取消",
|
||||
"ko-KR": "취소",
|
||||
"no": "Avbryt",
|
||||
"it": "Annulla",
|
||||
"pt": "Cancelar",
|
||||
"es": "Cancelar",
|
||||
"ar": "إلغاء",
|
||||
"fr": "Annuler",
|
||||
"tr": "İptal",
|
||||
"de": "Abbrechen",
|
||||
"uk": "Скасувати"
|
||||
},
|
||||
"SETTINGS$MCP_APPLY_CHANGES": {
|
||||
"en": "Apply Changes",
|
||||
"ja": "変更を適用",
|
||||
"zh-CN": "应用更改",
|
||||
"zh-TW": "應用更改",
|
||||
"ko-KR": "변경 사항 적용",
|
||||
"no": "Bruk endringer",
|
||||
"it": "Applica modifiche",
|
||||
"pt": "Aplicar alterações",
|
||||
"es": "Aplicar cambios",
|
||||
"ar": "تطبيق التغييرات",
|
||||
"fr": "Appliquer les modifications",
|
||||
"tr": "Değişiklikleri Uygula",
|
||||
"de": "Änderungen anwenden",
|
||||
"uk": "Застосувати зміни"
|
||||
},
|
||||
"SETTINGS$MCP_CONFIG_DESCRIPTION": {
|
||||
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays. For full configuration details and integration examples, see the <a>documentation</a>.",
|
||||
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。詳細な設定と統合の例については、<a>ドキュメント</a>を参照してください。",
|
||||
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。有关完整的配置详情和集成示例,请参阅<a>文档</a>。",
|
||||
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。有關完整配置詳情與整合範例,請參閱<a>文件</a>。",
|
||||
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다. 전체 구성 세부 정보와 통합 예시는 <a>문서</a>를 참조하세요.",
|
||||
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser. For detaljer om konfigurasjon og integrasjon, se <a>dokumentasjonen</a>.",
|
||||
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers. Per i dettagli completi sulla configurazione e gli esempi di integrazione, vedi la <a>documentazione</a>.",
|
||||
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers. Para detalhes completos de configuração e exemplos de integração, veja a <a>documentação</a>.",
|
||||
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers. Para ver detalles completos de configuración y ejemplos de integración, consulte la <a>documentación</a>.",
|
||||
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers. للحصول على تفاصيل التكوين الكاملة وأمثلة التكامل، راجع <a>التوثيق</a>.",
|
||||
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers. Pour plus de détails sur la configuration et des exemples d'intégration, voir la <a>documentation</a>.",
|
||||
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir. Tam yapılandırma ayrıntıları ve entegrasyon örnekleri için <a>belgeler</a>'e bakın.",
|
||||
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten. Weitere Konfigurationsdetails und Integrationsbeispiele finden Sie in der <a>Dokumentation</a>.",
|
||||
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers. Повну інформацію про конфігурацію та приклади інтеграції дивіться в <a>документації</a>."
|
||||
"en": "Edit the JSON configuration for MCP servers below. The configuration must include both sse_servers and stdio_servers arrays.",
|
||||
"ja": "以下のMCPサーバーのJSON設定を編集してください。設定にはsse_serversとstdio_serversの両方の配列を含める必要があります。",
|
||||
"zh-CN": "在下方编辑MCP服务器的JSON配置。配置必须包含sse_servers和stdio_servers数组。",
|
||||
"zh-TW": "在下方編輯MCP服務器的JSON配置。配置必須包含sse_servers和stdio_servers數組。",
|
||||
"ko-KR": "아래에서 MCP 서버의 JSON 구성을 편집하세요. 구성에는 sse_servers와 stdio_servers 배열이 모두 포함되어야 합니다.",
|
||||
"no": "Rediger JSON-konfigurasjonen for MCP-servere nedenfor. Konfigurasjonen må inkludere både sse_servers og stdio_servers-matriser.",
|
||||
"it": "Modifica la configurazione JSON per i server MCP qui sotto. La configurazione deve includere sia gli array sse_servers che stdio_servers.",
|
||||
"pt": "Edite a configuração JSON para servidores MCP abaixo. A configuração deve incluir os arrays sse_servers e stdio_servers.",
|
||||
"es": "Edite la configuración JSON para los servidores MCP a continuación. La configuración debe incluir tanto los arrays sse_servers como stdio_servers.",
|
||||
"ar": "قم بتحرير تكوين JSON لخوادم MCP أدناه. يجب أن يتضمن التكوين كلاً من مصفوفات sse_servers و stdio_servers.",
|
||||
"fr": "Modifiez la configuration JSON pour les serveurs MCP ci-dessous. La configuration doit inclure à la fois les tableaux sse_servers et stdio_servers.",
|
||||
"tr": "Aşağıdaki MCP sunucuları için JSON yapılandırmasını düzenleyin. Yapılandırma hem sse_servers hem de stdio_servers dizilerini içermelidir.",
|
||||
"de": "Bearbeiten Sie die JSON-Konfiguration für MCP-Server unten. Die Konfiguration muss sowohl sse_servers- als auch stdio_servers-Arrays enthalten.",
|
||||
"uk": "Відредагуйте JSON-конфігурацію для серверів MCP нижче. Конфігурація повинна включати масиви sse_servers та stdio_servers."
|
||||
},
|
||||
"SETTINGS$MCP_CONFIG_ERROR": {
|
||||
"en": "Error:",
|
||||
@@ -9247,6 +9263,22 @@
|
||||
"de": "oder siehe",
|
||||
"uk": "або перегляньте"
|
||||
},
|
||||
"COMMON$DOCUMENTATION": {
|
||||
"en": "documentation",
|
||||
"ja": "ドキュメント",
|
||||
"zh-CN": "文档",
|
||||
"zh-TW": "文件",
|
||||
"ko-KR": "문서",
|
||||
"no": "dokumentasjon",
|
||||
"it": "documentazione",
|
||||
"pt": "documentação",
|
||||
"es": "documentación",
|
||||
"ar": "التوثيق",
|
||||
"fr": "documentation",
|
||||
"tr": "belgelendirme",
|
||||
"de": "Dokumentation",
|
||||
"uk": "документація"
|
||||
},
|
||||
"AGENT_ERROR$ERROR_ACTION_NOT_EXECUTED": {
|
||||
"en": "The action has not been executed. This may have occurred because the user pressed the stop button, or because the runtime system crashed and restarted due to resource constraints. Any previously established system state, dependencies, or environment variables may have been lost.",
|
||||
"ja": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
|
||||
@@ -11134,197 +11166,5 @@
|
||||
"tr": "OpenHands, depolarınızı otomatik olarak öğrenebilir ve bulgularını bir mikro ajan içinde markdown olarak saklayabilir. Mikro ajan, OpenHands'in gelecekteki konuşmalarda daha hızlı ve daha doğru çalışmasına yardımcı olur.",
|
||||
"de": "OpenHands kann automatisch Informationen über Ihre Repositories sammeln und die Ergebnisse als Markdown in einem Microagent speichern. Der Microagent hilft OpenHands, in zukünftigen Gesprächen schneller und genauer zu arbeiten.",
|
||||
"uk": "OpenHands може автоматично вивчати ваші репозиторії та зберігати свої висновки у вигляді markdown у мікроагенті. Мікроагент допоможе OpenHands працювати швидше та точніше у майбутніх розмовах."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO": {
|
||||
"en": "Add a Microagent to",
|
||||
"ja": "にマイクロエージェントを追加",
|
||||
"zh-CN": "添加微代理到",
|
||||
"zh-TW": "新增微代理到",
|
||||
"ko-KR": "에 마이크로에이전트 추가",
|
||||
"no": "Legg til en mikroagent i",
|
||||
"it": "Aggiungi un microagent a",
|
||||
"pt": "Adicionar um microagente a",
|
||||
"es": "Agregar un microagente a",
|
||||
"ar": "إضافة وكيل صغير إلى",
|
||||
"fr": "Ajouter un microagent à",
|
||||
"tr": "Bir mikro ajan ekle",
|
||||
"de": "Microagent hinzufügen zu",
|
||||
"uk": "Додати мікроагента до"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT": {
|
||||
"en": "Add a Microagent",
|
||||
"ja": "マイクロエージェントを追加",
|
||||
"zh-CN": "添加微代理",
|
||||
"zh-TW": "新增微代理",
|
||||
"ko-KR": "마이크로에이전트 추가",
|
||||
"no": "Legg til en mikroagent",
|
||||
"it": "Aggiungi un microagent",
|
||||
"pt": "Adicionar um microagente",
|
||||
"es": "Agregar un microagente",
|
||||
"ar": "إضافة وكيل صغير",
|
||||
"fr": "Ajouter un microagent",
|
||||
"tr": "Bir mikro ajan ekle",
|
||||
"de": "Microagent hinzufügen",
|
||||
"uk": "Додати мікроагента"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$ADD_MICROAGENT_MODAL_DESCRIPTION": {
|
||||
"en": "OpenHands will create a new microagent based on your instructions.",
|
||||
"ja": "OpenHandsはあなたの指示に基づいて新しいマイクロエージェントを作成します。",
|
||||
"zh-CN": "OpenHands 将根据您的指示创建一个新的微代理。",
|
||||
"zh-TW": "OpenHands 將根據您的指示建立新的微代理。",
|
||||
"ko-KR": "OpenHands가 귀하의 지시에 따라 새로운 마이크로에이전트를 생성합니다.",
|
||||
"no": "OpenHands vil opprette en ny mikroagent basert på dine instruksjoner.",
|
||||
"it": "OpenHands creerà un nuovo microagent in base alle tue istruzioni.",
|
||||
"pt": "O OpenHands criará um novo microagente com base nas suas instruções.",
|
||||
"es": "OpenHands creará un nuevo microagente según tus instrucciones.",
|
||||
"ar": "سيقوم OpenHands بإنشاء وكيل صغير جديد بناءً على تعليماتك.",
|
||||
"fr": "OpenHands créera un nouveau microagent selon vos instructions.",
|
||||
"tr": "OpenHands, talimatlarınıza göre yeni bir mikro ajan oluşturacaktır.",
|
||||
"de": "OpenHands erstellt einen neuen Microagenten basierend auf Ihren Anweisungen.",
|
||||
"uk": "OpenHands створить нового мікроагента відповідно до ваших інструкцій."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$WHAT_TO_DO": {
|
||||
"en": "What would you like the Microagent to do?",
|
||||
"ja": "マイクロエージェントに何をさせたいですか?",
|
||||
"zh-CN": "您希望微代理做什么?",
|
||||
"zh-TW": "您希望微代理做什麼?",
|
||||
"ko-KR": "마이크로에이전트가 무엇을 하길 원하시나요?",
|
||||
"no": "Hva vil du at mikroagenten skal gjøre?",
|
||||
"it": "Cosa vuoi che faccia il microagent?",
|
||||
"pt": "O que você gostaria que o microagente fizesse?",
|
||||
"es": "¿Qué te gustaría que hiciera el microagente?",
|
||||
"ar": "ماذا تريد أن يفعل الوكيل الصغير؟",
|
||||
"fr": "Que souhaitez-vous que le microagent fasse ?",
|
||||
"tr": "Mikro ajanın ne yapmasını istersiniz?",
|
||||
"de": "Was soll der Microagent tun?",
|
||||
"uk": "Що ви хочете, щоб зробив мікроагент?"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
|
||||
"en": "Describe what you would like the Microagent to do.",
|
||||
"ja": "マイクロエージェントにしてほしいことを説明してください。",
|
||||
"zh-CN": "请描述您希望微代理执行的操作。",
|
||||
"zh-TW": "請描述您希望微代理執行的操作。",
|
||||
"ko-KR": "마이크로에이전트가 수행하길 원하는 작업을 설명하세요.",
|
||||
"no": "Beskriv hva du vil at mikroagenten skal gjøre.",
|
||||
"it": "Descrivi cosa vorresti che facesse il microagent.",
|
||||
"pt": "Descreva o que você gostaria que o microagente fizesse.",
|
||||
"es": "Describe lo que te gustaría que hiciera el microagente.",
|
||||
"ar": "صف ما ترغب أن يفعله الوكيل الصغير.",
|
||||
"fr": "Décrivez ce que vous souhaitez que le microagent fasse.",
|
||||
"tr": "Mikro ajanın ne yapmasını istediğinizi açıklayın.",
|
||||
"de": "Beschreiben Sie, was der Microagent tun soll.",
|
||||
"uk": "Опишіть, що ви хочете, щоб зробив мікроагент."
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$ADD_TRIGGERS": {
|
||||
"en": "Define triggers for the Microagent",
|
||||
"ja": "マイクロエージェントのトリガーを定義する",
|
||||
"zh-CN": "为微代理定义触发器",
|
||||
"zh-TW": "為微代理定義觸發條件",
|
||||
"ko-KR": "마이크로에이전트의 트리거 정의",
|
||||
"no": "Definer utløsere for mikroagenten",
|
||||
"it": "Definisci i trigger per il microagent",
|
||||
"pt": "Defina gatilhos para o microagente",
|
||||
"es": "Define desencadenantes para el microagente",
|
||||
"ar": "حدد المشغلات للوكيل الصغير",
|
||||
"fr": "Définir des déclencheurs pour le microagent",
|
||||
"tr": "Mikro ajan için tetikleyiciler tanımlayın",
|
||||
"de": "Definieren Sie Auslöser für den Microagenten",
|
||||
"uk": "Визначте тригери для мікроагента"
|
||||
},
|
||||
"MICROAGENT_MANAGEMENT$HELP_TEXT_DESCRIBING_VALID_TRIGGERS": {
|
||||
"en": "Help text describing valid triggers.",
|
||||
"ja": "有効なトリガーについて説明するヘルプテキスト。",
|
||||
"zh-CN": "描述有效触发器的帮助文本。",
|
||||
"zh-TW": "描述有效觸發條件的說明文字。",
|
||||
"ko-KR": "유효한 트리거를 설명하는 도움말 텍스트입니다.",
|
||||
"no": "Hjelpetekst som beskriver gyldige utløsere.",
|
||||
"it": "Testo di aiuto che descrive i trigger validi.",
|
||||
"pt": "Texto de ajuda descrevendo gatilhos válidos.",
|
||||
"es": "Texto de ayuda que describe desencadenantes válidos.",
|
||||
"ar": "نص المساعدة الذي يصف المشغلات الصالحة.",
|
||||
"fr": "Texte d'aide décrivant les déclencheurs valides.",
|
||||
"tr": "Geçerli tetikleyicileri açıklayan yardım metni.",
|
||||
"de": "Hilfetext, der gültige Auslöser beschreibt.",
|
||||
"uk": "Текст довідки, що описує дійсні тригери."
|
||||
},
|
||||
"COMMON$FOR_EXAMPLE": {
|
||||
"en": "For example",
|
||||
"ja": "例えば",
|
||||
"zh-CN": "例如",
|
||||
"zh-TW": "例如",
|
||||
"ko-KR": "예를 들어",
|
||||
"no": "For eksempel",
|
||||
"it": "Ad esempio",
|
||||
"pt": "Por exemplo",
|
||||
"es": "Por ejemplo",
|
||||
"ar": "على سبيل المثال",
|
||||
"fr": "Par exemple",
|
||||
"tr": "Örneğin",
|
||||
"de": "Zum Beispiel",
|
||||
"uk": "Наприклад:"
|
||||
},
|
||||
"COMMON$TEST_DB_MIGRATION": {
|
||||
"en": "Test DB Migration",
|
||||
"ja": "DBマイグレーションのテスト",
|
||||
"zh-CN": "测试数据库迁移",
|
||||
"zh-TW": "測試資料庫遷移",
|
||||
"ko-KR": "DB 마이그레이션 테스트",
|
||||
"no": "Test DB-migrering",
|
||||
"it": "Test migrazione DB",
|
||||
"pt": "Testar migração do banco de dados",
|
||||
"es": "Probar migración de base de datos",
|
||||
"ar": "اختبار ترحيل قاعدة البيانات",
|
||||
"fr": "Tester la migration de la base de données",
|
||||
"tr": "Veritabanı geçişini test et",
|
||||
"de": "Datenbankmigration testen",
|
||||
"uk": "Тестування міграції БД"
|
||||
},
|
||||
"COMMON$RUN_TEST": {
|
||||
"en": "Run Test",
|
||||
"ja": "テストを実行",
|
||||
"zh-CN": "运行测试",
|
||||
"zh-TW": "執行測試",
|
||||
"ko-KR": "테스트 실행",
|
||||
"no": "Kjør test",
|
||||
"it": "Esegui test",
|
||||
"pt": "Executar teste",
|
||||
"es": "Ejecutar prueba",
|
||||
"ar": "تشغيل الاختبار",
|
||||
"fr": "Exécuter le test",
|
||||
"tr": "Testi çalıştır",
|
||||
"de": "Test ausführen",
|
||||
"uk": "Запустити тест"
|
||||
},
|
||||
"COMMON$RUN_APP": {
|
||||
"en": "Run App",
|
||||
"ja": "アプリを実行",
|
||||
"zh-CN": "运行应用",
|
||||
"zh-TW": "執行應用程式",
|
||||
"ko-KR": "앱 실행",
|
||||
"no": "Kjør app",
|
||||
"it": "Esegui app",
|
||||
"pt": "Executar aplicativo",
|
||||
"es": "Ejecutar aplicación",
|
||||
"ar": "تشغيل التطبيق",
|
||||
"fr": "Exécuter l'application",
|
||||
"tr": "Uygulamayı çalıştır",
|
||||
"de": "App ausführen",
|
||||
"uk": "Запустити додаток"
|
||||
},
|
||||
"COMMON$LEARN_FILE_STRUCTURE": {
|
||||
"en": "Learn File Structure",
|
||||
"ja": "ファイル構造を学ぶ",
|
||||
"zh-CN": "学习文件结构",
|
||||
"zh-TW": "學習檔案結構",
|
||||
"ko-KR": "파일 구조 학습",
|
||||
"no": "Lær filstruktur",
|
||||
"it": "Impara la struttura dei file",
|
||||
"pt": "Aprender estrutura de arquivos",
|
||||
"es": "Aprender la estructura de archivos",
|
||||
"ar": "تعلم بنية الملفات",
|
||||
"fr": "Apprendre la structure des fichiers",
|
||||
"tr": "Dosya yapısını öğren",
|
||||
"de": "Dateistruktur lernen",
|
||||
"uk": "Вивчити структуру файлів"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 12.8683L5.53801 19.5225L4.46191 18.4775L10.9545 11.7917L4.86647 5.52251L5.94257 4.47751L12 10.7152L18.0574 4.47751L19.1334 5.52251L13.0454 11.7917L19.538 18.4775L18.4619 19.5225L12 12.8683Z" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 369 B |
@@ -1,14 +1,10 @@
|
||||
import { redirect } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { MicroagentManagementSidebar } from "#/components/features/microagent-management/microagent-management-sidebar";
|
||||
import { Route } from "./+types/settings";
|
||||
import { queryClient } from "#/query-client-config";
|
||||
import { GetConfigResponse } from "#/api/open-hands.types";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { MicroagentManagementMain } from "#/components/features/microagent-management/microagent-management-main";
|
||||
import { MicroagentManagementAddMicroagentModal } from "#/components/features/microagent-management/microagent-management-add-microagent-modal";
|
||||
import { RootState } from "#/store";
|
||||
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
|
||||
|
||||
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -31,30 +27,10 @@ export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
|
||||
};
|
||||
|
||||
function MicroagentManagement() {
|
||||
const { addMicroagentModalVisible } = useSelector(
|
||||
(state: RootState) => state.microagentManagement,
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const hideAddMicroagentModal = () => {
|
||||
dispatch(setAddMicroagentModalVisible(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
|
||||
<MicroagentManagementSidebar />
|
||||
<MicroagentManagementMain />
|
||||
{addMicroagentModalVisible && (
|
||||
<MicroagentManagementAddMicroagentModal
|
||||
onConfirm={() => {
|
||||
hideAddMicroagentModal();
|
||||
}}
|
||||
onCancel={() => {
|
||||
hideAddMicroagentModal();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ function SettingsScreen() {
|
||||
// this is used to determine which settings are available in the UI
|
||||
const navItems = isSaas ? SAAS_NAV_ITEMS : OSS_NAV_ITEMS;
|
||||
|
||||
// THIS IS A TEST
|
||||
|
||||
return (
|
||||
<main
|
||||
data-testid="settings-screen"
|
||||
|
||||
@@ -4,26 +4,14 @@ export const microagentManagementSlice = createSlice({
|
||||
name: "microagentManagement",
|
||||
initialState: {
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null,
|
||||
},
|
||||
reducers: {
|
||||
setSelectedMicroagent: (state, action) => {
|
||||
state.selectedMicroagent = action.payload;
|
||||
},
|
||||
setAddMicroagentModalVisible: (state, action) => {
|
||||
state.addMicroagentModalVisible = action.payload;
|
||||
},
|
||||
setSelectedRepository: (state, action) => {
|
||||
state.selectedRepository = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
setSelectedMicroagent,
|
||||
setAddMicroagentModalVisible,
|
||||
setSelectedRepository,
|
||||
} = microagentManagementSlice.actions;
|
||||
export const { setSelectedMicroagent } = microagentManagementSlice.actions;
|
||||
|
||||
export default microagentManagementSlice.reducer;
|
||||
|
||||
@@ -27,7 +27,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -21,7 +21,6 @@ Your primary role is to assist users by executing commands, modifying code, and
|
||||
* When implementing solutions, focus on making the minimal changes needed to solve the problem.
|
||||
* Before implementing any changes, first thoroughly understand the codebase through exploration.
|
||||
* If you are adding a lot of code to a function or file, consider splitting the function or file into smaller pieces when appropriate.
|
||||
* Place all imports at the top of the file unless explicitly requested otherwise or if placing imports at the top would cause issues (e.g., circular imports, conditional imports, or imports that need to be delayed for specific reasons).
|
||||
</CODE_QUALITY>
|
||||
|
||||
<VERSION_CONTROL>
|
||||
|
||||
@@ -8,6 +8,8 @@ from openhands.events.action import (
|
||||
Action,
|
||||
AgentFinishAction,
|
||||
AgentRejectAction,
|
||||
BrowseInteractiveAction,
|
||||
BrowseURLAction,
|
||||
CmdRunAction,
|
||||
FileReadAction,
|
||||
FileWriteAction,
|
||||
@@ -15,6 +17,7 @@ from openhands.events.action import (
|
||||
)
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
BrowserOutputObservation,
|
||||
CmdOutputMetadata,
|
||||
CmdOutputObservation,
|
||||
FileReadObservation,
|
||||
@@ -51,19 +54,17 @@ class DummyAgent(Agent):
|
||||
},
|
||||
{
|
||||
'action': CmdRunAction(command='echo "foo"'),
|
||||
'observations': [
|
||||
CmdOutputObservation(
|
||||
'foo',
|
||||
command='echo "foo"',
|
||||
metadata=CmdOutputMetadata(exit_code=0),
|
||||
)
|
||||
],
|
||||
'observations': [CmdOutputObservation('foo', command='echo "foo"')],
|
||||
},
|
||||
{
|
||||
'action': FileWriteAction(
|
||||
content='echo "Hello, World!"', path='hello.sh'
|
||||
),
|
||||
'observations': [FileWriteObservation(content='', path='hello.sh')],
|
||||
'observations': [
|
||||
FileWriteObservation(
|
||||
content='echo "Hello, World!"', path='hello.sh'
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
'action': FileReadAction(path='hello.sh'),
|
||||
@@ -75,12 +76,36 @@ class DummyAgent(Agent):
|
||||
'action': CmdRunAction(command='bash hello.sh'),
|
||||
'observations': [
|
||||
CmdOutputObservation(
|
||||
'Hello, World!',
|
||||
command='bash hello.sh',
|
||||
metadata=CmdOutputMetadata(exit_code=0),
|
||||
'bash: hello.sh: No such file or directory',
|
||||
command='bash workspace/hello.sh',
|
||||
metadata=CmdOutputMetadata(exit_code=127),
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
'action': BrowseURLAction(url='https://google.com'),
|
||||
'observations': [
|
||||
BrowserOutputObservation(
|
||||
'<html><body>Simulated Google page</body></html>',
|
||||
url='https://google.com',
|
||||
screenshot='',
|
||||
trigger_by_action='',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
'action': BrowseInteractiveAction(
|
||||
browser_actions='goto("https://google.com")'
|
||||
),
|
||||
'observations': [
|
||||
BrowserOutputObservation(
|
||||
'<html><body>Simulated Google page after interaction</body></html>',
|
||||
url='https://google.com',
|
||||
screenshot='',
|
||||
trigger_by_action='',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
'action': AgentRejectAction(),
|
||||
'observations': [AgentStateChangedObservation('', AgentState.REJECTED)],
|
||||
@@ -122,47 +147,6 @@ class DummyAgent(Agent):
|
||||
obs.pop('timestamp', None)
|
||||
obs.pop('cause', None)
|
||||
obs.pop('source', None)
|
||||
# Remove dynamic metadata fields that vary between runs
|
||||
if 'extras' in obs and 'metadata' in obs['extras']:
|
||||
metadata = obs['extras']['metadata']
|
||||
if isinstance(metadata, dict):
|
||||
metadata.pop('pid', None)
|
||||
metadata.pop('username', None)
|
||||
metadata.pop('hostname', None)
|
||||
metadata.pop('working_dir', None)
|
||||
metadata.pop('py_interpreter_path', None)
|
||||
metadata.pop('suffix', None)
|
||||
# Normalize file paths for comparison - extract just the filename
|
||||
if 'extras' in obs and 'path' in obs['extras']:
|
||||
path = obs['extras']['path']
|
||||
if isinstance(path, str):
|
||||
# Extract just the filename from the path
|
||||
import os
|
||||
|
||||
obs['extras']['path'] = os.path.basename(path)
|
||||
# Normalize message field to handle path differences
|
||||
if 'message' in obs:
|
||||
import os
|
||||
|
||||
message = obs['message']
|
||||
if isinstance(message, str):
|
||||
# Replace full paths with just filenames in messages
|
||||
if 'I wrote to the file ' in message:
|
||||
parts = message.split('I wrote to the file ')
|
||||
if len(parts) == 2:
|
||||
filename = os.path.basename(
|
||||
parts[1].rstrip('.')
|
||||
)
|
||||
obs['message'] = (
|
||||
f'I wrote to the file {filename}.'
|
||||
)
|
||||
elif 'I read the file ' in message:
|
||||
parts = message.split('I read the file ')
|
||||
if len(parts) == 2:
|
||||
filename = os.path.basename(
|
||||
parts[1].rstrip('.')
|
||||
)
|
||||
obs['message'] = f'I read the file {filename}.'
|
||||
|
||||
if hist_obs != expected_obs:
|
||||
print(
|
||||
|
||||
+2
-16
@@ -231,26 +231,12 @@ async def run_session(
|
||||
return
|
||||
|
||||
confirmation_status = await read_confirmation_input(config)
|
||||
if confirmation_status in ('yes', 'always'):
|
||||
if confirmation_status == 'yes' or confirmation_status == 'always':
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
|
||||
EventSource.USER,
|
||||
)
|
||||
elif confirmation_status == 'edit':
|
||||
# Tell the agent the proposed action was rejected
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
)
|
||||
# Notify the user
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<skyblue>Okay, please tell me what I should do instead.</skyblue>'
|
||||
)
|
||||
)
|
||||
# Solicit replacement isntructions
|
||||
await prompt_for_next_task(AgentState.AWAITING_USER_INPUT)
|
||||
else: # 'no' or fallback
|
||||
else:
|
||||
event_stream.add_event(
|
||||
ChangeAgentStateAction(AgentState.USER_REJECTED),
|
||||
EventSource.USER,
|
||||
|
||||
+25
-11
@@ -589,20 +589,34 @@ async def read_prompt_input(
|
||||
|
||||
async def read_confirmation_input(config: OpenHandsConfig) -> str:
|
||||
try:
|
||||
choices = [
|
||||
'Yes, proceed',
|
||||
'No, skip this action',
|
||||
"Always proceed (don't ask again)",
|
||||
'Let me provide different instructions',
|
||||
]
|
||||
prompt_session = create_prompt_session(config)
|
||||
|
||||
# keep the outer coroutine responsive by using asyncio.to_thread which puts the blocking call app.run() of cli_confirm() in a separate thread
|
||||
index = await asyncio.to_thread(
|
||||
cli_confirm, config, 'Choose an option:', choices
|
||||
)
|
||||
while True:
|
||||
with patch_stdout():
|
||||
print_formatted_text('')
|
||||
confirmation: str = await prompt_session.prompt_async(
|
||||
HTML('<gold>Proceed with action? (y)es/(n)o/(a)lways > </gold>'),
|
||||
)
|
||||
|
||||
return {0: 'yes', 1: 'no', 2: 'always', 3: 'edit'}.get(index, 'no')
|
||||
confirmation = (
|
||||
'' if confirmation is None else confirmation.strip().lower()
|
||||
)
|
||||
|
||||
if confirmation in ['y', 'yes']:
|
||||
return 'yes'
|
||||
elif confirmation in ['n', 'no']:
|
||||
return 'no'
|
||||
elif confirmation in ['a', 'always']:
|
||||
return 'always'
|
||||
else:
|
||||
# Display error message for invalid input
|
||||
print_formatted_text('')
|
||||
print_formatted_text(
|
||||
HTML(
|
||||
'<ansired>Invalid input. Please enter (y)es, (n)o, or (a)lways.</ansired>'
|
||||
)
|
||||
)
|
||||
# Continue the loop to re-prompt
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
return 'no'
|
||||
|
||||
|
||||
@@ -219,26 +219,10 @@ class EventStream(EventStore):
|
||||
def update_secrets(self, secrets: dict[str, str]) -> None:
|
||||
self.secrets.update(secrets)
|
||||
|
||||
def _replace_secrets(
|
||||
self, data: dict[str, Any], is_top_level: bool = True
|
||||
) -> dict[str, Any]:
|
||||
# Fields that should not have secrets replaced (only at top level - system metadata)
|
||||
TOP_LEVEL_PROTECTED_FIELDS = {
|
||||
'timestamp',
|
||||
'id',
|
||||
'source',
|
||||
'cause',
|
||||
'action',
|
||||
'observation',
|
||||
'message',
|
||||
}
|
||||
|
||||
def _replace_secrets(self, data: dict[str, Any]) -> dict[str, Any]:
|
||||
for key in data:
|
||||
if is_top_level and key in TOP_LEVEL_PROTECTED_FIELDS:
|
||||
# Skip secret replacement for protected system fields at top level only
|
||||
continue
|
||||
elif isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key], is_top_level=False)
|
||||
if isinstance(data[key], dict):
|
||||
data[key] = self._replace_secrets(data[key])
|
||||
elif isinstance(data[key], str):
|
||||
for secret in self.secrets.values():
|
||||
data[key] = data[key].replace(secret, '<secret_hidden>')
|
||||
|
||||
@@ -9,7 +9,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -21,7 +20,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
class BitBucketService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for Bitbucket integration.
|
||||
|
||||
This is an extension point in OpenHands that allows applications to customize Bitbucket
|
||||
@@ -186,89 +185,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
return all_items[:max_items] # Trim to max_items if needed
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
workspaces_url = f'{self.BASE_URL}/workspaces'
|
||||
workspaces = await self._fetch_paginated_data(workspaces_url, {}, 100)
|
||||
|
||||
installations: list[str] = []
|
||||
for workspace in workspaces:
|
||||
installations.append(workspace['slug'])
|
||||
|
||||
return installations
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
"""Get paginated repositories for a specific workspace.
|
||||
|
||||
Args:
|
||||
page: The page number to fetch
|
||||
per_page: The number of repositories per page
|
||||
sort: The sort field ('pushed', 'updated', 'created', 'full_name')
|
||||
installation_id: The workspace slug to fetch repositories from (as int, will be converted to string)
|
||||
|
||||
Returns:
|
||||
A list of Repository objects
|
||||
"""
|
||||
if not installation_id:
|
||||
return []
|
||||
|
||||
# Convert installation_id to string for use as workspace_slug
|
||||
workspace_slug = installation_id
|
||||
workspace_repos_url = f'{self.BASE_URL}/repositories/{workspace_slug}'
|
||||
|
||||
# Map sort parameter to Bitbucket API compatible values
|
||||
bitbucket_sort = sort
|
||||
if sort == 'pushed':
|
||||
# Bitbucket doesn't support 'pushed', use 'updated_on' instead
|
||||
bitbucket_sort = '-updated_on' # Use negative prefix for descending order
|
||||
elif sort == 'updated':
|
||||
bitbucket_sort = '-updated_on'
|
||||
elif sort == 'created':
|
||||
bitbucket_sort = '-created_on'
|
||||
elif sort == 'full_name':
|
||||
bitbucket_sort = 'name' # Bitbucket uses 'name' not 'full_name'
|
||||
else:
|
||||
# Default to most recently updated first
|
||||
bitbucket_sort = '-updated_on'
|
||||
|
||||
params = {
|
||||
'pagelen': per_page,
|
||||
'page': page,
|
||||
'sort': bitbucket_sort,
|
||||
}
|
||||
|
||||
response, headers = await self._make_request(workspace_repos_url, params)
|
||||
|
||||
# Extract repositories from the response
|
||||
repos = response.get('values', [])
|
||||
|
||||
# Extract link header for pagination
|
||||
next_link = response.get('next', '')
|
||||
|
||||
repositories = [
|
||||
Repository(
|
||||
id=repo.get('uuid', ''),
|
||||
full_name=f'{repo.get("workspace", {}).get("slug", "")}/{repo.get("slug", "")}',
|
||||
git_provider=ProviderType.BITBUCKET,
|
||||
is_public=repo.get('is_private', True) is False,
|
||||
stargazers_count=None, # Bitbucket doesn't have stars
|
||||
pushed_at=repo.get('updated_on'),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('workspace', {}).get('is_private') is False
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in repos
|
||||
]
|
||||
|
||||
return repositories
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user using workspaces endpoint.
|
||||
|
||||
This method gets all repositories (both public and private) that the user has access to
|
||||
|
||||
@@ -15,7 +15,6 @@ from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
ProviderType,
|
||||
Repository,
|
||||
@@ -29,7 +28,7 @@ from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
"""Default implementation of GitService for GitHub integration.
|
||||
|
||||
TODO: This doesn't seem a good candidate for the get_impl() pattern. What are the abstract methods we should actually separate and implement here?
|
||||
@@ -193,47 +192,14 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
):
|
||||
params = {'page': str(page), 'per_page': str(per_page)}
|
||||
if installation_id:
|
||||
url = f'{self.BASE_URL}/user/installations/{installation_id}/repositories'
|
||||
response, headers = await self._make_request(url, params)
|
||||
response = response.get('repositories', [])
|
||||
else:
|
||||
url = f'{self.BASE_URL}/user/repos'
|
||||
params['sort'] = sort
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
return [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('full_name'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('stargazers_count'),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=not repo.get('private', True),
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('owner', {}).get('type') == 'Organization'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitHub API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
if app_mode == AppMode.SAAS:
|
||||
# Get all installation IDs and fetch repos for each one
|
||||
installation_ids = await self.get_installations()
|
||||
installation_ids = await self.get_installation_ids()
|
||||
|
||||
# Iterate through each installation ID
|
||||
for installation_id in installation_ids:
|
||||
@@ -280,11 +246,11 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
for repo in all_repos
|
||||
]
|
||||
|
||||
async def get_installations(self) -> list[str]:
|
||||
async def get_installation_ids(self) -> list[int]:
|
||||
url = f'{self.BASE_URL}/user/installations'
|
||||
response, _ = await self._make_request(url)
|
||||
installations = response.get('installations', [])
|
||||
return [str(i['id']) for i in installations]
|
||||
return [i['id'] for i in installations]
|
||||
|
||||
async def search_repositories(
|
||||
self, query: str, per_page: int, sort: str, order: str
|
||||
|
||||
@@ -226,49 +226,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return repos
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
url = f'{self.BASE_URL}/projects'
|
||||
order_by = {
|
||||
'pushed': 'last_activity_at',
|
||||
'updated': 'last_activity_at',
|
||||
'created': 'created_at',
|
||||
'full_name': 'name',
|
||||
}.get(sort, 'last_activity_at')
|
||||
|
||||
params = {
|
||||
'page': str(page),
|
||||
'per_page': str(per_page),
|
||||
'order_by': order_by,
|
||||
'sort': 'desc', # GitLab uses sort for direction (asc/desc)
|
||||
'owned': True, # Boolean value without quotes
|
||||
'membership': True, # Include projects user is a member of
|
||||
}
|
||||
response, headers = await self._make_request(url, params)
|
||||
|
||||
next_link: str = headers.get('Link', '')
|
||||
repos = [
|
||||
Repository(
|
||||
id=str(repo.get('id')), # type: ignore[arg-type]
|
||||
full_name=repo.get('path_with_namespace'), # type: ignore[arg-type]
|
||||
stargazers_count=repo.get('star_count'),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
is_public=repo.get('visibility') == 'public',
|
||||
owner_type=(
|
||||
OwnerType.ORGANIZATION
|
||||
if repo.get('namespace', {}).get('kind') == 'group'
|
||||
else OwnerType.USER
|
||||
),
|
||||
link_header=next_link,
|
||||
)
|
||||
for repo in response
|
||||
]
|
||||
return repos
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
PER_PAGE = 100 # Maximum allowed by GitLab API
|
||||
all_repos: list[dict] = []
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Any, Coroutine, Literal, cast, overload
|
||||
from typing import Annotated, Any, Coroutine, Literal, overload
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
@@ -22,7 +22,6 @@ from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
@@ -161,61 +160,16 @@ class ProviderHandler:
|
||||
service = self._get_service(provider)
|
||||
return await service.get_latest_token()
|
||||
|
||||
async def get_github_installations(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get github installations {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_bitbucket_workspaces(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.BITBUCKET))
|
||||
try:
|
||||
return await service.get_installations()
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to get bitbucket workspaces {e}')
|
||||
|
||||
return []
|
||||
|
||||
async def get_repositories(
|
||||
self,
|
||||
sort: str,
|
||||
app_mode: AppMode,
|
||||
selected_provider: ProviderType | None,
|
||||
page: int | None,
|
||||
per_page: int | None,
|
||||
installation_id: str | None,
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
"""
|
||||
Get repositories from providers
|
||||
"""
|
||||
|
||||
if selected_provider:
|
||||
if not page or not per_page:
|
||||
logger.error('Failed to provider params for paginating repos')
|
||||
return []
|
||||
|
||||
service = self._get_service(selected_provider)
|
||||
try:
|
||||
return await service.get_paginated_repos(
|
||||
page, per_page, sort, installation_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {selected_provider}: {e}')
|
||||
|
||||
return []
|
||||
|
||||
all_repos: list[Repository] = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self._get_service(provider)
|
||||
service_repos = await service.get_all_repositories(sort, app_mode)
|
||||
service_repos = await service.get_repositories(sort, app_mode)
|
||||
all_repos.extend(service_repos)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching repos from {provider}: {e}')
|
||||
|
||||
@@ -22,7 +22,6 @@ class TaskType(str, Enum):
|
||||
UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS'
|
||||
OPEN_ISSUE = 'OPEN_ISSUE'
|
||||
OPEN_PR = 'OPEN_PR'
|
||||
CREATE_MICROAGENT = 'CREATE_MICROAGENT'
|
||||
|
||||
|
||||
class OwnerType(str, Enum):
|
||||
@@ -99,12 +98,6 @@ class SuggestedTask(BaseModel):
|
||||
return template.render(issue_number=issue_number, repo=repo, **terms)
|
||||
|
||||
|
||||
class CreateMicroagent(BaseModel):
|
||||
repo: str
|
||||
git_provider: ProviderType | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: str
|
||||
login: str
|
||||
@@ -200,12 +193,6 @@ class BaseGitService(ABC):
|
||||
return UnknownException(f'HTTP error {type(e).__name__} : {e}')
|
||||
|
||||
|
||||
class InstallationsService(Protocol):
|
||||
async def get_installations(self) -> list[str]:
|
||||
"""Get installations for the service; repos live underneath these installations"""
|
||||
...
|
||||
|
||||
|
||||
class GitService(Protocol):
|
||||
"""Protocol defining the interface for Git service providers"""
|
||||
|
||||
@@ -239,18 +226,10 @@ class GitService(Protocol):
|
||||
"""Search for repositories"""
|
||||
...
|
||||
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode
|
||||
) -> list[Repository]:
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_paginated_repos(
|
||||
self, page: int, per_page: int, sort: str, installation_id: str | None
|
||||
) -> list[Repository]:
|
||||
"""Get a page of repositories for the authenticated user"""
|
||||
...
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories"""
|
||||
...
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context
|
||||
1. Address the comment. Use the GitHub API to read issue title, body, and comments if you need more context
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the $GITHUB_TOKEN and Github API
|
||||
1. Read the issue body and comments using the Github API
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the PR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the PR Context
|
||||
Use the $GITHUB_TOKEN and GitHub API to:
|
||||
Use the GitHub API to:
|
||||
1. Retrieve the diff against main to understand the changes
|
||||
2. Fetch the PR body and the linked issue for context
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the $GITLAB_TOKEN and GitLab API to read issue title, body, and comments if you need more context
|
||||
1. Address the comment. Use the GitLab API to read issue title, body, and comments if you need more context
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the $GITLAB_TOKEN and GitLab API
|
||||
1. Read the issue body and comments using the GitLab API
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the MR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the MR Context
|
||||
Use the $GITLAB_TOKEN and GitLab API to:
|
||||
Use the GitLab API to:
|
||||
1. Retrieve the diff against main to understand the changes
|
||||
2. Fetch the MR body and the linked issue for context
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
|
||||
'gemini-2.5-pro',
|
||||
'gpt-4.1',
|
||||
'kimi-k2-0711-preview',
|
||||
'kimi-k2-instruct',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_SUPPORTED_MODELS = [
|
||||
@@ -811,8 +810,6 @@ class LLM(RetryMixin, DebugMixin):
|
||||
message.function_calling_enabled = self.is_function_calling_active()
|
||||
if 'deepseek' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
if 'kimi-k2-instruct' in self.config.model and 'groq' in self.config.model:
|
||||
message.force_string_serializer = True
|
||||
|
||||
# let pydantic handle the serialization
|
||||
return [message.model_dump() for message in messages]
|
||||
|
||||
+8
-20
@@ -5,8 +5,6 @@ if TYPE_CHECKING:
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
|
||||
from mcp import McpError
|
||||
|
||||
from openhands.core.config.mcp_config import (
|
||||
MCPConfig,
|
||||
MCPSHTTPServerConfig,
|
||||
@@ -179,25 +177,15 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse
|
||||
|
||||
logger.debug(f'Matching client: {matching_client}')
|
||||
|
||||
try:
|
||||
# Call the tool - this will create a new connection internally
|
||||
response = await matching_client.call_tool(action.name, action.arguments)
|
||||
logger.debug(f'MCP response: {response}')
|
||||
# Call the tool - this will create a new connection internally
|
||||
response = await matching_client.call_tool(action.name, action.arguments)
|
||||
logger.debug(f'MCP response: {response}')
|
||||
|
||||
return MCPObservation(
|
||||
content=json.dumps(response.model_dump(mode='json')),
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
except McpError as e:
|
||||
# Handle MCP errors by returning an error observation instead of raising
|
||||
logger.error(f'MCP error when calling tool {action.name}: {e}')
|
||||
error_content = json.dumps({'isError': True, 'error': str(e), 'content': []})
|
||||
return MCPObservation(
|
||||
content=error_content,
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
return MCPObservation(
|
||||
content=json.dumps(response.model_dump(mode='json')),
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
|
||||
|
||||
async def add_mcp_tools_to_agent(agent: 'Agent', runtime: Runtime, memory: 'Memory'):
|
||||
|
||||
@@ -635,8 +635,7 @@ def _create_server(
|
||||
server_port=execution_server_port,
|
||||
plugins=plugins,
|
||||
app_config=config,
|
||||
python_prefix=[],
|
||||
python_executable=sys.executable,
|
||||
python_prefix=['poetry', 'run'],
|
||||
override_user_id=user_id,
|
||||
override_username=username,
|
||||
)
|
||||
|
||||
@@ -38,55 +38,9 @@ from openhands.server.user_auth import (
|
||||
app = APIRouter(prefix='/api/user', dependencies=get_dependencies())
|
||||
|
||||
|
||||
@app.get('/github/installations', response_model=list[str])
|
||||
async def get_user_github_installations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
return await client.get_github_installations()
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/bitbucket/installations', response_model=list[str])
|
||||
async def get_user_bitbucket_installations(
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
):
|
||||
if provider_tokens:
|
||||
client = ProviderHandler(
|
||||
provider_tokens=provider_tokens,
|
||||
external_auth_token=access_token,
|
||||
external_auth_id=user_id,
|
||||
)
|
||||
|
||||
return await client.get_github_installations()
|
||||
|
||||
return JSONResponse(
|
||||
content='Git provider token required. (such as GitHub).',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
@app.get('/repositories', response_model=list[Repository])
|
||||
async def get_user_repositories(
|
||||
sort: str = 'pushed',
|
||||
selected_provider: ProviderType | None = None,
|
||||
page: int | None = None,
|
||||
per_page: int | None = None,
|
||||
installation_id: str | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -99,14 +53,7 @@ async def get_user_repositories(
|
||||
)
|
||||
|
||||
try:
|
||||
return await client.get_repositories(
|
||||
sort,
|
||||
server_config.app_mode,
|
||||
selected_provider,
|
||||
page,
|
||||
per_page,
|
||||
installation_id,
|
||||
)
|
||||
return await client.get_repositories(sort, server_config.app_mode)
|
||||
|
||||
except AuthenticationError as e:
|
||||
logger.info(
|
||||
|
||||
@@ -27,7 +27,6 @@ from openhands.integrations.provider import (
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
CreateMicroagent,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
)
|
||||
@@ -85,7 +84,6 @@ class InitSessionRequest(BaseModel):
|
||||
image_urls: list[str] | None = None
|
||||
replay_json: str | None = None
|
||||
suggested_task: SuggestedTask | None = None
|
||||
create_microagent: CreateMicroagent | None = None
|
||||
conversation_instructions: str | None = None
|
||||
# Only nested runtimes require the ability to specify a conversation id, and it could be a security risk
|
||||
if os.getenv('ALLOW_SET_CONVERSATION_ID', '0') == '1':
|
||||
@@ -125,7 +123,6 @@ async def new_conversation(
|
||||
image_urls = data.image_urls or []
|
||||
replay_json = data.replay_json
|
||||
suggested_task = data.suggested_task
|
||||
create_microagent = data.create_microagent
|
||||
git_provider = data.git_provider
|
||||
conversation_instructions = data.conversation_instructions
|
||||
|
||||
@@ -134,13 +131,6 @@ async def new_conversation(
|
||||
if suggested_task:
|
||||
initial_user_msg = suggested_task.get_prompt_for_task()
|
||||
conversation_trigger = ConversationTrigger.SUGGESTED_TASK
|
||||
elif create_microagent:
|
||||
conversation_trigger = ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
# Set repository and git_provider from create_microagent if not already set
|
||||
if not repository and create_microagent.repo:
|
||||
repository = create_microagent.repo
|
||||
if not git_provider and create_microagent.git_provider:
|
||||
git_provider = create_microagent.git_provider
|
||||
|
||||
if auth_type == AuthType.BEARER:
|
||||
conversation_trigger = ConversationTrigger.REMOTE_API_KEY
|
||||
@@ -220,43 +210,20 @@ async def new_conversation(
|
||||
async def search_conversations(
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
selected_repository: str | None = None,
|
||||
conversation_trigger: ConversationTrigger | None = None,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply filters at API level
|
||||
filtered_results = []
|
||||
# Filter out conversations older than max_age
|
||||
now = datetime.now(timezone.utc)
|
||||
max_age = config.conversation_max_age_seconds
|
||||
|
||||
for conversation in conversation_metadata_result_set.results:
|
||||
# Skip conversations without created_at or older than max_age
|
||||
if not hasattr(conversation, 'created_at'):
|
||||
continue
|
||||
|
||||
age_seconds = (
|
||||
now - conversation.created_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds()
|
||||
if age_seconds > max_age:
|
||||
continue
|
||||
|
||||
# Apply repository filter
|
||||
if (
|
||||
selected_repository is not None
|
||||
and conversation.selected_repository != selected_repository
|
||||
):
|
||||
continue
|
||||
|
||||
# Apply conversation trigger filter
|
||||
if (
|
||||
conversation_trigger is not None
|
||||
and conversation.trigger != conversation_trigger
|
||||
):
|
||||
continue
|
||||
|
||||
filtered_results.append(conversation)
|
||||
filtered_results = [
|
||||
conversation
|
||||
for conversation in conversation_metadata_result_set.results
|
||||
if hasattr(conversation, 'created_at')
|
||||
and (now - conversation.created_at.replace(tzinfo=timezone.utc)).total_seconds()
|
||||
<= max_age
|
||||
]
|
||||
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
|
||||
@@ -11,7 +11,6 @@ class ConversationTrigger(Enum):
|
||||
SUGGESTED_TASK = 'suggested_task'
|
||||
REMOTE_API_KEY = 'openhands_api'
|
||||
SLACK = 'slack'
|
||||
MICROAGENT_MANAGEMENT = 'microagent_management'
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ requires = [
|
||||
|
||||
[tool.poetry]
|
||||
name = "openhands-ai"
|
||||
version = "0.49.1"
|
||||
version = "0.49.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
authors = [ "OpenHands" ]
|
||||
license = "MIT"
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
|
||||
def test_headless_mode_with_dummy_agent_no_browser():
|
||||
"""
|
||||
E2E test: build a docker image from python:3.13, install openhands from source,
|
||||
and run a local runtime task in headless mode.
|
||||
"""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../'))
|
||||
dockerfile = """
|
||||
FROM python:3.13-slim
|
||||
WORKDIR /src
|
||||
RUN apt-get update && apt-get install -y git build-essential tmux
|
||||
COPY . /src
|
||||
RUN pip install --upgrade pip setuptools wheel
|
||||
RUN pip install .
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV RUNTIME=local
|
||||
ENV RUN_AS_OPENHANDS=false
|
||||
ENV ENABLE_BROWSER=false
|
||||
ENV AGENT_ENABLE_BROWSING=false
|
||||
ENV SKIP_DEPENDENCY_CHECK=1
|
||||
CMD ["python", "-m", "openhands.core.main", "-c", "DummyAgent", "-t", "Hello world"]
|
||||
"""
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
dockerfile_path = os.path.join(tmpdir, 'Dockerfile')
|
||||
with open(dockerfile_path, 'w') as f:
|
||||
f.write(dockerfile)
|
||||
# Copy the repo into the temp dir for docker build context
|
||||
build_context = os.path.join(tmpdir, 'context')
|
||||
shutil.copytree(repo_root, build_context, dirs_exist_ok=True)
|
||||
|
||||
image_tag = 'openhands-e2e-local-runtime-test'
|
||||
build_cmd = [
|
||||
'docker',
|
||||
'build',
|
||||
'-t',
|
||||
image_tag,
|
||||
'-f',
|
||||
dockerfile_path,
|
||||
build_context,
|
||||
]
|
||||
run_cmd = ['docker', 'run', '--rm', image_tag]
|
||||
|
||||
# Build the image
|
||||
build_proc = subprocess.run(build_cmd, capture_output=True, text=True)
|
||||
print('Docker build stdout:', build_proc.stdout)
|
||||
print('Docker build stderr:', build_proc.stderr)
|
||||
assert build_proc.returncode == 0, 'Docker build failed'
|
||||
|
||||
# Run the container
|
||||
run_proc = subprocess.run(run_cmd, capture_output=True, text=True)
|
||||
print('Docker run stdout:', run_proc.stdout)
|
||||
print('Docker run stderr:', run_proc.stderr)
|
||||
assert run_proc.returncode == 0, (
|
||||
f'Docker run failed with code {run_proc.returncode}'
|
||||
)
|
||||
assert 'Warning: Observation mismatch' not in run_proc.stdout
|
||||
@@ -450,7 +450,7 @@ async def test_bitbucket_sort_parameter_mapping():
|
||||
]
|
||||
|
||||
# Call get_repositories with sort='pushed'
|
||||
await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that the second call used 'updated_on' instead of 'pushed'
|
||||
assert mock_request.call_count == 2
|
||||
@@ -520,7 +520,7 @@ async def test_bitbucket_pagination():
|
||||
]
|
||||
|
||||
# Call get_repositories
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify that all three requests were made (workspaces + 2 pages of repos)
|
||||
assert mock_request.call_count == 3
|
||||
@@ -619,7 +619,7 @@ async def test_bitbucket_get_repositories_with_user_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -658,7 +658,7 @@ async def test_bitbucket_get_repositories_with_organization_owner_type():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -706,7 +706,7 @@ async def test_bitbucket_get_repositories_mixed_owner_types():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_user_repos, mock_org_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got repositories from both workspaces
|
||||
assert len(repositories) == 2
|
||||
@@ -746,7 +746,7 @@ async def test_bitbucket_get_repositories_owner_type_fallback():
|
||||
with patch.object(service, '_fetch_paginated_data') as mock_fetch:
|
||||
mock_fetch.side_effect = [mock_workspaces, mock_repos]
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type for private workspaces
|
||||
for repo in repositories:
|
||||
|
||||
+152
-27
@@ -1,4 +1,4 @@
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -275,46 +275,171 @@ class TestUserCancelledError:
|
||||
|
||||
class TestReadConfirmationInput:
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.cli_confirm')
|
||||
async def test_read_confirmation_input_yes(self, mock_confirm):
|
||||
mock_confirm.return_value = 0 # user picked first menu item
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_yes(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'y'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
cfg = MagicMock() # <- no spec for simplicity
|
||||
cfg.cli = MagicMock(vi_mode=False)
|
||||
|
||||
result = await read_confirmation_input(config=cfg)
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.cli_confirm')
|
||||
async def test_read_confirmation_input_no(self, mock_confirm):
|
||||
mock_confirm.return_value = 1 # user picked second menu item
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_yes_full(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'yes'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
cfg = MagicMock() # <- no spec for simplicity
|
||||
cfg.cli = MagicMock(vi_mode=False)
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
result = await read_confirmation_input(config=cfg)
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_no(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'n'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.cli_confirm')
|
||||
async def test_read_confirmation_input_always(self, mock_confirm):
|
||||
mock_confirm.return_value = 2 # user picked third menu item
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_no_full(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'no'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
cfg = MagicMock() # <- no spec for simplicity
|
||||
cfg.cli = MagicMock(vi_mode=False)
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
result = await read_confirmation_input(config=cfg)
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_always(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'a'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'always'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_always_full(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.return_value = 'always'
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'always'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
@patch('openhands.cli.tui.cli_confirm')
|
||||
async def test_read_confirmation_input_edit(self, mock_confirm, mock_print):
|
||||
mock_confirm.return_value = 3 # user picked third menu item
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_invalid_then_valid(
|
||||
self, mock_create_session, mock_print
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
# First return invalid input, then valid input
|
||||
mock_session.prompt_async.side_effect = ['invalid', 'y']
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
cfg = MagicMock() # <- no spec for simplicity
|
||||
cfg.cli = MagicMock(vi_mode=False)
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
result = await read_confirmation_input(config=cfg)
|
||||
assert result == 'edit'
|
||||
# Verify error message was displayed
|
||||
error_calls = [
|
||||
call
|
||||
for call in mock_print.call_args_list
|
||||
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
|
||||
]
|
||||
assert len(error_calls) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_empty_then_valid(
|
||||
self, mock_create_session, mock_print
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
# First return empty input, then valid input
|
||||
mock_session.prompt_async.side_effect = ['', 'n']
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
# Verify error message was displayed
|
||||
error_calls = [
|
||||
call
|
||||
for call in mock_print.call_args_list
|
||||
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
|
||||
]
|
||||
assert len(error_calls) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_none_then_valid(
|
||||
self, mock_create_session, mock_print
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
# First return None, then valid input
|
||||
mock_session.prompt_async.side_effect = [None, 'always']
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'always'
|
||||
|
||||
# Verify error message was displayed
|
||||
error_calls = [
|
||||
call
|
||||
for call in mock_print.call_args_list
|
||||
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
|
||||
]
|
||||
assert len(error_calls) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.print_formatted_text')
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_multiple_invalid_then_valid(
|
||||
self, mock_create_session, mock_print
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
# Multiple invalid inputs, then valid input
|
||||
mock_session.prompt_async.side_effect = ['invalid1', 'invalid2', 'maybe', 'y']
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'yes'
|
||||
|
||||
# Verify error message was displayed multiple times
|
||||
error_calls = [
|
||||
call
|
||||
for call in mock_print.call_args_list
|
||||
if len(call[0]) > 0 and 'Invalid input' in str(call[0][0])
|
||||
]
|
||||
assert len(error_calls) >= 3 # Should have at least 3 error messages
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_keyboard_interrupt(
|
||||
self, mock_create_session
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.side_effect = KeyboardInterrupt
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_eof_error(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.side_effect = EOFError
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
|
||||
assert result == 'no'
|
||||
|
||||
@@ -11,7 +11,6 @@ from fastapi.testclient import TestClient
|
||||
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
CreateMicroagent,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
@@ -159,8 +158,6 @@ async def test_search_conversations():
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
selected_repository=None,
|
||||
conversation_trigger=None,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
@@ -185,422 +182,6 @@ async def test_search_conversations():
|
||||
assert result_set == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_with_repository_filter():
|
||||
"""Test searching conversations with repository filter."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='conversation_1',
|
||||
title='Conversation 1',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='test/repo',
|
||||
user_id='12345',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
selected_repository='test/repo',
|
||||
conversation_trigger=None,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with only pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with(None, 20)
|
||||
|
||||
# Verify the result contains only conversations from the specified repository
|
||||
assert len(result_set.results) == 1
|
||||
assert result_set.results[0].selected_repository == 'test/repo'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_with_trigger_filter():
|
||||
"""Test searching conversations with conversation trigger filter."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='conversation_1',
|
||||
title='Conversation 1',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='test/repo',
|
||||
trigger=ConversationTrigger.GUI,
|
||||
user_id='12345',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
selected_repository=None,
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with only pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with(None, 20)
|
||||
|
||||
# Verify the result contains only conversations with the specified trigger
|
||||
assert len(result_set.results) == 1
|
||||
assert result_set.results[0].trigger == ConversationTrigger.GUI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_with_both_filters():
|
||||
"""Test searching conversations with both repository and trigger filters."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='conversation_1',
|
||||
title='Conversation 1',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='test/repo',
|
||||
trigger=ConversationTrigger.SUGGESTED_TASK,
|
||||
user_id='12345',
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
selected_repository='test/repo',
|
||||
conversation_trigger=ConversationTrigger.SUGGESTED_TASK,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with only pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with(None, 20)
|
||||
|
||||
# Verify the result contains only conversations matching both filters
|
||||
assert len(result_set.results) == 1
|
||||
result = result_set.results[0]
|
||||
assert result.selected_repository == 'test/repo'
|
||||
assert result.trigger == ConversationTrigger.SUGGESTED_TASK
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_with_pagination():
|
||||
"""Test searching conversations with pagination."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='conversation_1',
|
||||
title='Conversation 1',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='test/repo',
|
||||
user_id='12345',
|
||||
)
|
||||
],
|
||||
next_page_id='next_page_123',
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id='page_123',
|
||||
limit=10,
|
||||
selected_repository=None,
|
||||
conversation_trigger=None,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with('page_123', 10)
|
||||
|
||||
# Verify the result includes pagination info
|
||||
assert result_set.next_page_id == 'next_page_123'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_with_filters_and_pagination():
|
||||
"""Test searching conversations with filters and pagination."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[
|
||||
ConversationMetadata(
|
||||
conversation_id='conversation_1',
|
||||
title='Conversation 1',
|
||||
created_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
),
|
||||
last_updated_at=datetime.fromisoformat(
|
||||
'2025-01-01T00:01:00+00:00'
|
||||
),
|
||||
selected_repository='test/repo',
|
||||
trigger=ConversationTrigger.GUI,
|
||||
user_id='12345',
|
||||
)
|
||||
],
|
||||
next_page_id='next_page_456',
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id='page_456',
|
||||
limit=5,
|
||||
selected_repository='test/repo',
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with only pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with('page_456', 5)
|
||||
|
||||
# Verify the result includes pagination info
|
||||
assert result_set.next_page_id == 'next_page_456'
|
||||
assert len(result_set.results) == 1
|
||||
result = result_set.results[0]
|
||||
assert result.selected_repository == 'test/repo'
|
||||
assert result.trigger == ConversationTrigger.GUI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_conversations_empty_results():
|
||||
"""Test searching conversations that returns empty results."""
|
||||
with _patch_store():
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.config'
|
||||
) as mock_config:
|
||||
mock_config.conversation_max_age_seconds = 864000 # 10 days
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.conversation_manager'
|
||||
) as mock_manager:
|
||||
|
||||
async def mock_get_running_agent_loops(*args, **kwargs):
|
||||
return set()
|
||||
|
||||
async def mock_get_connections(*args, **kwargs):
|
||||
return {}
|
||||
|
||||
async def get_agent_loop_info(*args, **kwargs):
|
||||
return []
|
||||
|
||||
mock_manager.get_running_agent_loops = mock_get_running_agent_loops
|
||||
mock_manager.get_connections = mock_get_connections
|
||||
mock_manager.get_agent_loop_info = get_agent_loop_info
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.datetime'
|
||||
) as mock_datetime:
|
||||
mock_datetime.now.return_value = datetime.fromisoformat(
|
||||
'2025-01-01T00:00:00+00:00'
|
||||
)
|
||||
mock_datetime.fromisoformat = datetime.fromisoformat
|
||||
mock_datetime.timezone = timezone
|
||||
|
||||
# Mock the conversation store
|
||||
mock_store = MagicMock()
|
||||
mock_store.search = AsyncMock(
|
||||
return_value=ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
)
|
||||
|
||||
result_set = await search_conversations(
|
||||
page_id=None,
|
||||
limit=20,
|
||||
selected_repository='nonexistent/repo',
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
conversation_store=mock_store,
|
||||
)
|
||||
|
||||
# Verify that search was called with only pagination parameters (filtering is done at API level)
|
||||
mock_store.search.assert_called_once_with(None, 20)
|
||||
|
||||
# Verify the result is empty
|
||||
assert len(result_set.results) == 0
|
||||
assert result_set.next_page_id is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_conversation():
|
||||
with _patch_store():
|
||||
@@ -1027,174 +608,3 @@ async def test_new_conversation_with_unsupported_params():
|
||||
# Verify that the error message mentions the unsupported parameter
|
||||
assert 'Extra inputs are not permitted' in str(excinfo.value)
|
||||
assert 'unsupported_param' in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_with_create_microagent(provider_handler_mock):
|
||||
"""Test creating a new conversation with a CreateMicroagent object."""
|
||||
with _patch_store():
|
||||
# Mock the create_new_conversation function directly
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to return a conversation ID
|
||||
mock_create_conversation.return_value = MagicMock(
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Create the CreateMicroagent object
|
||||
create_microagent = CreateMicroagent(
|
||||
repo='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
title='Create a new microagent',
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
repository=None, # Not set in request, should be set from create_microagent
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
create_microagent=create_microagent,
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
assert response.conversation_id is not None
|
||||
assert isinstance(response.conversation_id, str)
|
||||
|
||||
# Verify that create_new_conversation was called with the correct arguments
|
||||
mock_create_conversation.assert_called_once()
|
||||
call_args = mock_create_conversation.call_args[1]
|
||||
assert call_args['user_id'] == 'test_user'
|
||||
assert (
|
||||
call_args['selected_repository'] == 'test/repo'
|
||||
) # Should be set from create_microagent
|
||||
assert call_args['selected_branch'] == 'main'
|
||||
assert call_args['initial_user_msg'] == 'Hello, agent!'
|
||||
assert (
|
||||
call_args['conversation_trigger']
|
||||
== ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
)
|
||||
assert (
|
||||
call_args['git_provider'] == ProviderType.GITHUB
|
||||
) # Should be set from create_microagent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_with_create_microagent_repository_override(
|
||||
provider_handler_mock,
|
||||
):
|
||||
"""Test creating a new conversation with CreateMicroagent when repository is already set."""
|
||||
with _patch_store():
|
||||
# Mock the create_new_conversation function directly
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to return a conversation ID
|
||||
mock_create_conversation.return_value = MagicMock(
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Create the CreateMicroagent object
|
||||
create_microagent = CreateMicroagent(
|
||||
repo='microagent/repo',
|
||||
git_provider=ProviderType.GITLAB,
|
||||
title='Create a new microagent',
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
repository='existing/repo', # Already set in request
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
create_microagent=create_microagent,
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
assert response.conversation_id is not None
|
||||
assert isinstance(response.conversation_id, str)
|
||||
|
||||
# Verify that create_new_conversation was called with the correct arguments
|
||||
mock_create_conversation.assert_called_once()
|
||||
call_args = mock_create_conversation.call_args[1]
|
||||
assert call_args['user_id'] == 'test_user'
|
||||
assert (
|
||||
call_args['selected_repository'] == 'existing/repo'
|
||||
) # Should keep existing value
|
||||
assert call_args['selected_branch'] == 'main'
|
||||
assert call_args['initial_user_msg'] == 'Hello, agent!'
|
||||
assert (
|
||||
call_args['conversation_trigger']
|
||||
== ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
)
|
||||
assert (
|
||||
call_args['git_provider'] == ProviderType.GITLAB
|
||||
) # Should be set from create_microagent
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_with_create_microagent_minimal(provider_handler_mock):
|
||||
"""Test creating a new conversation with minimal CreateMicroagent object (only repo field)."""
|
||||
with _patch_store():
|
||||
# Mock the create_new_conversation function directly
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations.create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to return a conversation ID
|
||||
mock_create_conversation.return_value = MagicMock(
|
||||
conversation_id='test_conversation_id',
|
||||
url='https://my-conversation.com',
|
||||
session_api_key=None,
|
||||
status=ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
# Create the CreateMicroagent object with only required field
|
||||
create_microagent = CreateMicroagent(
|
||||
repo='minimal/repo',
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
repository=None,
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
create_microagent=create_microagent,
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await create_new_test_conversation(test_request)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, ConversationResponse)
|
||||
assert response.status == 'ok'
|
||||
assert response.conversation_id is not None
|
||||
assert isinstance(response.conversation_id, str)
|
||||
|
||||
# Verify that create_new_conversation was called with the correct arguments
|
||||
mock_create_conversation.assert_called_once()
|
||||
call_args = mock_create_conversation.call_args[1]
|
||||
assert call_args['user_id'] == 'test_user'
|
||||
assert (
|
||||
call_args['selected_repository'] == 'minimal/repo'
|
||||
) # Should be set from create_microagent
|
||||
assert call_args['selected_branch'] == 'main'
|
||||
assert call_args['initial_user_msg'] == 'Hello, agent!'
|
||||
assert (
|
||||
call_args['conversation_trigger']
|
||||
== ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
)
|
||||
assert (
|
||||
call_args['git_provider'] is None
|
||||
) # Should remain None since not set in create_microagent
|
||||
|
||||
@@ -2,7 +2,6 @@ import gc
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import psutil
|
||||
import pytest
|
||||
@@ -11,7 +10,6 @@ from pytest import TempPathFactory
|
||||
from openhands.core.schema import ActionType, ObservationType
|
||||
from openhands.events import EventSource, EventStream, EventStreamSubscriber
|
||||
from openhands.events.action import (
|
||||
CmdRunAction,
|
||||
NullAction,
|
||||
)
|
||||
from openhands.events.action.files import (
|
||||
@@ -737,129 +735,3 @@ def test_cache_page_with_missing_events(temp_dir: str):
|
||||
# If the delete operation fails, we'll just verify that the basic functionality works
|
||||
print(f'Note: Could not delete file {missing_filename}: {e}')
|
||||
assert len(initial_events) > 0, 'Should retrieve events successfully'
|
||||
|
||||
|
||||
def test_secrets_replaced_in_content(temp_dir: str):
|
||||
"""Test that secrets are properly replaced in event content."""
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
stream = EventStream('test_session', file_store)
|
||||
|
||||
# Set up a secret
|
||||
stream.set_secrets({'api_key': 'secret123'})
|
||||
|
||||
# Create an event with the secret in the command
|
||||
action = CmdRunAction(
|
||||
command='curl -H "Authorization: Bearer secret123" https://api.example.com'
|
||||
)
|
||||
action._timestamp = datetime.now().isoformat()
|
||||
|
||||
# Convert to dict and apply secret replacement
|
||||
data = event_to_dict(action)
|
||||
data_with_secrets_replaced = stream._replace_secrets(data)
|
||||
|
||||
# The secret should be replaced in the command
|
||||
assert '<secret_hidden>' in data_with_secrets_replaced['args']['command']
|
||||
assert 'secret123' not in data_with_secrets_replaced['args']['command']
|
||||
|
||||
|
||||
def test_timestamp_not_affected_by_secret_replacement(temp_dir: str):
|
||||
"""Test that timestamps are not corrupted by secret replacement."""
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
stream = EventStream('test_session', file_store)
|
||||
|
||||
# Set up a secret that appears in the current date (e.g., "18" for 2025-07-18)
|
||||
stream.set_secrets({'test_secret': '18'})
|
||||
|
||||
# Create an event with a timestamp
|
||||
action = CmdRunAction(command='echo "hello world"')
|
||||
action._timestamp = '2025-07-18T17:01:36.799608' # Contains "18"
|
||||
|
||||
# Convert to dict and apply secret replacement
|
||||
data = event_to_dict(action)
|
||||
original_timestamp = data['timestamp']
|
||||
data_with_secrets_replaced = stream._replace_secrets(data)
|
||||
|
||||
# The timestamp should NOT be affected by secret replacement
|
||||
assert data_with_secrets_replaced['timestamp'] == original_timestamp
|
||||
assert '<secret_hidden>' not in data_with_secrets_replaced['timestamp']
|
||||
assert '18' in data_with_secrets_replaced['timestamp'] # Original value preserved
|
||||
|
||||
|
||||
def test_protected_fields_not_affected_by_secret_replacement(temp_dir: str):
|
||||
"""Test that protected system fields are not affected by secret replacement."""
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
stream = EventStream('test_session', file_store)
|
||||
|
||||
# Set up secrets that might appear in system fields
|
||||
stream.set_secrets(
|
||||
{
|
||||
'secret1': '123', # Could appear in ID
|
||||
'secret2': 'user', # Could appear in source
|
||||
'secret3': 'run', # Could appear in action/observation
|
||||
'secret4': 'Running', # Could appear in message
|
||||
}
|
||||
)
|
||||
|
||||
# Create test data with protected fields
|
||||
data = {
|
||||
'id': 123,
|
||||
'timestamp': '2025-07-18T17:01:36.799608',
|
||||
'source': 'user',
|
||||
'cause': 123,
|
||||
'action': 'run',
|
||||
'observation': 'run',
|
||||
'message': 'Running command: echo hello',
|
||||
'content': 'This contains secret1: 123 and secret2: user and secret3: run',
|
||||
}
|
||||
|
||||
data_with_secrets_replaced = stream._replace_secrets(data)
|
||||
|
||||
# Protected fields should not be affected at top level
|
||||
assert data_with_secrets_replaced['id'] == 123
|
||||
assert data_with_secrets_replaced['timestamp'] == '2025-07-18T17:01:36.799608'
|
||||
assert data_with_secrets_replaced['source'] == 'user'
|
||||
assert data_with_secrets_replaced['cause'] == 123
|
||||
assert data_with_secrets_replaced['action'] == 'run'
|
||||
assert data_with_secrets_replaced['observation'] == 'run'
|
||||
assert data_with_secrets_replaced['message'] == 'Running command: echo hello'
|
||||
|
||||
# But non-protected fields should have secrets replaced
|
||||
assert '<secret_hidden>' in data_with_secrets_replaced['content']
|
||||
assert '123' not in data_with_secrets_replaced['content']
|
||||
assert 'user' not in data_with_secrets_replaced['content']
|
||||
# Note: 'run' should still be replaced in content since it's not a protected field
|
||||
|
||||
|
||||
def test_nested_dict_secret_replacement(temp_dir: str):
|
||||
"""Test that secrets are replaced in nested dictionaries while preserving protected fields."""
|
||||
file_store = get_file_store('local', temp_dir)
|
||||
stream = EventStream('test_session', file_store)
|
||||
|
||||
stream.set_secrets({'secret': 'password123'})
|
||||
|
||||
# Create nested data structure
|
||||
data = {
|
||||
'timestamp': '2025-07-18T17:01:36.799608',
|
||||
'args': {
|
||||
'command': 'login --password password123',
|
||||
'env': {
|
||||
'SECRET_KEY': 'password123',
|
||||
'timestamp': 'password123_timestamp', # This should be replaced since it's not top-level
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data_with_secrets_replaced = stream._replace_secrets(data)
|
||||
|
||||
# Top-level timestamp should be protected
|
||||
assert data_with_secrets_replaced['timestamp'] == '2025-07-18T17:01:36.799608'
|
||||
|
||||
# Nested secrets should be replaced
|
||||
assert '<secret_hidden>' in data_with_secrets_replaced['args']['command']
|
||||
assert data_with_secrets_replaced['args']['env']['SECRET_KEY'] == '<secret_hidden>'
|
||||
assert '<secret_hidden>' in data_with_secrets_replaced['args']['env']['timestamp']
|
||||
|
||||
# Original secret should not appear in nested content
|
||||
assert 'password123' not in data_with_secrets_replaced['args']['command']
|
||||
assert 'password123' not in data_with_secrets_replaced['args']['env']['SECRET_KEY']
|
||||
assert 'password123' not in data_with_secrets_replaced['args']['env']['timestamp']
|
||||
|
||||
@@ -112,9 +112,9 @@ async def test_github_get_repositories_with_user_owner_type():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -151,9 +151,9 @@ async def test_github_get_repositories_with_organization_owner_type():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -190,9 +190,9 @@ async def test_github_get_repositories_mixed_owner_types():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -237,9 +237,9 @@ async def test_github_get_repositories_owner_type_fallback():
|
||||
|
||||
with (
|
||||
patch.object(service, '_fetch_paginated_repos', return_value=mock_repo_data),
|
||||
patch.object(service, 'get_installations', return_value=[123]),
|
||||
patch.object(service, 'get_installation_ids', return_value=[123]),
|
||||
):
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type
|
||||
for repo in repositories:
|
||||
|
||||
@@ -37,7 +37,7 @@ async def test_gitlab_get_repositories_with_user_owner_type():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -76,7 +76,7 @@ async def test_gitlab_get_repositories_with_organization_owner_type():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -115,7 +115,7 @@ async def test_gitlab_get_repositories_mixed_owner_types():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify we got the expected number of repositories
|
||||
assert len(repositories) == 2
|
||||
@@ -162,7 +162,7 @@ async def test_gitlab_get_repositories_owner_type_fallback():
|
||||
# Mock the pagination response
|
||||
mock_request.side_effect = [(mock_repos, {'Link': ''})] # No next page
|
||||
|
||||
repositories = await service.get_all_repositories('pushed', AppMode.SAAS)
|
||||
repositories = await service.get_repositories('pushed', AppMode.SAAS)
|
||||
|
||||
# Verify all repositories default to USER owner_type
|
||||
for repo in repositories:
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
"""Test for MCP tool timeout causing agent to stall indefinitely."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from mcp import McpError
|
||||
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.agent_controller import AgentController, AgentState
|
||||
from openhands.events.action.mcp import MCPAction
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.tool import MCPClientTool
|
||||
from openhands.mcp.utils import call_tool_mcp
|
||||
|
||||
|
||||
class MockConfig:
|
||||
"""Mock config for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.max_message_chars = 10000
|
||||
|
||||
|
||||
class MockLLM:
|
||||
"""Mock LLM for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.metrics = None
|
||||
self.config = MockConfig()
|
||||
|
||||
|
||||
class MockAgent(Agent):
|
||||
"""Mock agent for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self.step_called = False
|
||||
self.next_action = None
|
||||
self.llm = MockLLM()
|
||||
|
||||
def step(self, *args, **kwargs):
|
||||
"""Mock step method."""
|
||||
self.step_called = True
|
||||
return self.next_action
|
||||
|
||||
def get_system_message(self):
|
||||
"""Mock get_system_message method."""
|
||||
return SystemMessageAction(content='System message')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_timeout_error_handling():
|
||||
"""Test that verifies MCP tool timeout errors are properly handled and returned as observations."""
|
||||
# Create a mock MCPClient
|
||||
mock_client = mock.MagicMock(spec=MCPClient)
|
||||
|
||||
# Configure the mock to raise a McpError when call_tool is called
|
||||
async def mock_call_tool(*args, **kwargs):
|
||||
# Simulate a timeout
|
||||
await asyncio.sleep(0.1)
|
||||
# Create a mock error object with the message attribute
|
||||
error = mock.MagicMock()
|
||||
error.message = 'Timed out while waiting for response to ClientRequest. Waited 30.0 seconds.'
|
||||
raise McpError(error)
|
||||
|
||||
mock_client.call_tool.side_effect = mock_call_tool
|
||||
|
||||
# Create a mock tool
|
||||
mock_tool = MCPClientTool(
|
||||
name='test_tool',
|
||||
description='Test tool',
|
||||
inputSchema={'type': 'object', 'properties': {}},
|
||||
session=None,
|
||||
)
|
||||
mock_client.tools = [mock_tool]
|
||||
mock_client.tool_map = {'test_tool': mock_tool}
|
||||
|
||||
# Create a mock file store
|
||||
mock_file_store = mock.MagicMock()
|
||||
|
||||
# Create a mock event stream
|
||||
event_stream = EventStream(sid='test-session', file_store=mock_file_store)
|
||||
|
||||
# Create a mock agent
|
||||
agent = MockAgent()
|
||||
|
||||
# Create a mock agent controller
|
||||
controller = AgentController(
|
||||
sid='test-session',
|
||||
file_store=mock_file_store,
|
||||
user_id='test-user',
|
||||
agent=agent,
|
||||
event_stream=event_stream,
|
||||
iteration_delta=10,
|
||||
budget_per_task_delta=None,
|
||||
)
|
||||
|
||||
# Set up the agent state
|
||||
await controller.set_agent_state_to(AgentState.RUNNING)
|
||||
|
||||
# Create an MCP action
|
||||
mcp_action = MCPAction(
|
||||
name='test_tool',
|
||||
arguments={'param': 'value'},
|
||||
thought='Testing MCP timeout handling',
|
||||
)
|
||||
|
||||
# Add the action to the event stream
|
||||
event_stream.add_event(mcp_action, EventSource.AGENT)
|
||||
|
||||
# Set the pending action
|
||||
controller._pending_action = mcp_action
|
||||
|
||||
# Before the fix, this would raise an exception and not return an observation
|
||||
# Now with the fix, it should return an error observation
|
||||
result = await call_tool_mcp([mock_client], mcp_action)
|
||||
|
||||
# Verify that the function returns an error observation
|
||||
assert isinstance(result, MCPObservation)
|
||||
content = json.loads(result.content)
|
||||
assert content['isError'] is True
|
||||
assert 'timed out' in content['error'].lower()
|
||||
|
||||
# The agent controller would now be able to continue processing
|
||||
# because it received an error observation instead of an exception
|
||||
|
||||
# Verify that the agent is still in the RUNNING state
|
||||
assert controller.get_agent_state() == AgentState.RUNNING
|
||||
|
||||
# Verify that the agent can continue processing
|
||||
agent.next_action = MCPAction(
|
||||
name='another_tool',
|
||||
arguments={'param': 'value'},
|
||||
thought='Another action after timeout',
|
||||
)
|
||||
|
||||
# The agent controller would be able to step because it received an observation
|
||||
# This demonstrates that the fix is working
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_timeout_agent_continuation():
|
||||
"""Test that verifies the agent can continue processing after an MCP tool timeout."""
|
||||
# Create a mock MCPClient
|
||||
mock_client = mock.MagicMock(spec=MCPClient)
|
||||
|
||||
# Configure the mock to raise a McpError when call_tool is called
|
||||
async def mock_call_tool(*args, **kwargs):
|
||||
# Simulate a timeout
|
||||
await asyncio.sleep(0.1)
|
||||
# Create a mock error object with the message attribute
|
||||
error = mock.MagicMock()
|
||||
error.message = 'Timed out while waiting for response to ClientRequest. Waited 30.0 seconds.'
|
||||
raise McpError(error)
|
||||
|
||||
mock_client.call_tool.side_effect = mock_call_tool
|
||||
|
||||
# Create a mock tool
|
||||
mock_tool = MCPClientTool(
|
||||
name='test_tool',
|
||||
description='Test tool',
|
||||
inputSchema={'type': 'object', 'properties': {}},
|
||||
session=None,
|
||||
)
|
||||
mock_client.tools = [mock_tool]
|
||||
mock_client.tool_map = {'test_tool': mock_tool}
|
||||
|
||||
# Create a mock file store
|
||||
mock_file_store = mock.MagicMock()
|
||||
|
||||
# Create a mock event stream
|
||||
event_stream = EventStream(sid='test-session', file_store=mock_file_store)
|
||||
|
||||
# Create a mock agent
|
||||
agent = MockAgent()
|
||||
|
||||
# Create a mock agent controller
|
||||
controller = AgentController(
|
||||
sid='test-session',
|
||||
file_store=mock_file_store,
|
||||
user_id='test-user',
|
||||
agent=agent,
|
||||
event_stream=event_stream,
|
||||
iteration_delta=10,
|
||||
budget_per_task_delta=None,
|
||||
)
|
||||
|
||||
# Set up the agent state
|
||||
await controller.set_agent_state_to(AgentState.RUNNING)
|
||||
|
||||
# Create an MCP action
|
||||
mcp_action = MCPAction(
|
||||
name='test_tool',
|
||||
arguments={'param': 'value'},
|
||||
thought='Testing MCP timeout handling',
|
||||
)
|
||||
|
||||
# Add the action to the event stream
|
||||
event_stream.add_event(mcp_action, EventSource.AGENT)
|
||||
|
||||
# Set the pending action
|
||||
controller._pending_action = mcp_action
|
||||
|
||||
# Now implement the fix in call_tool_mcp
|
||||
async def fixed_call_tool_mcp(clients, action):
|
||||
try:
|
||||
# This will raise the McpError
|
||||
await mock_client.call_tool(action.name, action.arguments)
|
||||
except McpError as e:
|
||||
# Create an error observation
|
||||
error_content = json.dumps(
|
||||
{'isError': True, 'error': str(e), 'content': []}
|
||||
)
|
||||
observation = MCPObservation(
|
||||
content=error_content,
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
# Set the cause
|
||||
setattr(observation, '_cause', action.id)
|
||||
return observation
|
||||
|
||||
# Use our fixed function
|
||||
with mock.patch(
|
||||
'openhands.mcp.utils.call_tool_mcp', side_effect=fixed_call_tool_mcp
|
||||
):
|
||||
# Call the function that would normally be called by the agent controller
|
||||
result = await call_tool_mcp([mock_client], mcp_action)
|
||||
|
||||
# Verify that the function returns an error observation
|
||||
assert isinstance(result, MCPObservation)
|
||||
content = json.loads(result.content)
|
||||
assert content['isError'] is True
|
||||
assert 'timed out' in content['error'].lower()
|
||||
|
||||
# Now simulate the agent controller's handling of the observation
|
||||
event_stream.add_event(result, EventSource.ENVIRONMENT)
|
||||
|
||||
# Verify that the pending action is cleared
|
||||
controller._pending_action = None
|
||||
|
||||
# Verify that the agent is still in the RUNNING state
|
||||
assert controller.get_agent_state() == AgentState.RUNNING
|
||||
|
||||
# Verify that the agent can continue processing
|
||||
agent.next_action = MCPAction(
|
||||
name='another_tool',
|
||||
arguments={'param': 'value'},
|
||||
thought='Another action after timeout',
|
||||
)
|
||||
|
||||
# Simulate a step
|
||||
await controller._step()
|
||||
|
||||
# Verify that the agent was asked to step
|
||||
assert agent.step_called
|
||||
Reference in New Issue
Block a user