Compare commits

..

2 Commits

Author SHA1 Message Date
openhands 8c8c1c528f Merge origin/main into test/replicate-many-changes 2025-07-17 19:00:39 +00:00
amanape bf8b57ba12 Add comment 2025-07-17 22:53:01 +04:00
61 changed files with 416 additions and 2387 deletions
@@ -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:
+2 -3
View File
@@ -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 -28
View File
@@ -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")
);
});
});
+12 -10
View File
@@ -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": {
+1 -1
View File
@@ -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",
-18
View File
@@ -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}
/>
);
}
@@ -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>
@@ -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>
);
}
@@ -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,
)}
@@ -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>
@@ -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">
@@ -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
@@ -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
});
};
+3 -13
View File
@@ -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",
}
+61 -221
View File
@@ -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": "Вивчити структуру файлів"
}
}
-3
View File
@@ -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>
);
}
+2
View File
@@ -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>
+36 -52
View File
@@ -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
View File
@@ -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
View File
@@ -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'
+3 -19
View File
@@ -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] = []
+3 -49
View File
@@ -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}')
+1 -22
View File
@@ -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"""
...
@@ -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,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.
@@ -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
@@ -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,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.
@@ -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
-3
View File
@@ -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
View File
@@ -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,
)
+1 -54
View File
@@ -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
View File
@@ -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"
-62
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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'
-590
View File
@@ -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
-128
View File
@@ -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']
+8 -8
View File
@@ -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:
+4 -4
View File
@@ -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:
-260
View File
@@ -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