Compare commits

..

30 Commits

Author SHA1 Message Date
openhands 6b29607068 Add empty foo.ts file 2025-07-22 17:24:15 +00:00
openhands 42fd1e05d9 Merge main into test/replicate-many-changes 2025-07-22 17:20:22 +00:00
Hiep Le 38ffc85470 feat(frontend): Integrate with the API to add a new microagent. (#9821) 2025-07-22 16:57:05 +00:00
Xingyao Wang 58ea7b5248 Add make lint to pre-commit hook (#9795)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 12:36:54 -04:00
bojackli f62ed911d2 Fix: Resolve cross-platform path splitting bug in search (#9732)
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-07-22 18:09:50 +02:00
dependabot[bot] d13e32bcec chore(deps-dev): bump @types/node from 24.0.15 to 24.1.0 in /frontend in the version-all group (#9848)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 19:20:21 +04:00
Xingyao Wang b978b71c47 Enhance run-eval workflow: Add release triggers and manual dispatch (#9742)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 23:11:59 +08:00
llamantino dc2f5cd1b0 fix(cli): filter out LiteLLM coroutine not awaited warning at shutdown (#9842) 2025-07-22 21:53:58 +08:00
mamoodi 07041e057d fix(frontend): Add context menu state management to Controls component (#9841)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 09:49:41 -04:00
mamoodi 6e91d19f80 Fix: Prevent LLM settings from being accessible in SaaS mode via double-click (#9831)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 09:49:31 -04:00
dependabot[bot] 936510e219 chore(deps): bump the version-all group in /frontend with 2 updates (#9829)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 17:41:02 +04:00
Boxuan Li 7af35ab827 Evaluation: disable browser when NOT run_with_browsing (#9837) 2025-07-22 01:45:52 +00:00
Xingyao Wang a7245f2de2 fix(CLI): alias persistence issue (#9828)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-22 05:45:14 +08:00
Tim O'Farrell 6d7ab8a022 Fix for issue where some cases use WORK_PORT and some use APP_PORT (#9830) 2025-07-21 20:24:24 +00:00
Hiep Le bbfa37fd97 feat(frontend): Allow searching/filtering repositories. (#9791) 2025-07-21 16:05:32 +00:00
dependabot[bot] d0cf12e474 chore(deps-dev): bump the eslint group in /frontend with 3 updates (#9825)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-21 16:02:35 +00:00
sp.wack 78306b1ee7 hotfix(frontend): Fix context menu closing (#9822) 2025-07-21 19:44:08 +04:00
sp.wack f6d99234f1 fix(frontend): Fix auth modal tests by adding required providersConfigured prop (#9823) 2025-07-21 19:40:54 +04:00
Boxuan Li 19ca52f954 Skip browser dependency build in Dockerfile when browser is disabled (#9815) 2025-07-21 08:34:11 -07:00
Hiep Le df75116184 feat(frontend): Integrate with API to display repositories and their associated microagents. (#9784)
Co-authored-by: sp.wack <83104063+amanape@users.noreply.github.com>
2025-07-21 19:19:34 +04:00
Xingyao Wang acfb01d743 Fix timestamp corruption in EventStream secret replacement (#9793)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-21 22:23:52 +08:00
Regis David Souza Mesquita 0daaf21607 Add support for the groq hosted kimi-k2-instruct model in the functio… (#9759) 2025-07-21 15:14:09 +02:00
llamantino 95bda09cd9 fix(settings): improve MCP editor layout and controls (#9771)
Co-authored-by: Graham Neubig <neubig@gmail.com>
2025-07-20 19:15:45 +00:00
Rohit Malhotra 1243612694 [Fix]: instruct agent to use token when accessing provider apis (#9807) 2025-07-19 22:04:07 +00:00
Graham Neubig aff8aba5e6 Fix: Use correct git provider in Push & Create PR button (#9803)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-19 11:13:29 -04:00
mamoodi aea37e52f7 Update gitlab integration docs (#9025)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-07-18 16:42:56 -04:00
Xingyao Wang f5674d7c76 feat(agent): Add import placement guidance to CodeAct agent system prompts (#9794)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-07-18 19:59:09 +00:00
Vasi 9c68146b04 feat: [CLI] 9392 cli improve confirmation ux (#9758) 2025-07-18 19:42:17 +00:00
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
91 changed files with 4078 additions and 718 deletions
+100 -21
View File
@@ -1,56 +1,135 @@
# Run evaluation on a PR
# Run evaluation on a PR, after releases, or manually
name: Run Eval
# Runs when a PR is labeled with one of the "run-eval-" labels
# Runs when a PR is labeled with one of the "run-eval-" labels, after releases, or manually triggered
on:
pull_request:
types: [labeled]
release:
types: [published]
workflow_dispatch:
inputs:
branch:
description: 'Branch to evaluate'
required: true
default: 'main'
eval_instances:
description: 'Number of evaluation instances'
required: true
default: '50'
type: choice
options:
- '1'
- '2'
- '50'
- '100'
reason:
description: 'Reason for manual trigger'
required: false
default: ''
env:
# Environment variable for the master GitHub issue number where all evaluation results will be commented
# This should be set to the issue number where you want all evaluation results to be posted
MASTER_EVAL_ISSUE_NUMBER: ${{ vars.MASTER_EVAL_ISSUE_NUMBER || '0' }}
jobs:
trigger-job:
name: Trigger remote eval job
if: ${{ github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100' }}
if: ${{ (github.event_name == 'pull_request' && (github.event.label.name == 'run-eval-1' || github.event.label.name == 'run-eval-2' || github.event.label.name == 'run-eval-50' || github.event.label.name == 'run-eval-100')) || github.event_name == 'release' || github.event_name == 'workflow_dispatch' }}
runs-on: blacksmith-4vcpu-ubuntu-2204
steps:
- name: Checkout PR branch
- name: Checkout branch
uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
ref: ${{ github.event_name == 'pull_request' && github.head_ref || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch) || github.ref }}
- name: Trigger remote job
env:
PR_BRANCH: ${{ github.head_ref }}
- name: Set evaluation parameters
id: eval_params
run: |
REPO_URL="https://github.com/${{ github.repository }}"
echo "Repository URL: $REPO_URL"
echo "PR Branch: $PR_BRANCH"
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
# Determine branch based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
EVAL_BRANCH="${{ github.head_ref }}"
echo "PR Branch: $EVAL_BRANCH"
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_BRANCH="${{ github.event.inputs.branch }}"
echo "Manual Branch: $EVAL_BRANCH"
else
# For release events, use the tag name or main branch
EVAL_BRANCH="${{ github.ref_name }}"
echo "Release Branch/Tag: $EVAL_BRANCH"
fi
# Determine evaluation instances based on trigger type
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
if [[ "${{ github.event.label.name }}" == "run-eval-1" ]]; then
EVAL_INSTANCES="1"
elif [[ "${{ github.event.label.name }}" == "run-eval-2" ]]; then
EVAL_INSTANCES="2"
elif [[ "${{ github.event.label.name }}" == "run-eval-50" ]]; then
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
EVAL_INSTANCES="${{ github.event.inputs.eval_instances }}"
else
# For release events, default to 50 instances
EVAL_INSTANCES="50"
elif [[ "${{ github.event.label.name }}" == "run-eval-100" ]]; then
EVAL_INSTANCES="100"
fi
echo "Evaluation instances: $EVAL_INSTANCES"
echo "repo_url=$REPO_URL" >> $GITHUB_OUTPUT
echo "eval_branch=$EVAL_BRANCH" >> $GITHUB_OUTPUT
echo "eval_instances=$EVAL_INSTANCES" >> $GITHUB_OUTPUT
- name: Trigger remote job
run: |
# Determine PR number for the remote evaluation system
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
# For non-PR triggers, use the master issue number as PR number
PR_NUMBER="${{ env.MASTER_EVAL_ISSUE_NUMBER }}"
fi
curl -X POST \
-H "Authorization: Bearer ${{ secrets.PAT_TOKEN }}" \
-H "Accept: application/vnd.github+json" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${REPO_URL}\", \"github-branch\": \"${PR_BRANCH}\", \"pr-number\": \"${{ github.event.pull_request.number }}\", \"eval-instances\": \"${EVAL_INSTANCES}\"}}" \
-d "{\"ref\": \"main\", \"inputs\": {\"github-repo\": \"${{ steps.eval_params.outputs.repo_url }}\", \"github-branch\": \"${{ steps.eval_params.outputs.eval_branch }}\", \"pr-number\": \"${PR_NUMBER}\", \"eval-instances\": \"${{ steps.eval_params.outputs.eval_instances }}\"}}" \
https://api.github.com/repos/All-Hands-AI/evaluation/actions/workflows/create-branch.yml/dispatches
# Send Slack message
PR_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $PR_URL has triggered evaluation on $EVAL_INSTANCES instances..."
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
slack_text="PR $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
elif [[ "${{ github.event_name }}" == "release" ]]; then
TRIGGER_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.ref_name }}"
slack_text="Release $TRIGGER_URL has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances..."
else
TRIGGER_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
slack_text="Manual trigger (${{ github.event.inputs.reason || 'No reason provided' }}) has triggered evaluation on ${{ steps.eval_params.outputs.eval_instances }} instances for branch ${{ steps.eval_params.outputs.eval_branch }}..."
fi
curl -X POST -H 'Content-type: application/json' --data '{"text":"'"$slack_text"'"}' \
https://hooks.slack.com/services/${{ secrets.SLACK_TOKEN }}
- name: Comment on PR
- name: Comment on issue/PR
uses: KeisukeYamashita/create-comment@v1
with:
# For PR triggers, comment on the PR. For other triggers, comment on the master issue
number: ${{ github.event_name == 'pull_request' && github.event.pull_request.number || env.MASTER_EVAL_ISSUE_NUMBER }}
unique: false
comment: |
Running evaluation on the PR. Once eval is done, the results will be posted.
**Evaluation Triggered**
**Trigger:** ${{ github.event_name == 'pull_request' && format('Pull Request #{0}', github.event.pull_request.number) || (github.event_name == 'release' && 'Release') || format('Manual Trigger: {0}', github.event.inputs.reason || 'No reason provided') }}
**Branch:** ${{ steps.eval_params.outputs.eval_branch }}
**Instances:** ${{ steps.eval_params.outputs.eval_instances }}
**Commit:** ${{ github.sha }}
Running evaluation on the specified branch. Once eval is done, the results will be posted here.
+13 -10
View File
@@ -1,29 +1,32 @@
#!/bin/bash
echo "Running OpenHands pre-commit hook..."
echo "This hook runs 'make lint' to ensure code quality before committing."
# Store the exit code to return at the end
# This allows us to be additive to existing pre-commit hooks
EXIT_CODE=0
# Run make lint to check both frontend and backend code
echo "Running linting checks with 'make lint'..."
make lint
if [ $? -ne 0 ]; then
echo "Linting failed. Please fix the issues before committing."
EXIT_CODE=1
else
echo "Linting checks passed!"
fi
# Check if frontend directory has changed
frontend_changes=$(git diff --cached --name-only | grep "^frontend/")
if [ -n "$frontend_changes" ]; then
echo "Frontend changes detected. Running frontend checks..."
echo "Frontend changes detected. Running additional frontend checks..."
# Check if frontend directory exists
if [ -d "frontend" ]; then
# Change to frontend directory
cd frontend || exit 1
# Run lint:fix
echo "Running npm lint:fix..."
npm run lint:fix
if [ $? -ne 0 ]; then
echo "Frontend linting failed. Please fix the issues before committing."
EXIT_CODE=1
fi
# Run build
echo "Running npm build..."
npm run build
@@ -50,7 +53,7 @@ if [ -n "$frontend_changes" ]; then
echo "Frontend directory not found. Skipping frontend checks."
fi
else
echo "No frontend changes detected. Skipping frontend checks."
echo "No frontend changes detected. Skipping additional frontend checks."
fi
# Run any existing pre-commit hooks that might have been installed by the user
+1 -2
View File
@@ -51,8 +51,7 @@ 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.
+28 -1
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.
set up, it will allow OpenHands to work with your GitLab repository through the Cloud UI or straight from GitLab!.
---
## Prerequisites
@@ -25,6 +25,33 @@ 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).
@@ -117,6 +117,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -345,6 +345,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -226,6 +226,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -203,6 +203,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -164,6 +164,7 @@ def get_config(
default_agent=metadata.agent_class,
run_as_openhands=False,
max_iterations=metadata.max_iterations,
enable_browser=RUN_WITH_BROWSING,
runtime=os.environ.get('RUNTIME', 'docker'),
sandbox=sandbox_config,
# do not mount workspace
@@ -129,4 +129,159 @@ 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")
);
});
});
@@ -19,7 +19,13 @@ describe("AuthModal", () => {
});
it("should render the GitHub and GitLab buttons", () => {
render(<AuthModal githubAuthUrl="mock-url" appMode="saas" />);
render(
<AuthModal
githubAuthUrl="mock-url"
appMode="saas"
providersConfigured={["github", "gitlab"]}
/>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -35,7 +41,13 @@ describe("AuthModal", () => {
it("should redirect to GitHub auth URL when GitHub button is clicked", async () => {
const user = userEvent.setup();
const mockUrl = "https://github.com/login/oauth/authorize";
render(<AuthModal githubAuthUrl={mockUrl} appMode="saas" />);
render(
<AuthModal
githubAuthUrl={mockUrl}
appMode="saas"
providersConfigured={["github"]}
/>,
);
const githubButton = screen.getByRole("button", {
name: "GITHUB$CONNECT_TO_GITHUB",
@@ -52,7 +64,6 @@ describe("AuthModal", () => {
const termsSection = screen.getByTestId("auth-modal-terms-of-service");
expect(termsSection).toBeInTheDocument();
// Check that all text content is present in the paragraph
expect(termsSection).toHaveTextContent(
"AUTH$BY_SIGNING_UP_YOU_AGREE_TO_OUR",
@@ -16,8 +16,6 @@ import { ConversationCard } from "#/components/features/conversation-panel/conve
import { clickOnEditButton } from "./utils";
// We'll use the actual i18next implementation but override the translation function
import { I18nextProvider } from "react-i18next";
import i18n from "i18next";
// Mock the t function to return our custom translations
vi.mock("react-i18next", async () => {
@@ -124,7 +122,8 @@ describe("ConversationCard", () => {
it("should toggle a context menu when clicking the ellipsis button", async () => {
const user = userEvent.setup();
renderWithProviders(
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
@@ -132,6 +131,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen={false}
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -140,15 +141,32 @@ describe("ConversationCard", () => {
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
expect(onContextMenuToggle).toHaveBeenCalledWith(true);
// Simulate context menu being opened by parent
rerender(
<ConversationCard
onDelete={onDelete}
onChangeTitle={onChangeTitle}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
screen.getByTestId("context-menu");
await user.click(ellipsisButton);
expect(screen.queryByTestId("context-menu")).not.toBeInTheDocument();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
it("should call onDelete when the delete button is clicked", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -157,18 +175,18 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
await user.click(deleteButton);
expect(onDelete).toHaveBeenCalled();
expect(onContextMenuToggle).toHaveBeenCalledWith(false);
});
test("clicking the selectedRepository should not trigger the onClick handler", async () => {
@@ -198,7 +216,11 @@ describe("ConversationCard", () => {
test("conversation title should call onChangeTitle when changed and blurred", async () => {
const user = userEvent.setup();
renderWithProviders(
let menuOpen = true;
const onContextMenuToggle = vi.fn((isOpen: boolean) => {
menuOpen = isOpen;
});
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
isActive
@@ -206,10 +228,27 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
await clickOnEditButton(user);
// Re-render with updated state
rerender(
<ConversationCard
onDelete={onDelete}
isActive
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
onChangeTitle={onChangeTitle}
contextMenuOpen={menuOpen}
onContextMenuToggle={onContextMenuToggle}
/>,
);
const title = screen.getByTestId("conversation-card-title");
expect(title).toBeEnabled();
@@ -227,6 +266,7 @@ describe("ConversationCard", () => {
it("should reset title and not call onChangeTitle when the title is empty", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -235,6 +275,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -271,6 +313,7 @@ describe("ConversationCard", () => {
test("clicking the title should not trigger the onClick handler if edit mode", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -279,6 +322,8 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
@@ -292,6 +337,7 @@ describe("ConversationCard", () => {
test("clicking the delete button should not trigger the onClick handler", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -300,12 +346,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const deleteButton = within(menu).getByTestId("delete-button");
@@ -315,7 +360,7 @@ describe("ConversationCard", () => {
});
it("should show display cost button only when showOptions is true", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -324,21 +369,17 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
// Wait for context menu to appear
const menu = await screen.findByTestId("context-menu");
expect(
within(menu).queryByTestId("display-cost-button"),
).not.toBeInTheDocument();
// Close menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onDelete={onDelete}
@@ -348,12 +389,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
// Open menu again
await user.click(ellipsisButton);
// Wait for context menu to appear and check for display cost button
const newMenu = await screen.findByTestId("context-menu");
within(newMenu).getByTestId("display-cost-button");
@@ -361,6 +401,7 @@ describe("ConversationCard", () => {
it("should show metrics modal when clicking the display cost button", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
renderWithProviders(
<ConversationCard
onDelete={onDelete}
@@ -370,12 +411,11 @@ describe("ConversationCard", () => {
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
showOptions
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const displayCostButton = within(menu).getByTestId("display-cost-button");
@@ -386,7 +426,7 @@ describe("ConversationCard", () => {
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
const onContextMenuToggle = vi.fn();
const { rerender } = renderWithProviders(
<ConversationCard
onClick={onClick}
@@ -394,19 +434,15 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = await screen.findByTestId("context-menu");
expect(within(menu).queryByTestId("edit-button")).toBeInTheDocument();
expect(within(menu).queryByTestId("delete-button")).not.toBeInTheDocument();
// toggle to hide the context menu
await user.click(ellipsisButton);
rerender(
<ConversationCard
onClick={onClick}
@@ -414,10 +450,11 @@ describe("ConversationCard", () => {
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
contextMenuOpen
onContextMenuToggle={onContextMenuToggle}
/>,
);
await user.click(ellipsisButton);
const newMenu = await screen.findByTestId("context-menu");
expect(
within(newMenu).queryByTestId("edit-button"),
+20 -24
View File
@@ -34,7 +34,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.0",
"posthog-js": "^1.257.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -68,7 +68,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.14",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -82,11 +82,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
@@ -6160,9 +6160,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "24.0.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz",
"integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==",
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"devOptional": true,
"dependencies": {
"undici-types": "~7.8.0"
@@ -9017,11 +9017,10 @@
}
},
"node_modules/eslint-config-prettier": {
"version": "10.1.5",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
"integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
"version": "10.1.8",
"resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
"integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
"eslint-config-prettier": "bin/cli.js"
},
@@ -9083,11 +9082,10 @@
}
},
"node_modules/eslint-plugin-i18next": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.2.tgz",
"integrity": "sha512-hvTmws4kouNHkk314+9MHNj+RQmsqrkejWhTXGlRC0j8H+EXq2qDRLe6UqIjrFZo7/ogyd4btuqsnKCBi8wHbw==",
"version": "6.1.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-i18next/-/eslint-plugin-i18next-6.1.3.tgz",
"integrity": "sha512-z/h4oBRd9wI1ET60HqcLSU6XPeAh/EPOrBBTyCdkWeMoYrWAaUVA+DOQkWTiNIyCltG4NTmy62SQisVXxoXurw==",
"dev": true,
"license": "ISC",
"dependencies": {
"lodash": "^4.17.21",
"requireindex": "~1.1.0"
@@ -9252,11 +9250,10 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz",
"integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==",
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz",
"integrity": "sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.0",
"synckit": "^0.11.7"
@@ -14268,10 +14265,9 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.257.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.0.tgz",
"integrity": "sha512-Ujg9RGtWVCu+4tmlRpALSy2ZOZI6JtieSYXIDDdgMWm167KYKvTtbMPHdoBaPWcNu0Km+1hAIBnQFygyn30KhA==",
"license": "SEE LICENSE IN LICENSE",
"version": "1.257.1",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.257.1.tgz",
"integrity": "sha512-29kk3IO/LkPQ8E1cds6a2sWr5iN4BovgL+EMzRK9hQXbI6D3FJnQ7zLU6EUpktt6pHnqGpfO3BTEcflcDYkHBg==",
"dependencies": {
"core-js": "^3.38.1",
"fflate": "^0.4.8",
+5 -5
View File
@@ -33,7 +33,7 @@
"jose": "^6.0.12",
"lucide-react": "^0.525.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.257.0",
"posthog-js": "^1.257.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-highlight": "^0.15.0",
@@ -92,7 +92,7 @@
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.0.14",
"@types/node": "^24.1.0",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react-highlight": "^0.12.8",
@@ -106,11 +106,11 @@
"eslint": "^8.57.0",
"eslint-config-airbnb": "^19.0.4",
"eslint-config-airbnb-typescript": "^18.0.0",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-i18next": "^6.1.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-i18next": "^6.1.3",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-prettier": "^5.5.1",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-unused-imports": "^4.1.4",
+42
View File
@@ -13,11 +13,13 @@ import {
GitChange,
GetMicroagentsResponse,
GetMicroagentPromptResponse,
CreateMicroagent,
} from "./open-hands.types";
import { openHands } from "./open-hands-axios";
import { ApiSettings, PostApiSettings, Provider } from "#/types/settings";
import { GitUser, GitRepository, Branch } from "#/types/git";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { RepositoryMicroagent } from "#/types/microagent-management";
class OpenHands {
private static currentConversation: Conversation | null = null;
@@ -250,6 +252,28 @@ class OpenHands {
return data.results;
}
static async searchConversations(
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
): Promise<Conversation[]> {
const params = new URLSearchParams();
params.append("limit", limit.toString());
if (selectedRepository) {
params.append("selected_repository", selectedRepository);
}
if (conversationTrigger) {
params.append("conversation_trigger", conversationTrigger);
}
const { data } = await openHands.get<ResultSet<Conversation>>(
`/api/conversations?${params.toString()}`,
);
return data.results;
}
static async deleteUserConversation(conversationId: string): Promise<void> {
await openHands.delete(`/api/conversations/${conversationId}`);
}
@@ -261,6 +285,7 @@ class OpenHands {
suggested_task?: SuggestedTask,
selected_branch?: string,
conversationInstructions?: string,
createMicroagent?: CreateMicroagent,
): Promise<Conversation> {
const body = {
repository: selectedRepository,
@@ -269,6 +294,7 @@ class OpenHands {
initial_user_msg: initialUserMsg,
suggested_task,
conversation_instructions: conversationInstructions,
create_microagent: createMicroagent,
};
const { data } = await openHands.post<Conversation>(
@@ -464,6 +490,22 @@ class OpenHands {
return data;
}
/**
* Get the available microagents for a specific repository
* @param owner The repository owner
* @param repo The repository name
* @returns The available microagents for the repository
*/
static async getRepositoryMicroagents(
owner: string,
repo: string,
): Promise<RepositoryMicroagent[]> {
const { data } = await openHands.get<RepositoryMicroagent[]>(
`/api/user/repository/${owner}/${repo}/microagents`,
);
return data;
}
static async getMicroagentPrompt(
conversationId: string,
eventId: number,
+12 -1
View File
@@ -79,7 +79,11 @@ export interface RepositorySelection {
git_provider: Provider | null;
}
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
export type ConversationTrigger =
| "resolver"
| "gui"
| "suggested_task"
| "microagent_management";
export interface Conversation {
conversation_id: string;
@@ -94,6 +98,7 @@ export interface Conversation {
trigger?: ConversationTrigger;
url: string | null;
session_api_key: string | null;
pr_number?: number[] | null;
}
export interface ResultSet<T> {
@@ -133,3 +138,9 @@ export interface GetMicroagentPromptResponse {
status: string;
prompt: string;
}
export interface CreateMicroagent {
repo: string;
git_provider?: Provider;
title?: string;
}
@@ -19,8 +19,11 @@ export function ActionSuggestions({
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const providersAreSet = providers.length > 0;
const isGitLab = providers.includes("gitlab");
const isBitbucket = providers.includes("bitbucket");
// 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 pr = isGitLab ? "merge request" : "pull request";
const prShort = isGitLab ? "MR" : "PR";
@@ -13,6 +13,7 @@ interface ControlsProps {
export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
const { data: conversation } = useActiveConversation();
const [contextMenuOpen, setContextMenuOpen] = React.useState(false);
return (
<div className="flex flex-col gap-2 md:items-center md:justify-between md:flex-row">
@@ -37,6 +38,8 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
}}
conversationStatus={conversation?.status}
conversationId={conversation?.conversation_id}
contextMenuOpen={contextMenuOpen}
onContextMenuToggle={setContextMenuOpen}
/>
</div>
);
@@ -35,6 +35,8 @@ interface ConversationCardProps {
conversationStatus?: ConversationStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
contextMenuOpen?: boolean;
onContextMenuToggle?: (isOpen: boolean) => void;
}
const MAX_TIME_BETWEEN_CREATION_AND_UPDATE = 1000 * 60 * 30; // 30 minutes
@@ -55,10 +57,11 @@ export function ConversationCard({
conversationStatus = "STOPPED",
variant = "default",
conversationId,
contextMenuOpen = false,
onContextMenuToggle,
}: ConversationCardProps) {
const { t } = useTranslation();
const { parsedEvents } = useWsClient();
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const [metricsModalVisible, setMetricsModalVisible] = React.useState(false);
const [systemModalVisible, setSystemModalVisible] = React.useState(false);
@@ -101,21 +104,21 @@ export function ConversationCard({
event.preventDefault();
event.stopPropagation();
onDelete?.();
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleStop = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
onStop?.();
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleEdit = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
event.stopPropagation();
setTitleMode("edit");
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleDownloadViaVSCode = async (
@@ -141,7 +144,7 @@ export function ConversationCard({
}
}
setContextMenuVisible(false);
onContextMenuToggle?.(false);
};
const handleDisplayCost = (event: React.MouseEvent<HTMLButtonElement>) => {
@@ -224,15 +227,15 @@ export function ConversationCard({
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
setContextMenuVisible((prev) => !prev);
onContextMenuToggle?.(!contextMenuOpen);
}}
/>
</div>
)}
<div className="relative">
{contextMenuVisible && (
{contextMenuOpen && (
<ConversationCardContextMenu
onClose={() => setContextMenuVisible(false)}
onClose={() => onContextMenuToggle?.(false)}
onDelete={onDelete && handleDelete}
onStop={
conversationStatus !== "STOPPED"
@@ -36,6 +36,9 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
const [selectedConversationId, setSelectedConversationId] = React.useState<
string | null
>(null);
const [openContextMenuId, setOpenContextMenuId] = React.useState<
string | null
>(null);
const { data: conversations, isFetching, error } = useUserConversations();
@@ -144,6 +147,10 @@ export function ConversationPanel({ onClose }: ConversationPanelProps) {
createdAt={project.created_at}
conversationStatus={project.status}
conversationId={project.conversation_id}
contextMenuOpen={openContextMenuId === project.conversation_id}
onContextMenuToggle={(isOpen) =>
setOpenContextMenuId(isOpen ? project.conversation_id : null)
}
/>
)}
</NavLink>
@@ -1,7 +1,8 @@
import React from "react";
import React, { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SettingsDropdownInput } from "../../settings/settings-dropdown-input";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
export interface BranchDropdownProps {
items: { key: React.Key; label: string }[];
@@ -9,6 +10,8 @@ export interface BranchDropdownProps {
onInputChange: (value: string) => void;
isDisabled: boolean;
selectedKey?: string;
wrapperClassName?: string;
label?: ReactNode;
}
export function BranchDropdown({
@@ -17,6 +20,8 @@ export function BranchDropdown({
onInputChange,
isDisabled,
selectedKey,
wrapperClassName,
label,
}: BranchDropdownProps) {
const { t } = useTranslation();
@@ -26,11 +31,12 @@ export function BranchDropdown({
name="branch-dropdown"
placeholder={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
items={items}
wrapperClassName="max-w-[500px]"
wrapperClassName={cn("max-w-[500px]", wrapperClassName)}
onSelectionChange={onSelectionChange}
onInputChange={onInputChange}
isDisabled={isDisabled}
selectedKey={selectedKey}
label={label}
/>
);
}
@@ -1,12 +1,19 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
export function BranchErrorState() {
interface BranchErrorStateProps {
wrapperClassName?: string;
}
export function BranchErrorState({ wrapperClassName }: BranchErrorStateProps) {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-error"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm text-red-500",
wrapperClassName,
)}
>
<span className="text-sm">{t("HOME$FAILED_TO_LOAD_BRANCHES")}</span>
</div>
@@ -1,13 +1,22 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Spinner } from "@heroui/react";
import { cn } from "#/utils/utils";
export function BranchLoadingState() {
interface BranchLoadingStateProps {
wrapperClassName?: string;
}
export function BranchLoadingState({
wrapperClassName,
}: BranchLoadingStateProps) {
const { t } = useTranslation();
return (
<div
data-testid="branch-dropdown-loading"
className="flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm"
className={cn(
"flex items-center gap-2 max-w-[500px] h-10 px-3 bg-tertiary border border-[#717888] rounded-sm",
wrapperClassName,
)}
>
<Spinner size="sm" />
<span className="text-sm">{t("HOME$LOADING_BRANCHES")}</span>
@@ -0,0 +1,26 @@
import { GitProviderIcon } from "#/components/shared/git-provider-icon";
import { GitRepository } from "#/types/git";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
interface MicroagentManagementAccordionTitleProps {
repository: GitRepository;
}
export function MicroagentManagementAccordionTitle({
repository,
}: MicroagentManagementAccordionTitleProps) {
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitProviderIcon gitProvider={repository.git_provider} />
<div
className="text-white text-base font-normal truncate max-w-[150px]"
title={repository.full_name}
>
{repository.full_name}
</div>
</div>
<MicroagentManagementAddMicroagentButton repository={repository} />
</div>
);
}
@@ -1,10 +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 {
setAddMicroagentModalVisible,
setSelectedRepository,
} from "#/state/microagent-management-slice";
import { RootState } from "#/store";
import { GitRepository } from "#/types/git";
export function MicroagentManagementAddMicroagentButton() {
interface MicroagentManagementAddMicroagentButtonProps {
repository: GitRepository;
}
export function MicroagentManagementAddMicroagentButton({
repository,
}: MicroagentManagementAddMicroagentButtonProps) {
const { t } = useTranslation();
const { addMicroagentModalVisible } = useSelector(
@@ -13,8 +23,10 @@ export function MicroagentManagementAddMicroagentButton() {
const dispatch = useDispatch();
const handleClick = () => {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
dispatch(setSelectedRepository(repository));
};
return (
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSelector } from "react-redux";
import { FaCircleInfo } from "react-icons/fa6";
@@ -10,30 +10,155 @@ import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { BadgeInput } from "#/components/shared/inputs/badge-input";
import { MicroagentFormData } from "#/types/microagent-management";
import { Branch, GitRepository } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
import {
BranchDropdown,
BranchLoadingState,
BranchErrorState,
} from "../home/repository-selection";
interface MicroagentManagementAddMicroagentModalProps {
onConfirm: () => void;
onConfirm: (formData: MicroagentFormData) => void;
onCancel: () => void;
isLoading: boolean;
}
export function MicroagentManagementAddMicroagentModal({
onConfirm,
onCancel,
isLoading = false,
}: MicroagentManagementAddMicroagentModalProps) {
const { t } = useTranslation();
const [triggers, setTriggers] = useState<string[]>([]);
const [query, setQuery] = useState<string>("");
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const { selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
// Add a ref to track if the branch was manually cleared by the user
const branchManuallyClearedRef = useRef<boolean>(false);
const {
data: branches,
isLoading: isLoadingBranches,
isError: isBranchesError,
} = useRepositoryBranches(selectedRepository?.full_name || null);
const branchesItems = branches?.map((branch) => ({
key: branch.name,
label: branch.name,
}));
// Auto-select main or master branch if it exists.
useEffect(() => {
if (
branches &&
branches.length > 0 &&
!selectedBranch &&
!isLoadingBranches
) {
// Look for main or master branch
const mainBranch = branches.find((branch) => branch.name === "main");
const masterBranch = branches.find((branch) => branch.name === "master");
// Select main if it exists, otherwise select master if it exists
if (mainBranch) {
setSelectedBranch(mainBranch);
} else if (masterBranch) {
setSelectedBranch(masterBranch);
}
}
}, [branches, isLoadingBranches, selectedBranch]);
const modalTitle = selectedRepository
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${selectedRepository}`
? `${t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT_TO)} ${(selectedRepository as GitRepository).full_name}`
: t(I18nKey.MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT);
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
onConfirm({
query: query.trim(),
triggers,
selectedBranch: selectedBranch?.name || "",
});
};
const handleBranchSelection = (key: React.Key | null) => {
const selectedBranchObj = branches?.find((branch) => branch.name === key);
setSelectedBranch(selectedBranchObj || null);
// Reset the manually cleared flag when a branch is explicitly selected
branchManuallyClearedRef.current = false;
};
const handleBranchInputChange = (value: string) => {
// Clear the selected branch if the input is empty or contains only whitespace
// This fixes the issue where users can't delete the entire default branch name
if (value === "" || value.trim() === "") {
setSelectedBranch(null);
// Set the flag to indicate that the branch was manually cleared
branchManuallyClearedRef.current = true;
} else {
// Reset the flag when the user starts typing again
branchManuallyClearedRef.current = false;
}
};
// Render the appropriate UI for branch selector based on the loading/error state
const renderBranchSelector = () => {
if (!selectedRepository) {
return (
<BranchDropdown
items={[]}
onSelectionChange={() => {}}
onInputChange={() => {}}
isDisabled
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
}
if (isLoadingBranches) {
return <BranchLoadingState wrapperClassName="max-w-full w-full" />;
}
if (isBranchesError) {
return <BranchErrorState wrapperClassName="max-w-full w-full" />;
}
return (
<BranchDropdown
items={branchesItems || []}
onSelectionChange={handleBranchSelection}
onInputChange={handleBranchInputChange}
isDisabled={false}
selectedKey={selectedBranch?.name}
wrapperClassName="max-w-full w-full"
label={t(I18nKey.REPOSITORY$SELECT_BRANCH)}
/>
);
};
return (
@@ -64,6 +189,7 @@ export function MicroagentManagementAddMicroagentModal({
onSubmit={onSubmit}
className="flex flex-col gap-6 w-full"
>
{renderBranchSelector()}
<label
htmlFor="query-input"
className="flex flex-col gap-2 w-full text-sm font-normal"
@@ -73,6 +199,8 @@ export function MicroagentManagementAddMicroagentModal({
required
data-testid="query-input"
name="query-input"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t(I18nKey.MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO)}
rows={6}
className={cn(
@@ -80,19 +208,6 @@ export function MicroagentManagementAddMicroagentModal({
"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"
@@ -129,17 +244,26 @@ export function MicroagentManagementAddMicroagentModal({
type="button"
variant="secondary"
onClick={onCancel}
data-testid="cancel-button"
testId="cancel-button"
>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton
type="button"
variant="primary"
onClick={onConfirm}
data-testid="confirm-button"
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
isBranchesError
}
>
{t(I18nKey.MICROAGENT$LAUNCH)}
{isLoading || isLoadingBranches
? t(I18nKey.HOME$LOADING)
: t(I18nKey.MICROAGENT$LAUNCH)}
</BrandButton>
</div>
</ModalBody>
@@ -0,0 +1,193 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { MicroagentManagementSidebar } from "./microagent-management-sidebar";
import { MicroagentManagementMain } from "./microagent-management-main";
import { MicroagentManagementAddMicroagentModal } from "./microagent-management-add-microagent-modal";
import { RootState } from "#/store";
import { setAddMicroagentModalVisible } from "#/state/microagent-management-slice";
import { useCreateConversationAndSubscribeMultiple } from "#/hooks/use-create-conversation-and-subscribe-multiple";
import { MicroagentFormData } from "#/types/microagent-management";
import { AgentState } from "#/types/agent-state";
import { getPR, getProviderName, getPRShort } from "#/utils/utils";
import {
isOpenHandsEvent,
isAgentStateChangeObservation,
isFinishAction,
} from "#/types/core/guards";
import { GitRepository } from "#/types/git";
import { queryClient } from "#/query-client-config";
import { Provider } from "#/types/settings";
// Handle error events
const isErrorEvent = (evt: unknown): evt is { error: true; message: string } =>
typeof evt === "object" &&
evt !== null &&
"error" in evt &&
evt.error === true;
const isAgentStatusError = (evt: unknown): boolean =>
isOpenHandsEvent(evt) &&
isAgentStateChangeObservation(evt) &&
evt.extras.agent_state === AgentState.ERROR;
const shouldInvalidateConversationsList = (currentSocketEvent: unknown) => {
const hasError =
isErrorEvent(currentSocketEvent) || isAgentStatusError(currentSocketEvent);
const hasStateChanged =
isOpenHandsEvent(currentSocketEvent) &&
isAgentStateChangeObservation(currentSocketEvent);
const hasFinished =
isOpenHandsEvent(currentSocketEvent) && isFinishAction(currentSocketEvent);
return hasError || hasStateChanged || hasFinished;
};
const getConversationInstructions = (
repositoryName: string,
formData: MicroagentFormData,
pr: string,
prShort: string,
gitProvider: Provider,
) => `Create a microagent for the repository ${repositoryName} by following the steps below:
- Step 1: Create a markdown file inside the .openhands/microagents folder with the name of the microagent (The microagent must be created in the .openhands/microagents folder and should be able to perform the described task when triggered).
- Step 2: Update the markdown file with the content below:
${
formData.triggers &&
formData.triggers.length > 0 &&
`
---
triggers:
${formData.triggers.map((trigger: string) => ` - ${trigger}`).join("\n")}
---
`
}
${formData.query}
- Step 3: Create a new branch for the repository ${repositoryName}, must avoid duplicated branches.
- Step 4: Please push the changes to your branch on ${getProviderName(gitProvider)} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.
`;
export function MicroagentManagementContent() {
const { addMicroagentModalVisible, selectedRepository } = useSelector(
(state: RootState) => state.microagentManagement,
);
const dispatch = useDispatch();
const { createConversationAndSubscribe, isPending } =
useCreateConversationAndSubscribeMultiple();
const hideAddMicroagentModal = () => {
dispatch(setAddMicroagentModalVisible(false));
};
// Reusable function to invalidate conversations list for a repository
const invalidateConversationsList = React.useCallback(
(repositoryName: string) => {
queryClient.invalidateQueries({
queryKey: [
"conversations",
"search",
repositoryName,
"microagent_management",
],
});
},
[],
);
const handleMicroagentEvent = React.useCallback(
(socketEvent: unknown) => {
// Get repository name from selectedRepository for invalidation
const repositoryName =
selectedRepository && typeof selectedRepository === "object"
? (selectedRepository as GitRepository).full_name
: "";
if (shouldInvalidateConversationsList(socketEvent)) {
invalidateConversationsList(repositoryName);
}
},
[invalidateConversationsList, selectedRepository],
);
const handleCreateMicroagent = (formData: MicroagentFormData) => {
if (!selectedRepository || typeof selectedRepository !== "object") {
return;
}
// Use the GitRepository properties
const repository = selectedRepository as GitRepository;
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const isGitLab = gitProvider === "gitlab";
const pr = getPR(isGitLab);
const prShort = getPRShort(isGitLab);
// Create conversation instructions for microagent generation
const conversationInstructions = getConversationInstructions(
repositoryName,
formData,
pr,
prShort,
gitProvider,
);
// Create the CreateMicroagent object
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
createConversationAndSubscribe({
query: conversationInstructions,
conversationInstructions,
repository: {
name: repositoryName,
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideAddMicroagentModal();
// Invalidate conversations list to fetch the latest conversations for this repository
invalidateConversationsList(repositoryName);
// Also invalidate microagents list to fetch the latest microagents
// Extract owner and repo from full_name (format: "owner/repo")
const [owner, repo] = repositoryName.split("/");
queryClient.invalidateQueries({
queryKey: ["repository-microagents", owner, repo],
});
hideAddMicroagentModal();
},
onEventCallback: (event: unknown) => {
// Handle conversation events for real-time status updates
handleMicroagentEvent(event);
},
});
};
return (
<div className="w-full h-full flex rounded-lg border border-[#525252] bg-[#24272E]">
<MicroagentManagementSidebar />
<MicroagentManagementMain />
{addMicroagentModalVisible && (
<MicroagentManagementAddMicroagentModal
onConfirm={handleCreateMicroagent}
onCancel={hideAddMicroagentModal}
isLoading={isPending}
/>
)}
</div>
);
}
@@ -1,32 +1,80 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export interface Microagent {
id: string;
name: string;
repositoryUrl: string;
createdAt: string;
}
import { formatDateMMDDYYYY } from "#/utils/format-time-delta";
import { ConversationStatus } from "#/types/conversation-status";
import { RuntimeStatus } from "#/types/runtime-status";
interface MicroagentManagementMicroagentCardProps {
microagent: Microagent;
microagent: {
id: string;
name: string;
createdAt: string;
conversationStatus?: ConversationStatus;
runtimeStatus?: RuntimeStatus;
prNumber?: number[] | null;
};
showMicroagentFilePath?: boolean;
}
export function MicroagentManagementMicroagentCard({
microagent,
showMicroagentFilePath = true,
}: MicroagentManagementMicroagentCardProps) {
const { t } = useTranslation();
const { conversationStatus, runtimeStatus, prNumber } = microagent;
// Format the repository URL to point to the microagent file
const microagentFilePath = `.openhands/microagents/${microagent.name}`;
// Format the createdAt date using MM/DD/YYYY format
const formattedCreatedAt = formatDateMMDDYYYY(new Date(microagent.createdAt));
const hasPr = prNumber && prNumber.length > 0;
// Helper function to get status text
const statusText = useMemo(() => {
if (hasPr) {
return t(I18nKey.COMMON$READY_FOR_REVIEW);
}
if (
conversationStatus === "STOPPED" ||
runtimeStatus === "STATUS$STOPPED"
) {
return t(I18nKey.COMMON$STOPPED);
}
if (runtimeStatus === "STATUS$ERROR") {
return t(I18nKey.MICROAGENT$STATUS_ERROR);
}
if (
(conversationStatus === "STARTING" || conversationStatus === "RUNNING") &&
runtimeStatus === "STATUS$READY"
) {
return t(I18nKey.MICROAGENT$STATUS_OPENING_PR);
}
return "";
}, [conversationStatus, runtimeStatus, t, hasPr]);
return (
<div className="rounded-lg bg-[#ffffff0d] border border-[#ffffff33] p-4 cursor-pointer hover:bg-[#ffffff33] hover:border-[#C9B974] transition-all duration-300">
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
<div className="text-white text-sm font-normal">
{microagent.repositoryUrl}
</div>
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {microagent.createdAt}
<div className="flex flex-col items-start gap-2">
{statusText && (
<div className="px-[6px] py-[2px] text-[11px] font-medium bg-[#C9B97433] text-white rounded-2xl">
{statusText}
</div>
)}
<div className="text-white text-[16px] font-semibold">
{microagent.name}
</div>
{showMicroagentFilePath && (
<div className="text-white text-sm font-normal">
{microagentFilePath}
</div>
)}
<div className="text-white text-sm font-normal">
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
</div>
</div>
</div>
);
@@ -1,38 +0,0 @@
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export function MicroagentManagementMicroagents() {
const microagents = [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
{
id: "tell-me-a-joke",
name: "Tell me a joke",
repositoryUrl: ".openhands/microagents/Repo Overview",
createdAt: "05/30/2025",
},
];
const numberOfMicroagents = microagents.length;
if (numberOfMicroagents === 0) {
return null;
}
return (
<div>
<div className="flex items-center justify-end pb-4">
<MicroagentManagementAddMicroagentButton />
</div>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</div>
);
}
@@ -0,0 +1,22 @@
import { FaCircleInfo } from "react-icons/fa6";
interface MicroagentManagementNoRepositoriesProps {
title: string;
documentationUrl: string;
}
export function MicroagentManagementNoRepositories({
title,
documentationUrl,
}: MicroagentManagementNoRepositoriesProps) {
return (
<div className="flex items-center justify-center pt-10">
<div className="flex items-center gap-2">
<h2 className="text-white text-sm font-medium">{title}</h2>
<a href={documentationUrl} target="_blank" rel="noopener noreferrer">
<FaCircleInfo className="text-primary" />
</a>
</div>
</div>
);
}
@@ -1,49 +0,0 @@
import {
Microagent,
MicroagentManagementMicroagentCard,
} from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { MicroagentManagementAddMicroagentButton } from "./microagent-management-add-microagent-button";
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
microagents: Microagent[];
}
interface MicroagentManagementRepoMicroagentProps {
repoMicroagent: RepoMicroagent;
}
export function MicroagentManagementRepoMicroagent({
repoMicroagent,
}: MicroagentManagementRepoMicroagentProps) {
const { microagents } = repoMicroagent;
const numberOfMicroagents = microagents.length;
return (
<div className="pb-12">
<div className="flex items-center justify-between pb-4">
<div className="text-white text-base font-normal">
{repoMicroagent.repositoryName}
</div>
<MicroagentManagementAddMicroagentButton />
</div>
{numberOfMicroagents === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{numberOfMicroagents > 0 && (
<>
{microagents.map((microagent) => (
<div key={microagent.id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard microagent={microagent} />
</div>
))}
</>
)}
</div>
);
}
@@ -1,42 +1,109 @@
import { MicroagentManagementRepoMicroagent } from "./microagent-management-repo-microagent";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export function MicroagentManagementRepoMicroagents() {
const repoMicroagents = [
{
id: "rbren/rss-parser",
repositoryName: "rbren/rss-parser",
repositoryUrl: "https://github.com/rbren/rss-parser",
microagents: [],
},
{
id: "fairwinds/polaris",
repositoryName: "fairwinds/polaris",
repositoryUrl: "https://github.com/fairwinds/polaris",
microagents: [
{
id: "no-comments",
name: "No comments",
repositoryUrl: "fairwinds/polaris/Repo Overview",
createdAt: "05/30/2025",
},
],
},
];
export interface RepoMicroagent {
id: string;
repositoryName: string;
repositoryUrl: string;
}
const numberOfRepoMicroagents = repoMicroagents.length;
interface MicroagentManagementRepoMicroagentsProps {
repoMicroagent: RepoMicroagent;
}
if (numberOfRepoMicroagents === 0) {
return null;
export function MicroagentManagementRepoMicroagents({
repoMicroagent,
}: MicroagentManagementRepoMicroagentsProps) {
// Extract owner and repo from repositoryName (format: "owner/repo")
const [owner, repo] = repoMicroagent.repositoryName.split("/");
const {
data: microagents,
isLoading: isLoadingMicroagents,
isError: isErrorMicroagents,
} = useRepositoryMicroagents(owner, repo);
const {
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(
repoMicroagent.repositoryName,
"microagent_management",
1000,
);
// Show loading only when both queries are loading
const isLoading = isLoadingMicroagents || isLoadingConversations;
// Show error UI.
const isError = isErrorMicroagents || isErrorConversations;
if (isLoading) {
return (
<div className="pb-4 flex justify-center">
<LoadingSpinner size="small" />
</div>
);
}
return (
<div>
{repoMicroagents.map((repoMicroagent) => (
<MicroagentManagementRepoMicroagent
key={repoMicroagent.id}
repoMicroagent={repoMicroagent}
// If there's an error with microagents, show the learn this repo component
if (isError) {
return (
<div className="pb-4">
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
))}
</div>
);
}
const numberOfMicroagents = microagents?.length || 0;
const numberOfConversations = conversations?.length || 0;
const totalItems = numberOfMicroagents + numberOfConversations;
return (
<div className="pb-4">
{totalItems === 0 && (
<MicroagentManagementLearnThisRepo
repositoryUrl={repoMicroagent.repositoryUrl}
/>
)}
{/* Render microagents */}
{numberOfMicroagents > 0 &&
microagents?.map((microagent) => (
<div key={microagent.name} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={{
id: microagent.name,
name: microagent.name,
createdAt: microagent.created_at,
}}
/>
</div>
))}
{/* Render conversations */}
{numberOfConversations > 0 &&
conversations?.map((conversation) => (
<div key={conversation.conversation_id} className="pb-4 last:pb-0">
<MicroagentManagementMicroagentCard
microagent={{
id: conversation.conversation_id,
name: conversation.title,
createdAt: conversation.created_at,
conversationStatus: conversation.status,
runtimeStatus: conversation.runtime_status || undefined,
prNumber: conversation.pr_number || undefined,
}}
showMicroagentFilePath={false}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,125 @@
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Accordion, AccordionItem } from "@heroui/react";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { GitRepository } from "#/types/git";
import { getGitProviderBaseUrl, cn } from "#/utils/utils";
import { TabType } from "#/types/microagent-management";
import { MicroagentManagementNoRepositories } from "./microagent-management-no-repositories";
import { I18nKey } from "#/i18n/declaration";
import { DOCUMENTATION_URL } from "#/utils/constants";
import { MicroagentManagementAccordionTitle } from "./microagent-management-accordion-title";
import { sanitizeQuery } from "#/utils/sanitize-query";
type MicroagentManagementRepositoriesProps = {
repositories: GitRepository[];
tabType: TabType;
};
export function MicroagentManagementRepositories({
repositories,
tabType,
}: MicroagentManagementRepositoriesProps) {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState("");
const numberOfRepoMicroagents = repositories.length;
// Filter repositories based on search query
const filteredRepositories = useMemo(() => {
if (!searchQuery.trim()) {
return repositories;
}
const sanitizedQuery = sanitizeQuery(searchQuery);
return repositories.filter((repository) => {
const sanitizedRepoName = sanitizeQuery(repository.full_name);
return sanitizedRepoName.includes(sanitizedQuery);
});
}, [repositories, searchQuery]);
if (numberOfRepoMicroagents === 0) {
if (tabType === "personal") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS,
)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "repositories") {
return (
<MicroagentManagementNoRepositories
title={t(I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS)}
documentationUrl={DOCUMENTATION_URL.MICROAGENTS.MICROAGENTS_OVERVIEW}
/>
);
}
if (tabType === "organizations") {
return (
<MicroagentManagementNoRepositories
title={t(
I18nKey.MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS,
)}
documentationUrl={
DOCUMENTATION_URL.MICROAGENTS.ORGANIZATION_AND_USER_MICROAGENTS
}
/>
);
}
}
return (
<div className="flex flex-col gap-4 w-full">
{/* Search Input */}
<div className="flex flex-col gap-2 w-full">
<label htmlFor="repository-search" className="sr-only">
{t(I18nKey.COMMON$SEARCH_REPOSITORIES)}
</label>
<input
id="repository-search"
name="repository-search"
type="text"
placeholder={`${t(I18nKey.COMMON$SEARCH_REPOSITORIES)}...`}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn(
"bg-tertiary border border-[#717888] bg-[#454545] w-full rounded-sm p-2 placeholder:italic placeholder:text-tertiary-alt",
"disabled:bg-[#2D2F36] disabled:border-[#2D2F36] disabled:cursor-not-allowed",
)}
/>
</div>
{/* Repositories Accordion */}
<Accordion
variant="splitted"
className="w-full px-0 gap-3"
itemClasses={{
base: "shadow-none bg-transparent border border-[#ffffff40] rounded-[6px] cursor-pointer",
trigger: "cursor-pointer",
}}
selectionMode="multiple"
>
{filteredRepositories.map((repository) => (
<AccordionItem
key={repository.id}
aria-label={repository.full_name}
title={
<MicroagentManagementAccordionTitle repository={repository} />
}
>
<MicroagentManagementRepoMicroagents
repoMicroagent={{
id: repository.id,
repositoryName: repository.full_name,
repositoryUrl: `${getGitProviderBaseUrl(repository.git_provider)}/${repository.full_name}`,
}}
/>
</AccordionItem>
))}
</Accordion>
</div>
);
}
@@ -1,12 +1,16 @@
import { Tab, Tabs } from "@heroui/react";
import { useTranslation } from "react-i18next";
import { MicroagentManagementMicroagents } from "./microagent-management-microagents";
import { MicroagentManagementRepoMicroagents } from "./microagent-management-repo-microagents";
import { useSelector } from "react-redux";
import { MicroagentManagementRepositories } from "./microagent-management-repositories";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
export function MicroagentManagementSidebarTabs() {
const { t } = useTranslation();
const { repositories, personalRepositories, organizationRepositories } =
useSelector((state: RootState) => state.microagentManagement);
return (
<div className="flex w-full flex-col">
<Tabs
@@ -17,18 +21,27 @@ export function MicroagentManagementSidebarTabs() {
"w-full bg-transparent border border-[#ffffff40] rounded-[6px]",
tab: "px-2 h-[22px]",
tabContent: "text-white text-[12px] font-normal",
panel: "py-0",
panel: "p-0",
cursor: "bg-[#C9B97480] rounded-sm",
}}
>
<Tab key="personal" title={t(I18nKey.COMMON$PERSONAL)}>
<MicroagentManagementMicroagents />
<MicroagentManagementRepositories
repositories={personalRepositories}
tabType="personal"
/>
</Tab>
<Tab key="repositories" title={t(I18nKey.COMMON$REPOSITORIES)}>
<MicroagentManagementRepoMicroagents />
<MicroagentManagementRepositories
repositories={repositories}
tabType="repositories"
/>
</Tab>
<Tab key="organizations" title={t(I18nKey.COMMON$ORGANIZATIONS)}>
<MicroagentManagementMicroagents />
<MicroagentManagementRepositories
repositories={organizationRepositories}
tabType="organizations"
/>
</Tab>
</Tabs>
</div>
@@ -1,11 +1,59 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { MicroagentManagementSidebarHeader } from "./microagent-management-sidebar-header";
import { MicroagentManagementSidebarTabs } from "./microagent-management-sidebar-tabs";
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
import {
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} from "#/state/microagent-management-slice";
import { GitRepository } from "#/types/git";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
export function MicroagentManagementSidebar() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: repositories, isLoading } = useUserRepositories();
useEffect(() => {
if (repositories) {
const personalRepos: GitRepository[] = [];
const organizationRepos: GitRepository[] = [];
const otherRepos: GitRepository[] = [];
repositories.forEach((repo: GitRepository) => {
const hasOpenHandsSuffix = repo.full_name.endsWith("/.openhands");
if (repo.owner_type === "user" && hasOpenHandsSuffix) {
personalRepos.push(repo);
} else if (repo.owner_type === "organization" && hasOpenHandsSuffix) {
organizationRepos.push(repo);
} else {
otherRepos.push(repo);
}
});
dispatch(setPersonalRepositories(personalRepos));
dispatch(setOrganizationRepositories(organizationRepos));
dispatch(setRepositories(otherRepos));
}
}, [repositories, dispatch]);
return (
<div className="w-[418px] h-full border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6">
<div className="w-[418px] h-full max-h-full overflow-y-auto overflow-x-hidden border-r border-[#525252] bg-[#24272E] rounded-tl-lg rounded-bl-lg py-10 px-6 flex flex-col">
<MicroagentManagementSidebarHeader />
<MicroagentManagementSidebarTabs />
{isLoading ? (
<div className="flex flex-col items-center justify-center gap-4 flex-1">
<LoadingSpinner size="small" />
<span className="text-sm text-white">
{t("HOME$LOADING_REPOSITORIES")}
</span>
</div>
) : (
<MicroagentManagementSidebarTabs />
)}
</div>
);
}
@@ -32,32 +32,26 @@ export function MCPConfigEditor({ mcpConfig, onChange }: MCPConfigEditorProps) {
{t(I18nKey.SETTINGS$MCP_DESCRIPTION)}
</p>
</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>
{!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>
</div>
)}
<div>
{isEditing ? (
<MCPJsonEditor mcpConfig={mcpConfig} onChange={handleConfigChange} />
<MCPJsonEditor
mcpConfig={mcpConfig}
onChange={handleConfigChange}
onCancel={() => setIsEditing(false)}
/>
) : (
<>
<div className="flex flex-col gap-6">
@@ -1,15 +1,21 @@
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useTranslation, Trans } 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 }: MCPJsonEditorProps) {
export function MCPJsonEditor({
mcpConfig,
onChange,
onCancel,
}: MCPJsonEditorProps) {
const { t } = useTranslation();
const [configText, setConfigText] = useState(() =>
mcpConfig
@@ -65,11 +71,31 @@ export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
return (
<div>
<div className="mb-2 text-sm text-gray-400">
{t(I18nKey.SETTINGS$MCP_CONFIG_DESCRIPTION)}
</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>
<textarea
className="w-full h-64 p-2 text-sm font-mono bg-base-tertiary rounded-md focus:border-blue-500 focus:outline-hidden"
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",
)}
value={configText}
onChange={handleTextChange}
spellCheck="false"
@@ -87,9 +113,12 @@ export function MCPJsonEditor({ mcpConfig, onChange }: MCPJsonEditorProps) {
}
</code>
</div>
<div className="mt-4 flex justify-end">
<div className="mt-4 flex justify-end gap-3">
<BrandButton type="button" variant="secondary" onClick={onCancel}>
{t(I18nKey.BUTTON$CANCEL)}
</BrandButton>
<BrandButton type="button" variant="primary" onClick={handleSave}>
{t(I18nKey.SETTINGS$MCP_APPLY_CHANGES)}
{t(I18nKey.SETTINGS$MCP_CONFIRM_CHANGES)}
</BrandButton>
</div>
</div>
@@ -13,7 +13,6 @@ function Terminal() {
const ref = useTerminal({
commands,
readOnly: true, // Make terminal read-only
});
return (
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
import SettingsIcon from "#/icons/settings.svg?react";
import { TooltipButton } from "./tooltip-button";
import { I18nKey } from "#/i18n/declaration";
import { useConfig } from "#/hooks/query/use-config";
interface SettingsButtonProps {
onClick?: () => void;
@@ -13,6 +14,12 @@ export function SettingsButton({
disabled = false,
}: SettingsButtonProps) {
const { t } = useTranslation();
const { data: config } = useConfig();
// Determine the correct settings path based on app mode
// In SaaS mode, navigate directly to user settings to avoid the LLM settings page
const settingsPath =
config?.APP_MODE === "saas" ? "/settings/user" : "/settings";
return (
<TooltipButton
@@ -20,7 +27,7 @@ export function SettingsButton({
tooltip={t(I18nKey.SETTINGS$TITLE)}
ariaLabel={t(I18nKey.SETTINGS$TITLE)}
onClick={onClick}
navLinkTo="/settings"
navLinkTo={settingsPath}
disabled={disabled}
>
<SettingsIcon width={28} height={28} />
@@ -0,0 +1,16 @@
import { FaBitbucket, FaGithub, FaGitlab } from "react-icons/fa6";
import { Provider } from "#/types/settings";
interface GitProviderIconProps {
gitProvider: Provider;
}
export function GitProviderIcon({ gitProvider }: GitProviderIconProps) {
return (
<>
{gitProvider === "github" && <FaGithub size={14} />}
{gitProvider === "gitlab" && <FaGitlab />}
{gitProvider === "bitbucket" && <FaBitbucket />}
</>
);
}
+1
View File
@@ -0,0 +1 @@
// foo.ts - Empty TypeScript file
@@ -3,6 +3,7 @@ import posthog from "posthog-js";
import OpenHands from "#/api/open-hands";
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
interface CreateConversationVariables {
query?: string;
@@ -13,6 +14,7 @@ interface CreateConversationVariables {
};
suggestedTask?: SuggestedTask;
conversationInstructions?: string;
createMicroagent?: CreateMicroagent;
}
export const useCreateConversation = () => {
@@ -21,8 +23,13 @@ export const useCreateConversation = () => {
return useMutation({
mutationKey: ["create-conversation"],
mutationFn: async (variables: CreateConversationVariables) => {
const { query, repository, suggestedTask, conversationInstructions } =
variables;
const {
query,
repository,
suggestedTask,
conversationInstructions,
createMicroagent,
} = variables;
return OpenHands.createConversation(
repository?.name,
@@ -31,6 +38,7 @@ export const useCreateConversation = () => {
suggestedTask,
repository?.branch,
conversationInstructions,
createMicroagent,
);
},
onSuccess: async (_, { query, repository }) => {
@@ -0,0 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useRepositoryMicroagents = (owner: string, repo: string) =>
useQuery({
queryKey: ["repository", "microagents", owner, repo],
queryFn: () => OpenHands.getRepositoryMicroagents(owner, repo),
enabled: !!owner && !!repo,
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -0,0 +1,26 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useSearchConversations = (
selectedRepository?: string,
conversationTrigger?: string,
limit: number = 20,
) =>
useQuery({
queryKey: [
"conversations",
"search",
selectedRepository,
conversationTrigger,
limit,
],
queryFn: () =>
OpenHands.searchConversations(
selectedRepository,
conversationTrigger,
limit,
),
enabled: true, // Always enabled since parameters are optional
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
});
@@ -3,6 +3,7 @@ import { useCreateConversation } from "./mutation/use-create-conversation";
import { useUserProviders } from "./use-user-providers";
import { useConversationSubscriptions } from "#/context/conversation-subscriptions-provider";
import { Provider } from "#/types/settings";
import { CreateMicroagent } from "#/api/open-hands.types";
/**
* Custom hook to create a conversation and subscribe to it, supporting multiple subscriptions.
@@ -24,6 +25,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
onSuccessCallback,
onEventCallback,
}: {
@@ -34,6 +36,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
branch: string;
gitProvider: Provider;
};
createMicroagent?: CreateMicroagent;
onSuccessCallback?: (conversationId: string) => void;
onEventCallback?: (event: unknown, conversationId: string) => void;
}) => {
@@ -42,6 +45,7 @@ export const useCreateConversationAndSubscribeMultiple = () => {
query,
conversationInstructions,
repository,
createMicroagent,
},
{
onSuccess: (data) => {
+1 -4
View File
@@ -16,12 +16,10 @@ import { parseTerminalOutput } from "#/utils/parse-terminal-output";
interface UseTerminalConfig {
commands: Command[];
readOnly?: boolean;
}
const DEFAULT_TERMINAL_CONFIG: UseTerminalConfig = {
commands: [],
readOnly: false,
};
const renderCommand = (command: Command, terminal: Terminal) => {
@@ -38,7 +36,6 @@ const persistentLastCommandIndex = { current: 0 };
export const useTerminal = ({
commands,
readOnly = false,
}: UseTerminalConfig = DEFAULT_TERMINAL_CONFIG) => {
const { send } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
@@ -47,7 +44,7 @@ export const useTerminal = ({
const ref = React.useRef<HTMLDivElement>(null);
const lastCommandIndex = persistentLastCommandIndex; // Use the persistent reference
const keyEventDisposable = React.useRef<{ dispose: () => void } | null>(null);
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState) || readOnly;
const disabled = RUNTIME_INACTIVE_STATES.includes(curAgentState);
const createTerminal = () =>
new Terminal({
+10 -3
View File
@@ -12,6 +12,7 @@ export enum I18nKey {
MICROAGENT$VIEW_CONVERSATION = "MICROAGENT$VIEW_CONVERSATION",
MICROAGENT$SUCCESS_PR_READY = "MICROAGENT$SUCCESS_PR_READY",
MICROAGENT$STATUS_CREATING = "MICROAGENT$STATUS_CREATING",
MICROAGENT$STATUS_OPENING_PR = "MICROAGENT$STATUS_OPENING_PR",
MICROAGENT$STATUS_COMPLETED = "MICROAGENT$STATUS_COMPLETED",
MICROAGENT$STATUS_ERROR = "MICROAGENT$STATUS_ERROR",
MICROAGENT$VIEW_YOUR_PR = "MICROAGENT$VIEW_YOUR_PR",
@@ -50,8 +51,7 @@ 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_CANCEL = "SETTINGS$MCP_CANCEL",
SETTINGS$MCP_APPLY_CHANGES = "SETTINGS$MCP_APPLY_CHANGES",
SETTINGS$MCP_CONFIRM_CHANGES = "SETTINGS$MCP_CONFIRM_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",
@@ -579,7 +579,6 @@ 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",
@@ -710,4 +709,12 @@ export enum I18nKey {
COMMON$RUN_TEST = "COMMON$RUN_TEST",
COMMON$RUN_APP = "COMMON$RUN_APP",
COMMON$LEARN_FILE_STRUCTURE = "COMMON$LEARN_FILE_STRUCTURE",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS",
MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS = "MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS",
COMMON$SEARCH_REPOSITORIES = "COMMON$SEARCH_REPOSITORIES",
COMMON$READY_FOR_REVIEW = "COMMON$READY_FOR_REVIEW",
COMMON$COMPLETED = "COMMON$COMPLETED",
COMMON$COMPLETED_PARTIALLY = "COMMON$COMPLETED_PARTIALLY",
COMMON$STOPPED = "COMMON$STOPPED",
}
+174 -62
View File
@@ -191,6 +191,22 @@
"de": "Microagent wird geändert...",
"uk": "Зміна мікроагента..."
},
"MICROAGENT$STATUS_OPENING_PR": {
"en": "Opening PR",
"ja": "PRを開いています",
"zh-CN": "正在打开PR",
"zh-TW": "正在打開PR",
"ko-KR": "PR 열는 중",
"no": "Åpner PR",
"it": "Apertura PR",
"pt": "Abrindo PR",
"es": "Abriendo PR",
"ar": "فتح PR",
"fr": "Ouverture de la PR",
"tr": "PR açılıyor",
"de": "PR wird geöffnet",
"uk": "Відкриття PR"
},
"MICROAGENT$STATUS_COMPLETED": {
"en": "View microagent update",
"ja": "マイクロエージェントの更新を表示",
@@ -799,53 +815,37 @@
"de": "Konfiguration bearbeiten",
"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_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_CONFIG_DESCRIPTION": {
"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."
"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>."
},
"SETTINGS$MCP_CONFIG_ERROR": {
"en": "Error:",
@@ -9263,22 +9263,6 @@
"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": "アクションは実行されていません。これはユーザーが停止ボタンを押したか、リソース制約によりランタイムシステムがクラッシュして再起動したことが原因かもしれません。以前に確立されたシステム状態、依存関係、または環境変数は失われている可能性があります。",
@@ -11229,7 +11213,7 @@
"fr": "Que souhaitez-vous que le microagent fasse ?",
"tr": "Mikro ajanın ne yapmasını istersiniz?",
"de": "Was soll der Microagent tun?",
"uk": "Що ви хочете, щоб зробив мікроагент?"
"uk": "Що в,и хочете, щоб зробив мікроагент?"
},
"MICROAGENT_MANAGEMENT$DESCRIBE_WHAT_TO_DO": {
"en": "Describe what you would like the Microagent to do.",
@@ -11358,5 +11342,133 @@
"tr": "Dosya yapısını öğren",
"de": "Dateistruktur lernen",
"uk": "Вивчити структуру файлів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS": {
"en": "You do not have user-level microagents",
"ja": "ユーザーレベルのマイクロエージェントがありません",
"zh-CN": "您没有用户级微代理",
"zh-TW": "您沒有使用者層級的微代理",
"ko-KR": "사용자 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på brukernivå",
"it": "Non hai microagenti a livello utente",
"pt": "Você não possui microagentes de nível de usuário",
"es": "No tienes microagentes a nivel de usuario",
"ar": "ليس لديك وكلاء دقيقون على مستوى المستخدم",
"fr": "Vous n'avez pas de microagents au niveau utilisateur",
"tr": "Kullanıcı düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Benutzerebene",
"uk": "У вас немає мікроагентів на рівні користувача"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_MICROAGENTS": {
"en": "You do not have microagents",
"ja": "マイクロエージェントがありません",
"zh-CN": "您没有微代理",
"zh-TW": "您沒有微代理",
"ko-KR": "마이크로에이전트가 없습니다",
"no": "Du har ingen mikroagenter",
"it": "Non hai microagenti",
"pt": "Você não possui microagentes",
"es": "No tienes microagentes",
"ar": "ليس لديك وكلاء دقيقون",
"fr": "Vous n'avez pas de microagents",
"tr": "Mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten",
"uk": "У вас немає мікроагентів"
},
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_ORGANIZATION_LEVEL_MICROAGENTS": {
"en": "You do not have organization-level microagents",
"ja": "組織レベルのマイクロエージェントがありません",
"zh-CN": "您没有组织级微代理",
"zh-TW": "您沒有組織層級的微代理",
"ko-KR": "조직 수준의 마이크로에이전트가 없습니다",
"no": "Du har ikke mikroagenter på organisasjonsnivå",
"it": "Non hai microagenti a livello organizzazione",
"pt": "Você não possui microagentes de nível organizacional",
"es": "No tienes microagentes a nivel de organización",
"ar": "ليس لديك وكلاء دقيقون على مستوى المؤسسة",
"fr": "Vous n'avez pas de microagents au niveau organisation",
"tr": "Organizasyon düzeyinde mikro ajanınız yok",
"de": "Sie haben keine Mikroagenten auf Organisationsebene",
"uk": "У вас немає мікроагентів на рівні організації"
},
"COMMON$SEARCH_REPOSITORIES": {
"en": "Search repositories",
"ja": "リポジトリを検索",
"zh-CN": "搜索仓库",
"zh-TW": "搜尋存儲庫",
"ko-KR": "저장소 검색",
"no": "Søk i repositories",
"it": "Cerca repository",
"pt": "Pesquisar repositórios",
"es": "Buscar repositorios",
"ar": "البحث في المستودعات",
"fr": "Rechercher des dépôts",
"tr": "Depo ara",
"de": "Repositorys durchsuchen",
"uk": "Пошук репозиторіїв"
},
"COMMON$READY_FOR_REVIEW": {
"en": "Ready for review",
"ja": "レビューの準備ができました",
"zh-CN": "准备好审核",
"zh-TW": "已準備好審查",
"ko-KR": "검토 준비 완료",
"no": "Klar for gjennomgang",
"it": "Pronto per la revisione",
"pt": "Pronto para revisão",
"es": "Listo para revisión",
"ar": "جاهز للمراجعة",
"fr": "Prêt pour la relecture",
"tr": "İncelemeye hazır",
"de": "Bereit zur Überprüfung",
"uk": "Готово до перегляду"
},
"COMMON$COMPLETED": {
"en": "Completed",
"ja": "完了",
"zh-CN": "已完成",
"zh-TW": "已完成",
"ko-KR": "완료됨",
"no": "Fullført",
"it": "Completato",
"pt": "Concluído",
"es": "Completado",
"ar": "مكتمل",
"fr": "Terminé",
"tr": "Tamamlandı",
"de": "Abgeschlossen",
"uk": "Завершено"
},
"COMMON$COMPLETED_PARTIALLY": {
"en": "Completed partially",
"ja": "一部完了",
"zh-CN": "部分完成",
"zh-TW": "部分完成",
"ko-KR": "부분적으로 완료됨",
"no": "Delvis fullført",
"it": "Completato parzialmente",
"pt": "Concluído parcialmente",
"es": "Completado parcialmente",
"ar": "مكتمل جزئيًا",
"fr": "Partiellement terminé",
"tr": "Kısmen tamamlandı",
"de": "Teilweise abgeschlossen",
"uk": "Частково завершено"
},
"COMMON$STOPPED": {
"en": "Stopped",
"ja": "停止しました",
"zh-CN": "已停止",
"zh-TW": "已停止",
"ko-KR": "중지됨",
"no": "Stoppet",
"it": "Interrotto",
"pt": "Parado",
"es": "Detenido",
"ar": "متوقف",
"fr": "Arrêté",
"tr": "Durduruldu",
"de": "Gestoppt",
"uk": "Зупинено"
}
}
+8 -30
View File
@@ -1,14 +1,11 @@
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";
import { MicroagentManagementContent } from "#/components/features/microagent-management/microagent-management-content";
import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider";
import { EventHandler } from "#/wrapper/event-handler";
export const clientLoader = async ({ request }: Route.ClientLoaderArgs) => {
const url = new URL(request.url);
@@ -31,31 +28,12 @@ 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>
<ConversationSubscriptionsProvider>
<EventHandler>
<MicroagentManagementContent />
</EventHandler>
</ConversationSubscriptionsProvider>
);
}
+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"
@@ -1,11 +1,15 @@
import { createSlice } from "@reduxjs/toolkit";
import { GitRepository } from "#/types/git";
export const microagentManagementSlice = createSlice({
name: "microagentManagement",
initialState: {
selectedMicroagent: null,
addMicroagentModalVisible: false,
selectedRepository: null,
selectedRepository: null as GitRepository | null,
personalRepositories: [] as GitRepository[],
organizationRepositories: [] as GitRepository[],
repositories: [] as GitRepository[],
},
reducers: {
setSelectedMicroagent: (state, action) => {
@@ -17,6 +21,15 @@ export const microagentManagementSlice = createSlice({
setSelectedRepository: (state, action) => {
state.selectedRepository = action.payload;
},
setPersonalRepositories: (state, action) => {
state.personalRepositories = action.payload;
},
setOrganizationRepositories: (state, action) => {
state.organizationRepositories = action.payload;
},
setRepositories: (state, action) => {
state.repositories = action.payload;
},
},
});
@@ -24,6 +37,9 @@ export const {
setSelectedMicroagent,
setAddMicroagentModalVisible,
setSelectedRepository,
setPersonalRepositories,
setOrganizationRepositories,
setRepositories,
} = microagentManagementSlice.actions;
export default microagentManagementSlice.reducer;
+1
View File
@@ -30,6 +30,7 @@ interface GitRepository {
stargazers_count?: number;
link_header?: string;
pushed_at?: string;
owner_type?: "user" | "organization";
}
interface GitHubCommit {
@@ -0,0 +1,18 @@
export type TabType = "personal" | "repositories" | "organizations";
export interface RepositoryMicroagent {
name: string;
type: "repo" | "knowledge";
content: string;
triggers: string[];
inputs: string[];
tools: string[];
created_at: string;
git_provider: string;
}
export interface MicroagentFormData {
query: string;
triggers: string[];
selectedBranch: string;
}
+9
View File
@@ -28,3 +28,12 @@ export const JSON_VIEW_THEME = {
base0E: "#c792ea", // keywords, purple
base0F: "#ff5370", // deprecated, red
};
export const DOCUMENTATION_URL = {
MICROAGENTS: {
MICROAGENTS_OVERVIEW:
"https://docs.all-hands.dev/usage/prompting/microagents-overview",
ORGANIZATION_AND_USER_MICROAGENTS:
"https://docs.all-hands.dev/usage/prompting/microagents-org",
},
};
+16
View File
@@ -26,3 +26,19 @@ export const formatTimeDelta = (date: Date) => {
if (months < 12) return `${months}mo`;
return `${years}y`;
};
/**
* Formats a date into a MM/DD/YYYY string format.
* @param date The date to format
* @returns A string in MM/DD/YYYY format
*
* @example
* formatDateMMDDYYYY(new Date("2025-05-30T00:15:08")); // "05/30/2025"
* formatDateMMDDYYYY(new Date("2024-12-25T10:30:00")); // "12/25/2024"
*/
export const formatDateMMDDYYYY = (date: Date) =>
date.toLocaleDateString("en-US", {
month: "2-digit",
day: "2-digit",
year: "numeric",
});
+40
View File
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { Provider } from "#/types/settings";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -102,3 +103,42 @@ export const formatTimestamp = (timestamp: string) =>
minute: "2-digit",
second: "2-digit",
});
export const getGitProviderBaseUrl = (gitProvider: Provider): string => {
switch (gitProvider) {
case "github":
return "https://github.com";
case "gitlab":
return "https://gitlab.com";
case "bitbucket":
return "https://bitbucket.org";
default:
return "";
}
};
/**
* Get the name of the git provider
* @param gitProvider The git provider
* @returns The name of the git provider
*/
export const getProviderName = (gitProvider: Provider) => {
if (gitProvider === "gitlab") return "GitLab";
if (gitProvider === "bitbucket") return "Bitbucket";
return "GitHub";
};
/**
* Get the name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The name of the PR
*/
export const getPR = (isGitLab: boolean) =>
isGitLab ? "merge request" : "pull request";
/**
* Get the short name of the PR
* @param isGitLab Whether the git provider is GitLab
* @returns The short name of the PR
*/
export const getPRShort = (isGitLab: boolean) => (isGitLab ? "MR" : "PR");
@@ -27,6 +27,7 @@ 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,6 +21,7 @@ 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,6 +21,7 @@ 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>
+94 -90
View File
@@ -17,7 +17,9 @@ from openhands.cli.settings import modify_llm_settings_basic
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
alias_setup_declined,
aliases_exist_in_shell_config,
mark_alias_setup_declined,
)
from openhands.cli.tui import (
UsageMetrics,
@@ -231,12 +233,26 @@ async def run_session(
return
confirmation_status = await read_confirmation_input(config)
if confirmation_status == 'yes' or confirmation_status == 'always':
if confirmation_status in ('yes', 'always'):
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_CONFIRMED),
EventSource.USER,
)
else:
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
event_stream.add_event(
ChangeAgentStateAction(AgentState.USER_REJECTED),
EventSource.USER,
@@ -373,106 +389,86 @@ def run_alias_setup_flow(config: OpenHandsConfig) -> None:
Prompts the user to set up aliases for 'openhands' and 'oh' commands.
Handles existing aliases by offering to keep or remove them.
Args:
config: OpenHands configuration
"""
print_formatted_text('')
print_formatted_text(HTML('<gold>🚀 Welcome to OpenHands CLI!</gold>'))
print_formatted_text('')
# Check if aliases already exist
if aliases_exist_in_shell_config():
print_formatted_text(
HTML(
'<grey>We detected existing OpenHands aliases in your shell configuration.</grey>'
)
# Show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML('<grey>This will add the following aliases to your shell profile:</grey>')
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases are already configured.</ansigreen>')
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
return # Exit early since aliases already exist
else:
# No existing aliases, show the normal setup flow
print_formatted_text(
HTML('<grey>Would you like to set up convenient shell aliases?</grey>')
)
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>This will add the following aliases to your shell profile:</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>openhands</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text(
HTML(
'<grey> • <b>oh</b> → uvx --python 3.12 --from openhands-ai openhands</grey>'
)
)
print_formatted_text('')
print_formatted_text(
HTML(
'<ansiyellow>⚠️ Note: This requires uv to be installed first.</ansiyellow>'
)
)
print_formatted_text(
HTML(
'<ansiyellow> Installation guide: https://docs.astral.sh/uv/getting-started/installation</ansiyellow>'
)
)
print_formatted_text('')
)
print_formatted_text('')
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
# Use cli_confirm to get user choice
choice = cli_confirm(
config,
'Set up shell aliases?',
['Yes, set up aliases', 'No, skip this step'],
)
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
if choice == 0: # User chose "Yes"
success = add_aliases_to_shell_config()
if success:
print_formatted_text('')
print_formatted_text(
HTML('<ansigreen>✅ Aliases added successfully!</ansigreen>')
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
# Get the appropriate reload command using the shell config manager
shell_manager = ShellConfigManager()
reload_cmd = shell_manager.get_reload_command()
print_formatted_text(
HTML(
f'<grey>Run <b>{reload_cmd}</b> (or restart your terminal) to use the new aliases.</grey>'
)
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
)
else:
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
'<ansired>❌ Failed to add aliases. You can set them up manually later.</ansired>'
)
)
else: # User chose "No"
# Mark that the user has declined alias setup
mark_alias_setup_declined()
print_formatted_text('')
print_formatted_text(
HTML(
'<grey>Skipped alias setup. You can run this setup again anytime.</grey>'
)
)
print_formatted_text('')
@@ -569,15 +565,23 @@ async def main_with_loop(loop: asyncio.AbstractEventLoop) -> None:
finalize_config(config)
# Check if we should show the alias setup flow
# Only show it if aliases don't exist in the shell configuration
# and we're in an interactive environment (not during tests or CI)
if not aliases_exist_in_shell_config() and sys.stdin.isatty():
# Clear the terminal if we haven't shown a banner yet
# Only show it if:
# 1. Aliases don't exist in the shell configuration
# 2. User hasn't previously declined alias setup
# 3. We're in an interactive environment (not during tests or CI)
should_show_alias_setup = (
not aliases_exist_in_shell_config()
and not alias_setup_declined()
and sys.stdin.isatty()
)
if should_show_alias_setup:
# Clear the terminal if we haven't shown a banner yet (i.e., setup flow didn't run)
if not banner_shown:
clear()
run_alias_setup_flow(config)
banner_shown = True
# Don't set banner_shown = True here, so the ASCII art banner will still be shown
# TODO: Set working directory from config or use current working directory?
current_dir = config.workspace_base
+18
View File
@@ -277,3 +277,21 @@ def get_shell_config_path() -> Path:
"""Get the path to the shell configuration file."""
manager = ShellConfigManager()
return manager.get_shell_config_path()
def alias_setup_declined() -> bool:
"""Check if the user has previously declined alias setup.
Returns:
True if user has declined alias setup, False otherwise.
"""
marker_file = Path.home() / '.openhands' / '.cli_alias_setup_declined'
return marker_file.exists()
def mark_alias_setup_declined() -> None:
"""Mark that the user has declined alias setup."""
openhands_dir = Path.home() / '.openhands'
openhands_dir.mkdir(exist_ok=True)
marker_file = openhands_dir / '.cli_alias_setup_declined'
marker_file.touch()
+7
View File
@@ -42,6 +42,13 @@ def suppress_cli_warnings():
category=UserWarning,
)
# Suppress LiteLLM close_litellm_async_clients was never awaited warning
warnings.filterwarnings(
'ignore',
message="coroutine 'close_litellm_async_clients' was never awaited",
category=RuntimeWarning,
)
# Apply warning suppressions when module is imported
suppress_cli_warnings()
+11 -25
View File
@@ -589,34 +589,20 @@ async def read_prompt_input(
async def read_confirmation_input(config: OpenHandsConfig) -> str:
try:
prompt_session = create_prompt_session(config)
choices = [
'Yes, proceed',
'No, skip this action',
"Always proceed (don't ask again)",
'Let me provide different instructions',
]
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>'),
)
# 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
)
confirmation = (
'' if confirmation is None else confirmation.strip().lower()
)
return {0: 'yes', 1: 'no', 2: 'always', 3: 'edit'}.get(index, 'no')
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'
+19 -3
View File
@@ -219,10 +219,26 @@ class EventStream(EventStore):
def update_secrets(self, secrets: dict[str, str]) -> None:
self.secrets.update(secrets)
def _replace_secrets(self, data: dict[str, Any]) -> dict[str, Any]:
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',
}
for key in data:
if isinstance(data[key], dict):
data[key] = self._replace_secrets(data[key])
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)
elif isinstance(data[key], str):
for secret in self.secrets.values():
data[key] = data[key].replace(secret, '<secret_hidden>')
@@ -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 API to read issue title, body, and comments if you need more context
1. Address the comment. Use the $GITHUB_TOKEN and 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 API
1. Read the issue body and comments using the $GITHUB_TOKEN and 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 API to:
Use the $GITHUB_TOKEN and 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 API to read issue title, body, and comments if you need more context
1. Address the comment. Use the $GITLAB_TOKEN and 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 API
1. Read the issue body and comments using the $GITLAB_TOKEN and 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 API to:
Use the $GITLAB_TOKEN and 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,6 +87,7 @@ FUNCTION_CALLING_SUPPORTED_MODELS = [
'gemini-2.5-pro',
'gpt-4.1',
'kimi-k2-0711-preview',
'kimi-k2-instruct',
]
REASONING_EFFORT_SUPPORTED_MODELS = [
@@ -810,6 +811,8 @@ 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]
@@ -210,6 +210,7 @@ class DockerRuntime(ActionExecutionClient):
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
extra_build_args=self.config.sandbox.runtime_extra_build_args,
enable_browser=self.config.enable_browser,
)
@staticmethod
+10 -2
View File
@@ -623,8 +623,16 @@ def _create_server(
os.getenv('VSCODE_PORT') or str(find_available_tcp_port(*VSCODE_PORT_RANGE))
)
app_ports = [
int(os.getenv('APP_PORT_1') or str(find_available_tcp_port(*APP_PORT_RANGE_1))),
int(os.getenv('APP_PORT_2') or str(find_available_tcp_port(*APP_PORT_RANGE_2))),
int(
os.getenv('WORK_PORT_1')
or os.getenv('APP_PORT_1')
or str(find_available_tcp_port(*APP_PORT_RANGE_1))
),
int(
os.getenv('WORK_PORT_2')
or os.getenv('APP_PORT_2')
or str(find_available_tcp_port(*APP_PORT_RANGE_2))
),
]
# Get user info
@@ -250,6 +250,7 @@ class RemoteRuntime(ActionExecutionClient):
platform=self.config.sandbox.platform,
extra_deps=self.config.sandbox.runtime_extra_deps,
force_rebuild=self.config.sandbox.force_rebuild_runtime,
enable_browser=self.config.enable_browser,
)
response = self._send_runtime_api_request(
+26 -4
View File
@@ -32,6 +32,7 @@ def _generate_dockerfile(
base_image: str,
build_from: BuildFromImageType = BuildFromImageType.SCRATCH,
extra_deps: str | None = None,
enable_browser: bool = True,
) -> str:
"""Generate the Dockerfile content for the runtime image based on the base image.
@@ -39,6 +40,7 @@ def _generate_dockerfile(
- base_image (str): The base image provided for the runtime image
- build_from (BuildFromImageType): The build method for the runtime image.
- extra_deps (str):
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: The resulting Dockerfile content
@@ -55,6 +57,7 @@ def _generate_dockerfile(
build_from_scratch=build_from == BuildFromImageType.SCRATCH,
build_from_versioned=build_from == BuildFromImageType.VERSIONED,
extra_deps=extra_deps if extra_deps is not None else '',
enable_browser=enable_browser,
)
return dockerfile_content
@@ -111,6 +114,7 @@ def build_runtime_image(
dry_run: bool = False,
force_rebuild: bool = False,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
"""Prepares the final docker build folder.
@@ -125,6 +129,7 @@ def build_runtime_image(
- dry_run (bool): if True, it will only ready the build folder. It will not actually build the Docker image
- force_rebuild (bool): if True, it will create the Dockerfile which uses the base_image
- extra_build_args (List[str]): Additional build arguments to pass to the builder
- enable_browser (bool): Whether to enable browser support (install Playwright)
Returns:
- str: <image_repo>:<MD5 hash>. Where MD5 hash is the hash of the docker build folder
@@ -142,6 +147,7 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -154,6 +160,7 @@ def build_runtime_image(
force_rebuild=force_rebuild,
platform=platform,
extra_build_args=extra_build_args,
enable_browser=enable_browser,
)
return result
@@ -167,9 +174,10 @@ def build_runtime_image_in_folder(
force_rebuild: bool,
platform: str | None = None,
extra_build_args: list[str] | None = None,
enable_browser: bool = True,
) -> str:
runtime_image_repo, _ = get_runtime_image_repo_and_tag(base_image)
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image)}'
lock_tag = f'oh_v{oh_version}_{get_hash_for_lock_files(base_image, enable_browser)}'
versioned_tag = (
# truncate the base image to 96 characters to fit in the tag max length (128 characters)
f'oh_v{oh_version}_{get_tag_for_versioned_image(base_image)}'
@@ -188,6 +196,7 @@ def build_runtime_image_in_folder(
base_image,
build_from=BuildFromImageType.SCRATCH,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
if not dry_run:
_build_sandbox_image(
@@ -226,7 +235,7 @@ def build_runtime_image_in_folder(
else:
logger.debug(f'Build [{hash_image_name}] from scratch')
prep_build_folder(build_folder, base_image, build_from, extra_deps)
prep_build_folder(build_folder, base_image, build_from, extra_deps, enable_browser)
if not dry_run:
_build_sandbox_image(
build_folder,
@@ -251,6 +260,7 @@ def prep_build_folder(
base_image: str,
build_from: BuildFromImageType,
extra_deps: str | None,
enable_browser: bool = True,
) -> None:
# Copy the source code to directory. It will end up in build_folder/code
# If package is not found, build from source code
@@ -282,6 +292,7 @@ def prep_build_folder(
base_image,
build_from=build_from,
extra_deps=extra_deps,
enable_browser=enable_browser,
)
dockerfile_path = Path(build_folder, 'Dockerfile')
with open(str(dockerfile_path), 'w') as f:
@@ -301,10 +312,13 @@ def truncate_hash(hash: str) -> str:
return ''.join(result)
def get_hash_for_lock_files(base_image: str) -> str:
def get_hash_for_lock_files(base_image: str, enable_browser: bool = True) -> str:
openhands_source_dir = Path(openhands.__file__).parent
md5 = hashlib.md5()
md5.update(base_image.encode())
# Only include enable_browser in hash when it's False for backward compatibility
if not enable_browser:
md5.update(str(enable_browser).encode())
for file in ['pyproject.toml', 'poetry.lock']:
src = Path(openhands_source_dir, file)
if not src.exists():
@@ -378,6 +392,10 @@ if __name__ == '__main__':
parser.add_argument('--build_folder', type=str, default=None)
parser.add_argument('--force_rebuild', action='store_true', default=False)
parser.add_argument('--platform', type=str, default=None)
parser.add_argument('--enable_browser', action='store_true', default=True)
parser.add_argument(
'--no_enable_browser', dest='enable_browser', action='store_false'
)
args = parser.parse_args()
if args.build_folder is not None:
@@ -409,6 +427,7 @@ if __name__ == '__main__':
dry_run=True,
force_rebuild=args.force_rebuild,
platform=args.platform,
enable_browser=args.enable_browser,
)
_runtime_image_repo, runtime_image_source_tag = (
@@ -444,6 +463,9 @@ if __name__ == '__main__':
logger.debug('Building image in a temporary folder')
docker_builder = DockerRuntimeBuilder(docker.from_env())
image_name = build_runtime_image(
args.base_image, docker_builder, platform=args.platform
args.base_image,
docker_builder,
platform=args.platform,
enable_browser=args.enable_browser,
)
logger.debug(f'\nBuilt image: {image_name}\n')
@@ -127,7 +127,9 @@ RUN \
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
# Update and install additional tools
# (There used to be an "apt-get update" here, hopefully we can skip it.)
{% if enable_browser %}
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
{% endif %}
# Set environment variables
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
# Set permissions
@@ -27,3 +27,4 @@ class ConversationInfo:
url: str | None = None
session_api_key: str | None = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
pr_number: list[int] = field(default_factory=list)
@@ -424,6 +424,7 @@ async def _get_conversation_info(
num_connections=num_connections,
url=agent_loop_info.url if agent_loop_info else None,
session_api_key=getattr(agent_loop_info, 'session_api_key', None),
pr_number=conversation.pr_number,
)
except Exception as e:
logger.error(
@@ -73,9 +73,9 @@ class FileConversationStore(ConversationStore):
metadata_dir = self.get_conversation_metadata_dir()
try:
conversation_ids = [
path.split('/')[-2]
Path(path).name
for path in self.file_store.list(metadata_dir)
if not path.startswith(f'{metadata_dir}/.')
if not Path(path).name.startswith('.')
]
except FileNotFoundError:
return ConversationMetadataResultSet([])
+1 -1
View File
@@ -212,7 +212,7 @@ def _load_runtime(
runtime_startup_env_vars: dict[str, str] | None = None,
docker_runtime_kwargs: dict[str, str] | None = None,
override_mcp_config: MCPConfig | None = None,
enable_browser: bool = True,
enable_browser: bool = False,
) -> tuple[Runtime, OpenHandsConfig]:
sid = 'rt_' + str(random.randint(100000, 999999))
+3 -1
View File
@@ -38,7 +38,9 @@ def test_view_file(temp_dir, runtime_cls, run_as_openhands):
def test_view_directory(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create test file
test_file = os.path.join(config.workspace_mount_path_in_sandbox, 'test.txt')
+1
View File
@@ -36,6 +36,7 @@ def test_browsergym_eval_env(runtime_cls, temp_dir):
base_container_image='xingyaoww/od-eval-miniwob:v1.0',
browsergym_eval_env='browsergym/miniwob.choose-list',
force_rebuild_runtime=True,
enable_browser=True,
)
from openhands.runtime.browser.browser_env import (
BROWSER_EVAL_GET_GOAL_ACTION,
+24 -8
View File
@@ -144,7 +144,9 @@ def test_browser_disabled(temp_dir, runtime_cls, run_as_openhands):
def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
# Test browse
action_cmd = CmdRunAction(command='python3 -m http.server 8000 > server.log 2>&1 &')
@@ -189,7 +191,9 @@ def test_simple_browse(temp_dir, runtime_cls, run_as_openhands):
def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser navigation actions: goto, go_back, go_forward, noop."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create test HTML pages
page1_content = """
@@ -322,7 +326,9 @@ def test_browser_navigation_actions(temp_dir, runtime_cls, run_as_openhands):
def test_browser_form_interactions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser form interaction actions: fill, click, select_option, clear."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test form page
form_content = """
@@ -536,7 +542,9 @@ fill("{textarea_bid}", "This is a test message")
def test_browser_interactive_actions(temp_dir, runtime_cls, run_as_openhands):
"""Test browser interactive actions: scroll, hover, fill, press, focus."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test page with scrollable content
scroll_content = """
@@ -742,7 +750,9 @@ scroll(0, 400)
def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
"""Test browser file upload action."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a test file to upload
test_file_content = 'This is a test file for upload testing.'
@@ -897,7 +907,9 @@ def test_browser_file_upload(temp_dir, runtime_cls, run_as_openhands):
def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a PDF file using reportlab in the host environment
from reportlab.lib.pagesizes import letter
@@ -969,7 +981,9 @@ def test_read_pdf_browse(temp_dir, runtime_cls, run_as_openhands):
def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Create a PNG file using PIL in the host environment
from PIL import Image, ImageDraw
@@ -1037,7 +1051,9 @@ def test_read_png_browse(temp_dir, runtime_cls, run_as_openhands):
def test_download_file(temp_dir, runtime_cls, run_as_openhands):
"""Test downloading a file using the browser."""
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, enable_browser=True
)
try:
# Minimal PDF content for testing
pdf_content = b"""%PDF-1.4
+7 -1
View File
@@ -128,7 +128,11 @@ async def test_fetch_mcp_via_stdio(temp_dir, runtime_cls, run_as_openhands):
)
override_mcp_config = MCPConfig(stdio_servers=[mcp_stdio_server_config])
runtime, config = _load_runtime(
temp_dir, runtime_cls, run_as_openhands, override_mcp_config=override_mcp_config
temp_dir,
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# Test browser server
@@ -220,6 +224,7 @@ async def test_both_stdio_and_sse_mcp(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# ======= Test SSE server =======
@@ -297,6 +302,7 @@ async def test_microagent_and_one_stdio_mcp_in_config(
runtime_cls,
run_as_openhands,
override_mcp_config=override_mcp_config,
enable_browser=True,
)
# NOTE: this simulate the case where the microagent adds a new stdio server to the runtime
+123 -1
View File
@@ -4,12 +4,16 @@ import tempfile
from pathlib import Path
from unittest.mock import patch
from openhands.cli.main import alias_setup_declined as main_alias_setup_declined
from openhands.cli.main import aliases_exist_in_shell_config, run_alias_setup_flow
from openhands.cli.shell_config import (
ShellConfigManager,
add_aliases_to_shell_config,
aliases_exist_in_shell_config,
alias_setup_declined,
get_shell_config_path,
mark_alias_setup_declined,
)
from openhands.core.config import OpenHandsConfig
def test_get_shell_config_path_no_files_fallback():
@@ -244,3 +248,121 @@ def test_shell_config_manager_template_rendering():
assert 'test-command' in content
assert 'alias openhands="test-command"' in content
assert 'alias oh="test-command"' in content
def test_alias_setup_declined_false():
"""Test alias setup declined check when marker file doesn't exist."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
assert alias_setup_declined() is False
def test_alias_setup_declined_true():
"""Test alias setup declined check when marker file exists."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Create the marker file
mark_alias_setup_declined()
assert alias_setup_declined() is True
def test_mark_alias_setup_declined():
"""Test marking alias setup as declined creates the marker file."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Initially should be False
assert alias_setup_declined() is False
# Mark as declined
mark_alias_setup_declined()
# Should now be True
assert alias_setup_declined() is True
# Verify the file exists
marker_file = Path(temp_dir) / '.openhands' / '.cli_alias_setup_declined'
assert marker_file.exists()
def test_alias_setup_declined_persisted():
"""Test that when user declines alias setup, their choice is persisted."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=1
): # User chooses "No"
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After declining, the marker should be set
assert alias_setup_declined()
def test_alias_setup_skipped_when_previously_declined():
"""Test that alias setup is skipped when user has previously declined."""
OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
# Mark that user has previously declined
mark_alias_setup_declined()
assert alias_setup_declined()
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch('openhands.cli.main.cli_confirm'):
with patch('prompt_toolkit.print_formatted_text'):
# This should not show the setup flow since user previously declined
# We test this by checking the main logic conditions
should_show = (
not aliases_exist_in_shell_config()
and not main_alias_setup_declined()
)
assert not should_show, (
'Alias setup should be skipped when user previously declined'
)
def test_alias_setup_accepted_does_not_set_declined_flag():
"""Test that when user accepts alias setup, no declined marker is created."""
config = OpenHandsConfig()
with tempfile.TemporaryDirectory() as temp_dir:
with patch('openhands.cli.shell_config.Path.home', return_value=Path(temp_dir)):
with patch('shellingham.detect_shell', return_value=('bash', 'bash')):
with patch(
'openhands.cli.shell_config.aliases_exist_in_shell_config',
return_value=False,
):
with patch(
'openhands.cli.main.cli_confirm', return_value=0
): # User chooses "Yes"
with patch(
'openhands.cli.shell_config.add_aliases_to_shell_config',
return_value=True,
):
with patch('prompt_toolkit.print_formatted_text'):
# Initially, user hasn't declined
assert not alias_setup_declined()
# Run the alias setup flow
run_alias_setup_flow(config)
# After accepting, the declined marker should still be False
assert not alias_setup_declined()
+27 -152
View File
@@ -1,4 +1,4 @@
from unittest.mock import AsyncMock, MagicMock, Mock, patch
from unittest.mock import MagicMock, Mock, patch
import pytest
@@ -275,171 +275,46 @@ class TestUserCancelledError:
class TestReadConfirmationInput:
@pytest.mark.asyncio
@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
@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
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
result = await read_confirmation_input(config=cfg)
assert result == 'yes'
@pytest.mark.asyncio
@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
@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
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
@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))
result = await read_confirmation_input(config=cfg)
assert result == 'no'
@pytest.mark.asyncio
@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
@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
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'no'
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
@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))
result = await read_confirmation_input(config=cfg)
assert result == 'always'
@pytest.mark.asyncio
@patch('openhands.cli.tui.print_formatted_text')
@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
@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
result = await read_confirmation_input(config=MagicMock(spec=OpenHandsConfig))
assert result == 'yes'
cfg = MagicMock() # <- no spec for simplicity
cfg.cli = MagicMock(vi_mode=False)
# 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'
result = await read_confirmation_input(config=cfg)
assert result == 'edit'
+364
View File
@@ -179,6 +179,7 @@ async def test_search_conversations():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
]
)
@@ -638,6 +639,7 @@ async def test_get_conversation():
selected_repository='foobar',
num_connections=0,
url=None,
pr_number=[], # Default empty list for pr_number
)
assert conversation == expected
@@ -1198,3 +1200,365 @@ async def test_new_conversation_with_create_microagent_minimal(provider_handler_
assert (
call_args['git_provider'] is None
) # Should remain None since not set in create_microagent
@pytest.mark.asyncio
async def test_search_conversations_with_pr_number():
"""Test searching conversations includes pr_number field in response."""
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_with_pr',
title='Conversation with PR',
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',
pr_number=[123, 456], # Multiple PR numbers
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [123, 456]
assert conversation_info.conversation_id == 'conversation_with_pr'
assert conversation_info.title == 'Conversation with PR'
@pytest.mark.asyncio
async def test_search_conversations_with_empty_pr_number():
"""Test searching conversations with empty pr_number field."""
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_no_pr',
title='Conversation without PR',
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',
pr_number=[], # Empty PR numbers list
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes empty pr_number field
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == []
assert conversation_info.conversation_id == 'conversation_no_pr'
assert conversation_info.title == 'Conversation without PR'
@pytest.mark.asyncio
async def test_search_conversations_with_single_pr_number():
"""Test searching conversations with single PR number."""
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_single_pr',
title='Conversation with Single PR',
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',
pr_number=[789], # Single PR number
)
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify the result includes single pr_number
assert len(result_set.results) == 1
conversation_info = result_set.results[0]
assert conversation_info.pr_number == [789]
assert conversation_info.conversation_id == 'conversation_single_pr'
assert conversation_info.title == 'Conversation with Single PR'
@pytest.mark.asyncio
async def test_get_conversation_with_pr_number():
"""Test getting a single conversation includes pr_number field."""
with _patch_store():
# Mock the conversation store
mock_store = MagicMock()
mock_store.get_metadata = AsyncMock(
return_value=ConversationMetadata(
conversation_id='conversation_with_pr',
title='Conversation with PR',
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',
pr_number=[123, 456, 789], # Multiple PR numbers
)
)
# Mock the conversation manager
with patch(
'openhands.server.routes.manage_conversations.conversation_manager'
) as mock_manager:
mock_manager.is_agent_loop_running = AsyncMock(return_value=False)
mock_manager.get_connections = AsyncMock(return_value={})
mock_manager.get_agent_loop_info = AsyncMock(return_value=[])
conversation = await get_conversation(
'conversation_with_pr', conversation_store=mock_store
)
expected = ConversationInfo(
conversation_id='conversation_with_pr',
title='Conversation with PR',
created_at=datetime.fromisoformat('2025-01-01T00:00:00+00:00'),
last_updated_at=datetime.fromisoformat('2025-01-01T00:01:00+00:00'),
status=ConversationStatus.STOPPED,
selected_repository='test/repo',
num_connections=0,
url=None,
pr_number=[123, 456, 789], # Should include PR numbers
)
assert conversation == expected
@pytest.mark.asyncio
async def test_search_conversations_multiple_with_pr_numbers():
"""Test searching conversations with multiple conversations having different PR numbers."""
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',
pr_number=[100, 200], # Multiple PR numbers
),
ConversationMetadata(
conversation_id='conversation_2',
title='Conversation 2',
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',
pr_number=[], # Empty PR numbers
),
ConversationMetadata(
conversation_id='conversation_3',
title='Conversation 3',
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',
pr_number=[300], # Single PR number
),
]
)
)
result_set = await search_conversations(
page_id=None,
limit=20,
selected_repository=None,
conversation_trigger=None,
conversation_store=mock_store,
)
# Verify all results include pr_number field
assert len(result_set.results) == 3
# Check first conversation
assert result_set.results[0].conversation_id == 'conversation_1'
assert result_set.results[0].pr_number == [100, 200]
# Check second conversation
assert result_set.results[1].conversation_id == 'conversation_2'
assert result_set.results[1].pr_number == []
# Check third conversation
assert result_set.results[2].conversation_id == 'conversation_3'
assert result_set.results[2].pr_number == [300]
+128
View File
@@ -2,6 +2,7 @@ import gc
import json
import os
import time
from datetime import datetime
import psutil
import pytest
@@ -10,6 +11,7 @@ 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 (
@@ -735,3 +737,129 @@ 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']
+29 -2
View File
@@ -101,7 +101,7 @@ def test_prep_build_folder(temp_dir):
def test_get_hash_for_lock_files():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash = get_hash_for_lock_files('some_base_image')
hash = get_hash_for_lock_files('some_base_image', enable_browser=True)
# Since we mocked open to always return "mock_data", the hash is the result
# of hashing the name of the base image followed by "mock-data" twice
md5 = hashlib.md5()
@@ -111,6 +111,31 @@ def test_get_hash_for_lock_files():
assert hash == truncate_hash(md5.hexdigest())
def test_get_hash_for_lock_files_different_enable_browser():
with patch('builtins.open', mock_open(read_data='mock-data'.encode())):
hash_true = get_hash_for_lock_files('some_base_image', enable_browser=True)
hash_false = get_hash_for_lock_files('some_base_image', enable_browser=False)
# Hash with enable_browser=True should not include the enable_browser value
md5_true = hashlib.md5()
md5_true.update('some_base_image'.encode())
for _ in range(2):
md5_true.update('mock-data'.encode())
expected_hash_true = truncate_hash(md5_true.hexdigest())
# Hash with enable_browser=False should include the enable_browser value
md5_false = hashlib.md5()
md5_false.update('some_base_image'.encode())
md5_false.update('False'.encode()) # enable_browser=False is included
for _ in range(2):
md5_false.update('mock-data'.encode())
expected_hash_false = truncate_hash(md5_false.hexdigest())
assert hash_true == expected_hash_true
assert hash_false == expected_hash_false
assert hash_true != hash_false # They should be different
def test_get_hash_for_source_files():
dirhash_mock = MagicMock()
dirhash_mock.return_value = '1f69bd20d68d9e3874d5bf7f7459709b'
@@ -247,7 +272,7 @@ def test_build_runtime_image_from_scratch():
== f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag_mock-source-tag'
)
mock_prep_build_folder.assert_called_once_with(
ANY, base_image, BuildFromImageType.SCRATCH, None
ANY, base_image, BuildFromImageType.SCRATCH, None, True
)
@@ -342,6 +367,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_exist():
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-lock-tag',
BuildFromImageType.LOCK,
None,
True,
)
@@ -401,6 +427,7 @@ def test_build_runtime_image_exact_hash_not_exist_and_lock_not_exist_and_version
f'{get_runtime_image_repo()}:{OH_VERSION}_mock-versioned-tag',
BuildFromImageType.VERSIONED,
None,
True,
)
+2 -1
View File
@@ -60,7 +60,7 @@ class ModalRuntime(ActionExecutionClient):
# Read Modal API credentials from environment variables
modal_token_id = os.getenv('MODAL_TOKEN_ID')
modal_token_secret = os.getenv('MODAL_TOKEN_SECRET')
if not modal_token_id:
raise ValueError('MODAL_TOKEN_ID environment variable is required for Modal runtime')
if not modal_token_secret:
@@ -186,6 +186,7 @@ class ModalRuntime(ActionExecutionClient):
base_image=base_container_image_id,
build_from=BuildFromImageType.SCRATCH,
extra_deps=runtime_extra_deps,
enable_browser=True,
)
base_runtime_image = modal.Image.from_dockerfile(