mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aedd795aff | |||
| 0e19d3b723 | |||
| 324d7bff0f | |||
| f62ed911d2 | |||
| d13e32bcec | |||
| b978b71c47 | |||
| dc2f5cd1b0 | |||
| 07041e057d | |||
| 6e91d19f80 | |||
| 936510e219 | |||
| 7af35ab827 | |||
| a7245f2de2 | |||
| 6d7ab8a022 | |||
| bbfa37fd97 | |||
| d0cf12e474 | |||
| 78306b1ee7 | |||
| f6d99234f1 | |||
| 19ca52f954 | |||
| df75116184 | |||
| acfb01d743 | |||
| 0daaf21607 | |||
| 95bda09cd9 | |||
| 1243612694 | |||
| aff8aba5e6 |
+100
-21
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
+69
-32
@@ -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"),
|
||||
|
||||
+752
@@ -0,0 +1,752 @@
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClientConfig } from "@tanstack/react-query";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRoutesStub } from "react-router";
|
||||
import React from "react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import MicroagentManagement from "#/routes/microagent-management";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RepositoryMicroagent } from "#/types/microagent-management";
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
const RouterStub = createRoutesStub([
|
||||
{
|
||||
Component: MicroagentManagement,
|
||||
path: "/",
|
||||
},
|
||||
]);
|
||||
|
||||
const renderMicroagentManagement = (config?: QueryClientConfig) =>
|
||||
renderWithProviders(<RouterStub />, {
|
||||
preloadedState: {
|
||||
metrics: {
|
||||
cost: null,
|
||||
max_budget_per_task: null,
|
||||
usage: null,
|
||||
},
|
||||
microagentManagement: {
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: null,
|
||||
personalRepositories: [],
|
||||
organizationRepositories: [],
|
||||
repositories: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
vi.mock("react-router", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-router")>()),
|
||||
Link: ({ children }: React.PropsWithChildren) => children,
|
||||
useNavigate: vi.fn(() => vi.fn()),
|
||||
useLocation: vi.fn(() => ({ pathname: "/microagent-management" })),
|
||||
useParams: vi.fn(() => ({ conversationId: "2" })),
|
||||
}));
|
||||
});
|
||||
|
||||
const mockRepositories: GitRepository[] = [
|
||||
{
|
||||
id: "1",
|
||||
full_name: "user/repo1",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-01T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
full_name: "user/repo2/.openhands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-02T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
full_name: "org/repo3/.openhands",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-03T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
full_name: "user/repo4",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-04T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
full_name: "user/TestRepository",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "user",
|
||||
pushed_at: "2021-10-05T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
full_name: "org/AnotherRepo",
|
||||
git_provider: "github",
|
||||
is_public: true,
|
||||
owner_type: "organization",
|
||||
pushed_at: "2021-10-06T12:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const mockMicroagents: RepositoryMicroagent[] = [
|
||||
{
|
||||
name: "test-microagent-1",
|
||||
type: "repo",
|
||||
content: "Test microagent content 1",
|
||||
triggers: ["test", "microagent"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-01T12:00:00Z",
|
||||
git_provider: "github",
|
||||
},
|
||||
{
|
||||
name: "test-microagent-2",
|
||||
type: "knowledge",
|
||||
content: "Test microagent content 2",
|
||||
triggers: ["knowledge", "test"],
|
||||
inputs: [],
|
||||
tools: [],
|
||||
created_at: "2021-10-02T12:00:00Z",
|
||||
git_provider: "github",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
// Setup default mock for retrieveUserGitRepositories
|
||||
vi.spyOn(OpenHands, "retrieveUserGitRepositories").mockResolvedValue([
|
||||
...mockRepositories,
|
||||
]);
|
||||
// Setup default mock for getRepositoryMicroagents
|
||||
vi.spyOn(OpenHands, "getRepositoryMicroagents").mockResolvedValue([
|
||||
...mockMicroagents,
|
||||
]);
|
||||
});
|
||||
|
||||
it("should render the microagent management page", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that the main title is rendered
|
||||
await screen.findByText("MICROAGENT_MANAGEMENT$DESCRIPTION");
|
||||
});
|
||||
|
||||
it("should display loading state when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that loading spinner is displayed
|
||||
const loadingSpinner = await screen.findByText("HOME$LOADING_REPOSITORIES");
|
||||
expect(loadingSpinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle error when fetching repositories", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch repositories"),
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("should categorize repositories correctly", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that tabs are rendered
|
||||
const personalTab = screen.getByText("COMMON$PERSONAL");
|
||||
const repositoriesTab = screen.getByText("COMMON$REPOSITORIES");
|
||||
const organizationsTab = screen.getByText("COMMON$ORGANIZATIONS");
|
||||
|
||||
expect(personalTab).toBeInTheDocument();
|
||||
expect(repositoriesTab).toBeInTheDocument();
|
||||
expect(organizationsTab).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display repositories in accordion", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded and rendered
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that repository names are displayed
|
||||
const repo1 = screen.getByText("user/repo2/.openhands");
|
||||
expect(repo1).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should expand repository accordion and show microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display loading state when fetching microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockImplementation(
|
||||
() => new Promise(() => {}), // Never resolves
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Check that loading spinner is displayed
|
||||
const loadingSpinner = screen.getByTestId("loading-spinner");
|
||||
expect(loadingSpinner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle error when fetching microagents", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockRejectedValue(
|
||||
new Error("Failed to fetch microagents"),
|
||||
);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
|
||||
});
|
||||
});
|
||||
|
||||
it("should display empty state when no microagents are found", async () => {
|
||||
const user = userEvent.setup();
|
||||
const getRepositoryMicroagentsSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"getRepositoryMicroagents",
|
||||
);
|
||||
getRepositoryMicroagentsSpy.mockResolvedValue([]);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(getRepositoryMicroagentsSpy).toHaveBeenCalledWith("user", "repo2");
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed
|
||||
const learnThisRepo = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$LEARN_THIS_REPO",
|
||||
);
|
||||
expect(learnThisRepo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display microagent cards with correct information", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagent cards display correct information
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
|
||||
// Check that microagent file paths are displayed
|
||||
const filePath1 = screen.getByText(
|
||||
".openhands/microagents/test-microagent-1",
|
||||
);
|
||||
const filePath2 = screen.getByText(
|
||||
".openhands/microagents/test-microagent-2",
|
||||
);
|
||||
|
||||
expect(filePath1).toBeInTheDocument();
|
||||
expect(filePath2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display add microagent button in repository accordion", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that add microagent buttons are present
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
expect(addButtons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should open add microagent modal when add button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
await user.click(addButtons[0]);
|
||||
|
||||
// Check that the modal is opened
|
||||
const modalTitle = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
|
||||
);
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close add microagent modal when cancel is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click the first add microagent button
|
||||
const addButtons = screen.getAllByText("COMMON$ADD_MICROAGENT");
|
||||
await user.click(addButtons[0]);
|
||||
|
||||
// Check that the modal is opened
|
||||
const modalTitle = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT",
|
||||
);
|
||||
expect(modalTitle).toBeInTheDocument();
|
||||
|
||||
// Find and click the cancel button
|
||||
const cancelButton = screen.getByRole("button", { name: /cancel/i });
|
||||
await user.click(cancelButton);
|
||||
|
||||
// Check that the modal is closed
|
||||
expect(
|
||||
screen.queryByText("MICROAGENT_MANAGEMENT$ADD_A_MICROAGENT"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display empty state when no repositories are found", async () => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
OpenHands,
|
||||
"retrieveUserGitRepositories",
|
||||
);
|
||||
retrieveUserGitRepositoriesSpy.mockResolvedValue([]);
|
||||
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(retrieveUserGitRepositoriesSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that empty state messages are displayed
|
||||
const personalEmptyState = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$YOU_DO_NOT_HAVE_USER_LEVEL_MICROAGENTS",
|
||||
);
|
||||
|
||||
expect(personalEmptyState).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiple repository expansions", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Find and click on the first repository accordion
|
||||
const repoAccordion1 = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion1);
|
||||
|
||||
// Wait for microagents to be fetched for first repo
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that the API call was made
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should display ready to add microagent message in main area", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Check that the main area shows the ready message
|
||||
const readyMessage = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$READY_TO_ADD_MICROAGENT",
|
||||
);
|
||||
const descriptionMessage = screen.getByText(
|
||||
"MICROAGENT_MANAGEMENT$OPENHANDS_CAN_LEARN_ABOUT_REPOSITORIES",
|
||||
);
|
||||
|
||||
expect(readyMessage).toBeInTheDocument();
|
||||
expect(descriptionMessage).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Search functionality tests
|
||||
describe("Search functionality", () => {
|
||||
it("should render search input field", async () => {
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that search input is rendered
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveAttribute(
|
||||
"placeholder",
|
||||
"Search repositories...",
|
||||
);
|
||||
});
|
||||
|
||||
it("should filter repositories when typing in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Initially only repositories with .openhands should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
|
||||
// Type in search input to filter further
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Only repo2 should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should perform case-insensitive search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with uppercase
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "REPO2");
|
||||
|
||||
// repo2 should be visible (case-insensitive match)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should filter repositories by partial matches", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with partial match
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo");
|
||||
|
||||
// All repositories with "repo" in the name should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show all repositories when search input is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Only repo2 should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Clear the search input
|
||||
await user.clear(searchInput);
|
||||
|
||||
// All repositories should be visible again (only those with .openhands)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty search results", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with non-existent repository name
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "nonexistent");
|
||||
|
||||
// No repositories should be visible
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("user/repo2/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("org/repo3/.openhands"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/TestRepository")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("org/AnotherRepo")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in search", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with special characters
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, ".openhands");
|
||||
|
||||
// Only repositories with .openhands should be visible
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo4")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should maintain accordion functionality with filtered results", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Filter to show only repo2
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, "repo2");
|
||||
|
||||
// Click on the filtered repository accordion
|
||||
const repoAccordion = screen.getByText("user/repo2/.openhands");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for microagents to be fetched
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.getRepositoryMicroagents).toHaveBeenCalledWith(
|
||||
"user",
|
||||
"repo2",
|
||||
);
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
const microagent1 = screen.getByText("test-microagent-1");
|
||||
const microagent2 = screen.getByText("test-microagent-2");
|
||||
|
||||
expect(microagent1).toBeInTheDocument();
|
||||
expect(microagent2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle whitespace in search input", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Type in search input with leading/trailing whitespace
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
await user.type(searchInput, " repo2 ");
|
||||
|
||||
// repo2 should still be visible (whitespace should be trimmed)
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should update search results in real-time", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
// Wait for repositories to be loaded
|
||||
await waitFor(() => {
|
||||
expect(OpenHands.retrieveUserGitRepositories).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByRole("textbox", {
|
||||
name: /search repositories/i,
|
||||
});
|
||||
|
||||
// Type "repo" - should show repo2
|
||||
await user.type(searchInput, "repo");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Add "2" to make it "repo2" - should show only repo2
|
||||
await user.type(searchInput, "2");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
|
||||
// Remove "2" to make it "repo" again - should show repo2
|
||||
await user.keyboard("{Backspace}");
|
||||
expect(screen.getByText("user/repo2/.openhands")).toBeInTheDocument();
|
||||
expect(screen.queryByText("user/repo1")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+20
-24
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -18,6 +18,7 @@ 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;
|
||||
@@ -464,6 +465,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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
+26
@@ -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 />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+2
-1
@@ -13,7 +13,8 @@ export function MicroagentManagementAddMicroagentButton() {
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = () => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
dispatch(setAddMicroagentModalVisible(!addMicroagentModalVisible));
|
||||
};
|
||||
|
||||
|
||||
+14
-12
@@ -1,15 +1,13 @@
|
||||
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";
|
||||
|
||||
interface MicroagentManagementMicroagentCardProps {
|
||||
microagent: Microagent;
|
||||
microagent: {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function MicroagentManagementMicroagentCard({
|
||||
@@ -17,16 +15,20 @@ export function MicroagentManagementMicroagentCard({
|
||||
}: MicroagentManagementMicroagentCardProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 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));
|
||||
|
||||
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">{microagentFilePath}</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}
|
||||
{t(I18nKey.COMMON$CREATED_ON)} {formattedCreatedAt}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
-38
@@ -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>
|
||||
);
|
||||
}
|
||||
+22
@@ -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>
|
||||
);
|
||||
}
|
||||
-49
@@ -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>
|
||||
);
|
||||
}
|
||||
+60
-33
@@ -1,42 +1,69 @@
|
||||
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 { 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,
|
||||
isError,
|
||||
} = useRepositoryMicroagents(owner, repo);
|
||||
|
||||
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 (isError) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const numberOfMicroagents = microagents?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{numberOfMicroagents === 0 && (
|
||||
<MicroagentManagementLearnThisRepo
|
||||
repositoryUrl={repoMicroagent.repositoryUrl}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+125
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-6
@@ -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>
|
||||
|
||||
+50
-2
@@ -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>
|
||||
|
||||
@@ -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 />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
@@ -50,8 +50,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 +578,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 +708,8 @@ 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",
|
||||
}
|
||||
|
||||
@@ -799,53 +799,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 +9247,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 +11197,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 +11326,69 @@
|
||||
"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": "Пошук репозиторіїв"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createSlice } from "@reduxjs/toolkit";
|
||||
import { GitRepository } from "#/types/git";
|
||||
|
||||
export const microagentManagementSlice = createSlice({
|
||||
name: "microagentManagement",
|
||||
@@ -6,6 +7,9 @@ export const microagentManagementSlice = createSlice({
|
||||
selectedMicroagent: null,
|
||||
addMicroagentModalVisible: false,
|
||||
selectedRepository: 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;
|
||||
|
||||
Vendored
+1
@@ -30,6 +30,7 @@ interface GitRepository {
|
||||
stargazers_count?: number;
|
||||
link_header?: string;
|
||||
pushed_at?: string;
|
||||
owner_type?: "user" | "organization";
|
||||
}
|
||||
|
||||
interface GitHubCommit {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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,16 @@ 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 "";
|
||||
}
|
||||
};
|
||||
|
||||
+78
-88
@@ -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,
|
||||
@@ -387,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('')
|
||||
|
||||
@@ -583,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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>')
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the GitHub 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
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the Github 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.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the PR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the PR Context
|
||||
Use the GitHub 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
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the GitLab 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
-1
@@ -1,6 +1,6 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the GitLab 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.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ A comment on the MR has been addressed to you. Do NOT respond to this comment vi
|
||||
# Steps to Handle the Comment
|
||||
|
||||
## Understand the MR Context
|
||||
Use the GitLab 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -75,10 +75,38 @@ class GitHandler:
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
def _has_recent_merge_commits(self) -> bool:
|
||||
"""
|
||||
Checks if there are recent merge commits in the current branch.
|
||||
|
||||
Returns:
|
||||
bool: True if there are merge commits in the last 10 commits, False otherwise.
|
||||
"""
|
||||
# Check for merge commits in recent history
|
||||
cmd = 'git --no-pager rev-list --merges -n 1 HEAD~10..HEAD 2>/dev/null || git --no-pager rev-list --merges -n 1 HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
has_merges = output.exit_code == 0 and output.content.strip() != ''
|
||||
|
||||
if has_merges:
|
||||
return True
|
||||
|
||||
# Also check if HEAD itself is a merge commit
|
||||
cmd = 'git --no-pager rev-list --parents -n 1 HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code == 0:
|
||||
# If HEAD has more than one parent, it's a merge commit
|
||||
parents = output.content.strip().split()
|
||||
return len(parents) > 2 # commit hash + parent1 + parent2 (or more)
|
||||
|
||||
return False
|
||||
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Uses merge-base when recent merge commits are detected to show only user changes.
|
||||
Otherwise uses origin/current-branch to show changes since last push.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
@@ -86,16 +114,33 @@ class GitHandler:
|
||||
default_branch = self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_merge_base = f'$(git --no-pager merge-base HEAD "$(git --no-pager rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
ref_default_branch = 'origin/' + default_branch
|
||||
ref_new_repo = '$(git --no-pager rev-parse --verify 4b825dc642cb6eb9a060e54bf8d69288fbee4904)' # compares with empty tree
|
||||
|
||||
# Only use merge-base if:
|
||||
# 1. There are recent merge commits AND
|
||||
# 2. origin/current-branch would show merged content (is behind after merge)
|
||||
if (
|
||||
self._has_recent_merge_commits()
|
||||
and self._verify_ref_exists(ref_current_branch)
|
||||
and self._verify_ref_exists(ref_merge_base)
|
||||
):
|
||||
# Check if origin/current-branch is behind (would show merged content)
|
||||
cmd = f'git --no-pager rev-list --count {ref_current_branch}..HEAD'
|
||||
output = self.execute(cmd, self.cwd)
|
||||
if output.exit_code == 0 and int(output.content.strip()) > 0:
|
||||
# origin/current-branch is behind, use merge-base to filter merged content
|
||||
return ref_merge_base
|
||||
|
||||
# Default behavior: use origin/current-branch first (shows changes since push)
|
||||
refs = [
|
||||
ref_current_branch,
|
||||
ref_non_default_branch,
|
||||
ref_merge_base,
|
||||
ref_default_branch,
|
||||
ref_new_repo,
|
||||
]
|
||||
|
||||
for ref in refs:
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -164,22 +164,12 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
|
||||
def test_get_valid_ref_with_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref returns the current branch in origin when it exists."""
|
||||
"""Test that _get_valid_ref returns origin/current-branch when no recent merges."""
|
||||
# This test uses the setup from setUp where the current branch exists in origin
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
verify_commands = [
|
||||
cmd
|
||||
for cmd, _ in self.executed_commands
|
||||
if cmd.startswith('git --no-pager rev-parse --verify')
|
||||
]
|
||||
|
||||
# First should check origin/feature-branch (current branch)
|
||||
self.assertTrue(any('origin/feature-branch' in cmd for cmd in verify_commands))
|
||||
|
||||
# Should have found a valid ref (origin/feature-branch)
|
||||
# Should prefer origin/feature-branch when no recent merge commits
|
||||
self.assertEqual(ref, 'origin/feature-branch')
|
||||
|
||||
# Verify the ref exists
|
||||
@@ -396,6 +386,65 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
def test_merge_scenario_shows_only_user_changes(self):
|
||||
"""Test that after merging main, only user changes are shown, not merged content."""
|
||||
# Add a file to main in origin (simulating other developers' work)
|
||||
with open(os.path.join(self.origin_dir, 'main-addition.txt'), 'w') as f:
|
||||
f.write('Content added to main')
|
||||
|
||||
self._execute_command('git add main-addition.txt', self.origin_dir)
|
||||
self._execute_command("git commit -m 'Add main-addition.txt'", self.origin_dir)
|
||||
|
||||
# Switch back to local repo and merge main
|
||||
self._execute_command('git fetch origin', self.local_dir)
|
||||
self._execute_command('git merge origin/main', self.local_dir)
|
||||
|
||||
# Clear executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
# Get changes after merge
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should NOT include the merged file as a change
|
||||
change_paths = [change['path'] for change in changes]
|
||||
self.assertNotIn(
|
||||
'main-addition.txt',
|
||||
change_paths,
|
||||
'Merged content should not appear as user changes',
|
||||
)
|
||||
|
||||
# Should still show user's original changes (if any exist relative to merge-base)
|
||||
# In this case, we expect to see the files that were changed on the feature branch
|
||||
# relative to where it diverged from main
|
||||
|
||||
def test_no_changes_after_push_without_merge(self):
|
||||
"""Test that after pushing changes (without merge), no changes are shown."""
|
||||
# Create a new file and commit it
|
||||
with open(os.path.join(self.local_dir, 'new-feature.txt'), 'w') as f:
|
||||
f.write('New feature content')
|
||||
|
||||
self._execute_command('git add new-feature.txt', self.local_dir)
|
||||
self._execute_command("git commit -m 'Add new feature'", self.local_dir)
|
||||
|
||||
# Push the changes
|
||||
self._execute_command('git push origin feature-branch', self.local_dir)
|
||||
|
||||
# Clear executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
# Get changes after push - should show no changes since no recent merges
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should show no changes since everything is pushed and no recent merges
|
||||
# This tests the regression fix - after push, changes should be clear
|
||||
self.assertEqual(
|
||||
len(changes),
|
||||
0,
|
||||
'Should show no changes after pushing without recent merges',
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user