Compare commits

..

22 Commits

Author SHA1 Message Date
openhands cedd94009b feat(frontend): improve pause button visibility by adding gray circle background\n\n- Adds a light gray circular background behind the pause icon to increase contrast and discoverability\n\nCo-authored-by: openhands <openhands@all-hands.dev> 2025-08-28 18:15:59 +00:00
dependabot[bot] 23713bfe8c chore(deps): bump the version-all group in /frontend with 5 updates (#10686)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 14:53:14 +00:00
Ryan H. Tran 81829289ab Add support for passing list of Message into LLM completion (#10671) 2025-08-28 21:22:28 +08:00
Ray Myers 9709431874 fix: cli dedupe TaskTrackingAction thoughts by using display_thought_if_new (#10660)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-28 21:20:39 +08:00
dependabot[bot] 0e9906f41e chore(deps): bump posthog-js from 1.260.3 to 1.261.0 in /frontend in the version-all group (#10658)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-28 16:15:31 +04:00
chuckbutkus 9ac9a47207 Missed a place for the group change (#10659) 2025-08-27 21:47:20 +00:00
Hiep Le 75653e805a refactor(frontend): enhance the launch microagent modal (memory UI). (#10651) 2025-08-28 01:41:58 +07:00
mamoodi 9630b536cd Revert "Add support for passing list of Message into LLM completion" (#10653) 2025-08-27 17:51:17 +00:00
Engel Nyst 6f5c8186b8 Fix(settings): enforce condenser max history size >= 20 and improve messaging (#10638) 2025-08-27 18:37:41 +02:00
Rohit Malhotra 36e0d8d3da [Fix]: token refresh for nested runtimes (#10637)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-27 12:20:34 -04:00
Ryan H. Tran e68abf8d75 Add support for passing list of Message into LLM completion (#10650) 2025-08-27 22:39:26 +07:00
Ryan H. Tran 93ef1b0cda Remove image content filtering in ConversationMemory (#10645) 2025-08-27 22:28:09 +07:00
Web3 Outlaw 77b5c6b161 Fix Typos in Comment and Docs (#10644) 2025-08-27 14:06:39 +00:00
Hiep Le 57aa7d5c12 feat: hide conversations after PR closure or merge (microagent management) (#10600) 2025-08-27 16:32:04 +07:00
Hiep Le 50391ecdf3 feat(frontend): update learning repo flow (microagent management) (#10597)
Co-authored-by: amanape <83104063+amanape@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-27 16:02:48 +07:00
dependabot[bot] 672650d3d9 chore(deps): bump the version-all group in /frontend with 7 updates (#10643)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-27 12:10:48 +04:00
Rohit Malhotra 9afedea170 [Bug, GitHub]: fix missing context in cloud resolver (#10517)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-27 07:07:09 +00:00
chuckbutkus c0bb84dfa2 Non root user (#10155)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-27 02:23:39 -04:00
Hiep Le 18b5139237 fix(backend): show name of created branch in conversation list. (#10208) 2025-08-27 11:41:12 +07:00
Rohit Malhotra 4849369ede frontend(chat): render conversation_instructions from RecallObservation (#10639)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-26 23:32:18 -04:00
Xingyao Wang b082ccc0fb feat(llm): add support for deepseek and gpt-5-mini, util for token count (#10626)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-08-27 11:03:35 +08:00
mamoodi b0007076c0 Remove duplicated command in CLI (#10634) 2025-08-26 16:01:16 -04:00
56 changed files with 2421 additions and 349 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ else
fi
fi
fi
usermod -aG app enduser
usermod -aG openhands enduser
# get the user group of /var/run/docker.sock and set openhands to that group
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
echo "Docker socket group id: $DOCKER_SOCKET_GID"
+1 -7
View File
@@ -87,19 +87,13 @@ source ~/.bashrc # or source ~/.zshrc
</AccordionGroup>
3. Launch an interactive OpenHands conversation from the command line:
```bash
# If using uvx (recommended)
uvx --python 3.12 --from openhands-ai openhands
```
<Note>
If you have cloned the repository, you can also run the CLI directly using Poetry:
poetry run openhands
</Note>
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
The first time you run the CLI, it will take you through configuring the required LLM
+1 -1
View File
@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
directory. and it's called `openhands`.
directory, and it's called `openhands`.
## Debugging
@@ -13,6 +13,7 @@ N_RUNS=${4:-1}
export EXP_NAME=$EXP_NAME
# use 2x resources for rollout since some codebases are pretty resource-intensive
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
export ITERATIVE_EVAL_MODE=false
echo "MODEL: $MODEL"
echo "EXP_NAME: $EXP_NAME"
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
Script to aggregate token usage metrics from LLM completion files.
Usage:
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
Arguments:
directory_path: Path to the directory containing completion files
--input-cost: Cost per input token (default: 0.0)
--output-cost: Cost per output token (default: 0.0)
--cached-cost: Cost per cached token (default: 0.0)
"""
import argparse
import json
import os
from pathlib import Path
def aggregate_token_usage(
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
):
"""
Aggregate token usage metrics from all JSON completion files in the directory.
Args:
directory_path (str): Path to directory containing completion files
input_cost (float): Cost per input token
output_cost (float): Cost per output token
cached_cost (float): Cost per cached token
"""
# Initialize counters
totals = {
'input_tokens': 0,
'output_tokens': 0,
'cached_tokens': 0,
'total_tokens': 0,
'files_processed': 0,
'files_with_errors': 0,
'cost': 0,
}
# Find all JSON files recursively
json_files = list(Path(directory_path).rglob('*.json'))
print(f'Found {len(json_files)} JSON files to process...')
for json_file in json_files:
try:
with open(json_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# Look for usage data in response or fncall_response
usage_data = None
if (
'response' in data
and isinstance(data['response'], dict)
and 'usage' in data['response']
):
usage_data = data['response']['usage']
elif (
'fncall_response' in data
and isinstance(data['fncall_response'], dict)
and 'usage' in data['fncall_response']
):
usage_data = data['fncall_response']['usage']
if usage_data:
# Extract token counts
completion_tokens = usage_data.get('completion_tokens', 0)
prompt_tokens = usage_data.get('prompt_tokens', 0)
cached_tokens = usage_data.get('cached_tokens', 0)
# Handle cases where cached_tokens might be in prompt_tokens_details
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
details = usage_data['prompt_tokens_details']
if isinstance(details, dict) and 'cached_tokens' in details:
cached_tokens = details.get('cached_tokens', 0) or 0
# Calculate non-cached input tokens
non_cached_input = prompt_tokens - cached_tokens
# Update totals
totals['input_tokens'] += non_cached_input
totals['output_tokens'] += completion_tokens
totals['cached_tokens'] += cached_tokens
totals['total_tokens'] += prompt_tokens + completion_tokens
if 'cost' in data:
totals['cost'] += data['cost']
totals['files_processed'] += 1
# Progress indicator
if totals['files_processed'] % 1000 == 0:
print(f'Processed {totals["files_processed"]} files...')
except Exception as e:
totals['files_with_errors'] += 1
if totals['files_with_errors'] <= 5: # Only show first 5 errors
print(f'Error processing {json_file}: {e}')
# Calculate costs
input_cost_total = totals['input_tokens'] * input_cost
output_cost_total = totals['output_tokens'] * output_cost
cached_cost_total = totals['cached_tokens'] * cached_cost
total_cost = input_cost_total + output_cost_total + cached_cost_total
# Print results
print('\n' + '=' * 60)
print('TOKEN USAGE AGGREGATION RESULTS')
print('=' * 60)
print(f'Files processed: {totals["files_processed"]:,}')
print(f'Files with errors: {totals["files_with_errors"]:,}')
print()
print('TOKEN COUNTS:')
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
print(f' Output tokens: {totals["output_tokens"]:,}')
print(f' Cached tokens: {totals["cached_tokens"]:,}')
print(f' Total tokens: {totals["total_tokens"]:,}')
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
print()
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
print('COST CALCULATED BASED ON PROVIDED RATE:')
print(
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
)
print(
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
)
print(
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
)
print(f' Total cost: ${total_cost:.6f}')
print()
print('SUMMARY:')
print(
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
)
print(f' Total output tokens: {totals["output_tokens"]:,}')
print(f' Grand total tokens: {totals["total_tokens"]:,}')
return totals
def main():
parser = argparse.ArgumentParser(
description='Aggregate token usage metrics from LLM completion files',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python aggregate_token_usage.py /path/to/completions
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
""",
)
parser.add_argument(
'directory_path', help='Path to directory containing completion files'
)
parser.add_argument(
'--input-cost',
type=float,
default=0.0,
help='Cost per input token (default: 0.0)',
)
parser.add_argument(
'--output-cost',
type=float,
default=0.0,
help='Cost per output token (default: 0.0)',
)
parser.add_argument(
'--cached-cost',
type=float,
default=0.0,
help='Cost per cached token (default: 0.0)',
)
args = parser.parse_args()
# Validate directory path
if not os.path.exists(args.directory_path):
print(f"Error: Directory '{args.directory_path}' does not exist.")
return 1
if not os.path.isdir(args.directory_path):
print(f"Error: '{args.directory_path}' is not a directory.")
return 1
# Run aggregation
try:
aggregate_token_usage(
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
)
return 0
except Exception as e:
print(f'Error during aggregation: {e}')
return 1
if __name__ == '__main__':
exit(main())
@@ -17,7 +17,7 @@ const mockUseUserProviders = vi.fn();
const mockUseGitRepositories = vi.fn();
const mockUseConfig = vi.fn();
const mockUseRepositoryMicroagents = vi.fn();
const mockUseSearchConversations = vi.fn();
const mockUseMicroagentManagementConversations = vi.fn();
vi.mock("#/hooks/use-user-providers", () => ({
useUserProviders: () => mockUseUserProviders(),
@@ -35,8 +35,9 @@ vi.mock("#/hooks/query/use-repository-microagents", () => ({
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
}));
vi.mock("#/hooks/query/use-search-conversations", () => ({
useSearchConversations: () => mockUseSearchConversations(),
vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
useMicroagentManagementConversations: () =>
mockUseMicroagentManagementConversations(),
}));
describe("MicroagentManagement", () => {
@@ -212,7 +213,7 @@ describe("MicroagentManagement", () => {
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: mockConversations,
isLoading: false,
isError: false,
@@ -859,8 +860,8 @@ describe("MicroagentManagement", () => {
});
// Search conversations functionality tests
describe("Search conversations functionality", () => {
it("should call searchConversations API when repository is expanded", async () => {
describe("Microagent management conversations functionality", () => {
it("should call useMicroagentManagementConversations API when repository is expanded", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
@@ -876,7 +877,7 @@ describe("MicroagentManagement", () => {
// Wait for both microagents and conversations to be fetched
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
});
@@ -896,7 +897,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -921,7 +922,7 @@ describe("MicroagentManagement", () => {
isLoading: true,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
@@ -958,7 +959,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that loading spinner is not displayed
@@ -983,7 +984,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagent file paths are displayed for microagents
@@ -1013,7 +1014,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -1033,7 +1034,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed
@@ -1050,7 +1051,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [...mockConversations],
isLoading: false,
isError: false,
@@ -1070,7 +1071,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that conversations are displayed
@@ -1093,7 +1094,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -1113,7 +1114,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that microagents are displayed
@@ -1131,7 +1132,7 @@ describe("MicroagentManagement", () => {
it("should handle error when fetching conversations", async () => {
const user = userEvent.setup();
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
@@ -1150,7 +1151,7 @@ describe("MicroagentManagement", () => {
// Wait for the error to be handled
await waitFor(() => {
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that the learn this repo component is displayed (since conversations failed)
@@ -1195,7 +1196,7 @@ describe("MicroagentManagement", () => {
expect(learnThisRepo).toBeInTheDocument();
});
it("should call searchConversations with correct parameters", async () => {
it("should call useMicroagentManagementConversations with correct parameters", async () => {
const user = userEvent.setup();
renderMicroagentManagement();
@@ -1208,9 +1209,9 @@ describe("MicroagentManagement", () => {
const repoAccordion = screen.getByTestId("repository-name-tooltip");
await user.click(repoAccordion);
// Wait for searchConversations to be called
// Wait for useMicroagentManagementConversations to be called
await waitFor(() => {
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
});
@@ -1230,7 +1231,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to complete
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that conversations display correct information
@@ -1257,7 +1258,7 @@ describe("MicroagentManagement", () => {
// Wait for both queries to be called for first repo
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Check that both microagents and conversations are displayed
@@ -2391,7 +2392,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2411,7 +2412,7 @@ describe("MicroagentManagement", () => {
// Wait for microagents and conversations to be fetched
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Verify the learn this repo trigger is displayed when no microagents exist
@@ -2436,7 +2437,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2491,7 +2492,7 @@ describe("MicroagentManagement", () => {
isLoading: false,
isError: false,
});
mockUseSearchConversations.mockReturnValue({
mockUseMicroagentManagementConversations.mockReturnValue({
data: [],
isLoading: false,
isError: false,
@@ -2508,7 +2509,7 @@ describe("MicroagentManagement", () => {
await waitFor(() => {
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
expect(mockUseSearchConversations).toHaveBeenCalled();
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
});
// Should NOT show the learn this repo trigger when microagents exist
+246 -52
View File
@@ -14,15 +14,15 @@
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@react-types/shared": "^3.31.0",
"@react-types/shared": "^3.32.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.1",
"@stripe/stripe-js": "^7.8.0",
"@stripe/react-stripe-js": "^3.9.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.1",
"@vitejs/plugin-react": "^5.0.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
@@ -34,10 +34,10 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.30",
"jose": "^6.0.13",
"lucide-react": "^0.541.0",
"jose": "^6.1.0",
"lucide-react": "^0.542.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.260.2",
"posthog-js": "^1.261.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -48,7 +48,7 @@
"react-redux": "^9.2.0",
"react-router": "^7.8.2",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.5",
"react-syntax-highlighter": "^15.6.6",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@@ -73,8 +73,8 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -99,7 +99,7 @@
"lint-staged": "^16.1.4",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.4.0",
"stripe": "^18.5.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.5.0",
@@ -1556,6 +1556,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/accordion/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/alert": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/@heroui/alert/-/alert-2.2.24.tgz",
@@ -1592,6 +1600,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/aria-utils/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/autocomplete": {
"version": "2.3.26",
"resolved": "https://registry.npmjs.org/@heroui/autocomplete/-/autocomplete-2.3.26.tgz",
@@ -1623,6 +1639,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/autocomplete/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/avatar": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/@heroui/avatar/-/avatar-2.2.20.tgz",
@@ -1701,6 +1725,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/button/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/calendar": {
"version": "2.2.24",
"resolved": "https://registry.npmjs.org/@heroui/calendar/-/calendar-2.2.24.tgz",
@@ -1735,6 +1767,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/calendar/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/card": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/@heroui/card/-/card-2.2.23.tgz",
@@ -1757,6 +1797,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/card/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/checkbox": {
"version": "2.3.24",
"resolved": "https://registry.npmjs.org/@heroui/checkbox/-/checkbox-2.3.24.tgz",
@@ -1783,6 +1831,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/checkbox/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/chip": {
"version": "2.2.20",
"resolved": "https://registry.npmjs.org/@heroui/chip/-/chip-2.2.20.tgz",
@@ -1841,6 +1897,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/date-input/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/date-picker": {
"version": "2.3.25",
"resolved": "https://registry.npmjs.org/@heroui/date-picker/-/date-picker-2.3.25.tgz",
@@ -1872,6 +1936,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/date-picker/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/divider": {
"version": "2.2.17",
"resolved": "https://registry.npmjs.org/@heroui/divider/-/divider-2.2.17.tgz",
@@ -1888,6 +1960,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/divider/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/dom-animation": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@heroui/dom-animation/-/dom-animation-2.1.10.tgz",
@@ -1959,6 +2039,14 @@
"react-dom": ">=18"
}
},
"node_modules/@heroui/form/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/framer-utils": {
"version": "2.1.20",
"resolved": "https://registry.npmjs.org/@heroui/framer-utils/-/framer-utils-2.1.20.tgz",
@@ -2041,6 +2129,14 @@
"react-dom": ">=18"
}
},
"node_modules/@heroui/input/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/kbd": {
"version": "2.2.19",
"resolved": "https://registry.npmjs.org/@heroui/kbd/-/kbd-2.2.19.tgz",
@@ -2102,6 +2198,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/listbox/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/menu": {
"version": "2.2.23",
"resolved": "https://registry.npmjs.org/@heroui/menu/-/menu-2.2.23.tgz",
@@ -2127,6 +2231,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/menu/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/modal": {
"version": "2.2.21",
"resolved": "https://registry.npmjs.org/@heroui/modal/-/modal-2.2.21.tgz",
@@ -2211,6 +2323,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/number-input/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/pagination": {
"version": "2.2.22",
"resolved": "https://registry.npmjs.org/@heroui/pagination/-/pagination-2.2.22.tgz",
@@ -2307,6 +2427,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/radio/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/react": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@heroui/react/-/react-2.8.2.tgz",
@@ -2460,6 +2588,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/select/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/shared-icons": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@heroui/shared-icons/-/shared-icons-2.1.10.tgz",
@@ -2622,6 +2758,14 @@
"react": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/system-rsc/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/system-rsc/node_modules/clsx": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
@@ -2684,6 +2828,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/tabs/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/theme": {
"version": "2.4.20",
"resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.20.tgz",
@@ -2779,6 +2931,14 @@
"react": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/use-aria-accordion/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/use-aria-button": {
"version": "2.2.18",
"resolved": "https://registry.npmjs.org/@heroui/use-aria-button/-/use-aria-button-2.2.18.tgz",
@@ -2795,6 +2955,14 @@
"react": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/use-aria-button/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/use-aria-link": {
"version": "2.2.19",
"resolved": "https://registry.npmjs.org/@heroui/use-aria-link/-/use-aria-link-2.2.19.tgz",
@@ -2811,6 +2979,14 @@
"react": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/use-aria-link/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/use-aria-modal-overlay": {
"version": "2.2.17",
"resolved": "https://registry.npmjs.org/@heroui/use-aria-modal-overlay/-/use-aria-modal-overlay-2.2.17.tgz",
@@ -2852,6 +3028,14 @@
"react-dom": ">=18 || >=19.0.0-rc.0"
}
},
"node_modules/@heroui/use-aria-multiselect/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/use-aria-overlay": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@heroui/use-aria-overlay/-/use-aria-overlay-2.0.2.tgz",
@@ -2868,6 +3052,14 @@
"react-dom": ">=18"
}
},
"node_modules/@heroui/use-aria-overlay/node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
},
"node_modules/@heroui/use-callback-ref": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@heroui/use-callback-ref/-/use-callback-ref-2.1.8.tgz",
@@ -3683,6 +3875,11 @@
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"license": "MIT"
},
"node_modules/@posthog/core": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.2.tgz",
"integrity": "sha512-hWk3rUtJl2crQK0WNmwg13n82hnTwB99BT99/XI5gZSvIlYZ1TPmMZE8H2dhJJ98J/rm9vYJ/UXNzw3RV5HTpQ=="
},
"node_modules/@react-aria/breadcrumbs": {
"version": "3.5.27",
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.27.tgz",
@@ -5118,10 +5315,9 @@
}
},
"node_modules/@react-types/shared": {
"version": "3.31.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
"license": "Apache-2.0",
"version": "3.32.0",
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
}
@@ -5227,9 +5423,9 @@
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.32",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.32.tgz",
"integrity": "sha512-QReCdvxiUZAPkvp1xpAg62IeNzykOFA6syH2CnClif4YmALN1XKpB39XneL80008UbtMShthSVDKmrx05N1q/g=="
"version": "1.0.0-beta.34",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.34.tgz",
"integrity": "sha512-LyAREkZHP5pMom7c24meKmJCdhf2hEyvam2q0unr3or9ydwDL+DJ8chTF6Av/RFPb3rH8UFBdMzO5MxTZW97oA=="
},
"node_modules/@rollup/pluginutils": {
"version": "5.2.0",
@@ -5560,9 +5756,9 @@
"license": "MIT"
},
"node_modules/@stripe/react-stripe-js": {
"version": "3.9.1",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.1.tgz",
"integrity": "sha512-t5KZiu7jkUTHOx0adGSlSj4xPpFSvW6BsgIRQHNXqhHeYBH0mpddVUZsO33WM1m6Vyd1Wl96JoBhwEsw8jMHTQ==",
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz",
"integrity": "sha512-urAZek4LrnHWfk4WYXItOiX+6xyxjcn0SkhBDoysXphLkUt92UWCd5+NlomhVqaLo98XiUQGZRiRcL8HOHZ8Jw==",
"dependencies": {
"prop-types": "^15.7.2"
},
@@ -5573,10 +5769,9 @@
}
},
"node_modules/@stripe/stripe-js": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.8.0.tgz",
"integrity": "sha512-DNXRfYUgkZlrniQORbA/wH8CdFRhiBSE0R56gYU0V5vvpJ9WZwvGrz9tBAZmfq2aTgw6SK7mNpmTizGzLWVezw==",
"license": "MIT",
"version": "7.9.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
"engines": {
"node": ">=12.16"
}
@@ -6495,19 +6690,18 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.11",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.11.tgz",
"integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==",
"version": "19.1.12",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
"dependencies": {
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.1.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
"version": "19.1.9",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz",
"integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
}
@@ -6995,14 +7189,14 @@
"license": "ISC"
},
"node_modules/@vitejs/plugin-react": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.1.tgz",
"integrity": "sha512-DE4UNaBXwtVoDJ0ccBdLVjFTWL70NRuWNCxEieTI3lrq9ORB9aOCQEKstwDXBl87NvFdbqh/p7eINGyj0BthJA==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.2.tgz",
"integrity": "sha512-tmyFgixPZCx2+e6VO9TNITWcCQl8+Nl/E8YbAyPVv85QCc7/A3JrdfG2A8gIzvVhWuzMOVrFW1aReaNxrI6tbw==",
"dependencies": {
"@babel/core": "^7.28.3",
"@babel/plugin-transform-react-jsx-self": "^7.27.1",
"@babel/plugin-transform-react-jsx-source": "^7.27.1",
"@rolldown/pluginutils": "1.0.0-beta.32",
"@rolldown/pluginutils": "1.0.0-beta.34",
"@types/babel__core": "^7.20.5",
"react-refresh": "^0.17.0"
},
@@ -11846,9 +12040,9 @@
}
},
"node_modules/jose": {
"version": "6.0.13",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.13.tgz",
"integrity": "sha512-Yms4GpbmdANamS51kKK6w4hRlKx8KTxbWyAAKT/MhUMtqbIqh5mb2HjhTNUbk7TFL8/MBB5zWSDohL7ed4k/UA==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"funding": {
"url": "https://github.com/sponsors/panva"
}
@@ -12687,9 +12881,9 @@
}
},
"node_modules/lucide-react": {
"version": "0.541.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.541.0.tgz",
"integrity": "sha512-s0Vircsu5WaGv2KoJZ5+SoxiAJ3UXV5KqEM3eIFDHaHkcLIFdIWgXtZ412+Gh02UsdS7Was+jvEpBvPCWQISlg==",
"version": "0.542.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz",
"integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
@@ -14712,10 +14906,11 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.260.2",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.2.tgz",
"integrity": "sha512-2Q+QUz9j9+uG16wp0WcOEbezVsLZCobZyTX8NvWPMGKyPaf2lOsjbPjznsq5JiIt324B6NAqzpWYZTzvhn9k9Q==",
"version": "1.261.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.261.0.tgz",
"integrity": "sha512-jyiXqyrCU+VlpbNNVRA6OQYAVut0XZMYNELCZH+XvTd981VqbE4jXn4XCBreo7XCL2gdPgDVxUVOuzNvEuKcmw==",
"dependencies": {
"@posthog/core": "1.0.2",
"core-js": "^3.38.1",
"fflate": "^0.4.8",
"preact": "^10.19.3",
@@ -15235,9 +15430,9 @@
}
},
"node_modules/react-syntax-highlighter": {
"version": "15.6.5",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.5.tgz",
"integrity": "sha512-Sscw/qACcdp3UIuDVN+PhdKkQZTmAv55+RTzwTJZS+UFFpLilogVnKelDqHuc4E//d7lgEAo2dcDY9h4xhEtJw==",
"version": "15.6.6",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
"dependencies": {
"@babel/runtime": "^7.3.1",
"highlight.js": "^10.4.1",
@@ -16781,11 +16976,10 @@
"license": "MIT"
},
"node_modules/stripe": {
"version": "18.4.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.4.0.tgz",
"integrity": "sha512-LKFeDnDYo4U/YzNgx2Lc9PT9XgKN0JNF1iQwZxgkS4lOw5NunWCnzyH5RhTlD3clIZnf54h7nyMWkS8VXPmtTQ==",
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz",
"integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==",
"dev": true,
"license": "MIT",
"dependencies": {
"qs": "^6.11.0"
},
+11 -11
View File
@@ -13,15 +13,15 @@
"@monaco-editor/react": "^4.7.0-rc.0",
"@react-router/node": "^7.8.2",
"@react-router/serve": "^7.8.2",
"@react-types/shared": "^3.31.0",
"@react-types/shared": "^3.32.0",
"@reduxjs/toolkit": "^2.8.2",
"@stripe/react-stripe-js": "^3.9.1",
"@stripe/stripe-js": "^7.8.0",
"@stripe/react-stripe-js": "^3.9.2",
"@stripe/stripe-js": "^7.9.0",
"@tailwindcss/postcss": "^4.1.12",
"@tailwindcss/vite": "^4.1.12",
"@tanstack/react-query": "^5.85.5",
"@uidotdev/usehooks": "^2.4.1",
"@vitejs/plugin-react": "^5.0.1",
"@vitejs/plugin-react": "^5.0.2",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.4.0",
"axios": "^1.11.0",
@@ -33,10 +33,10 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"isbot": "^5.1.30",
"jose": "^6.0.13",
"lucide-react": "^0.541.0",
"jose": "^6.1.0",
"lucide-react": "^0.542.0",
"monaco-editor": "^0.52.2",
"posthog-js": "^1.260.2",
"posthog-js": "^1.261.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-highlight": "^0.15.0",
@@ -47,7 +47,7 @@
"react-redux": "^9.2.0",
"react-router": "^7.8.2",
"react-select": "^5.10.2",
"react-syntax-highlighter": "^15.6.5",
"react-syntax-highlighter": "^15.6.6",
"react-textarea-autosize": "^8.5.9",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
@@ -97,8 +97,8 @@
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@types/react": "^19.1.12",
"@types/react-dom": "^19.1.9",
"@types/react-highlight": "^0.12.8",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/ws": "^8.18.1",
@@ -123,7 +123,7 @@
"lint-staged": "^16.1.4",
"msw": "^2.6.6",
"prettier": "^3.6.2",
"stripe": "^18.4.0",
"stripe": "^18.5.0",
"tailwindcss": "^4.1.8",
"typescript": "^5.9.2",
"vite-plugin-svgr": "^4.5.0",
+21
View File
@@ -726,6 +726,27 @@ class OpenHands {
);
return data;
}
static async getMicroagentManagementConversations(
selectedRepository: string,
pageId?: string,
limit: number = 100,
): Promise<Conversation[]> {
const params: Record<string, string | number> = {
limit,
selected_repository: selectedRepository,
};
if (pageId) {
params.page_id = pageId;
}
const { data } = await openHands.get<ResultSet<Conversation>>(
"/api/microagent-management/conversations",
{ params },
);
return data.results;
}
}
export default OpenHands;
+1
View File
@@ -10,6 +10,7 @@ function PauseIcon() {
stroke="currentColor"
className="w-5 h-5"
>
<circle cx="12" cy="12" r="10" fill="#e5e7eb" />
<path
strokeLinecap="round"
strokeLinejoin="round"
@@ -9,6 +9,7 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
import { anchor } from "../markdown/anchor";
import { OpenHandsSourceType } from "#/types/core/base";
import { paragraph } from "../markdown/paragraph";
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
interface ChatMessageProps {
type: OpenHandsSourceType;
@@ -16,6 +17,7 @@ interface ChatMessageProps {
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
}
@@ -66,17 +68,35 @@ export function ChatMessage({
"items-center gap-1",
)}
>
{actions?.map((action, index) => (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
))}
{actions?.map((action, index) =>
action.tooltip ? (
<TooltipButton
key={index}
tooltip={action.tooltip}
ariaLabel={action.tooltip}
placement="top"
>
<button
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
</TooltipButton>
) : (
<button
key={index}
type="button"
onClick={action.onClick}
className="button-base p-1 cursor-pointer"
aria-label={`Action ${index + 1}`}
>
{action.icon}
</button>
),
)}
<CopyToClipboardButton
isHidden={!isHovering}
@@ -72,6 +72,9 @@ const getRecallObservationContent = (event: RecallObservation): string => {
if (event.extras.repo_instructions) {
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
}
if (event.extras.conversation_instructions) {
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
}
if (event.extras.additional_agent_instructions) {
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
}
@@ -46,6 +46,7 @@ interface EventMessageProps {
actions?: Array<{
icon: React.ReactNode;
onClick: () => void;
tooltip?: string;
}>;
isInLast10Actions: boolean;
}
@@ -1,4 +1,5 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { OpenHandsAction } from "#/types/core/actions";
import { OpenHandsObservation } from "#/types/core/observations";
@@ -62,6 +63,8 @@ export const Messages: React.FC<MessagesProps> = React.memo(
EventMicroagentStatus[]
>([]);
const { t } = useTranslation();
const actionHasObservationPair = React.useCallback(
(event: OpenHandsAction | OpenHandsObservation): boolean => {
if (isOpenHandsAction(event)) {
@@ -243,6 +246,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
setSelectedEventId(message.id);
setShowLaunchMicroagentModal(true);
},
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
},
]
: undefined
@@ -76,6 +76,10 @@ export function LaunchMicroagentModal({
</button>
</div>
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
{t("MICROAGENT$DEFINITION")}
</span>
<form
data-testid="launch-microagent-modal"
onSubmit={onSubmit}
@@ -277,6 +277,12 @@ export function MicroagentManagementContent() {
const repositoryName = repository.full_name;
const gitProvider = repository.git_provider;
const createMicroagent = {
repo: repositoryName,
git_provider: gitProvider,
title: formData.query,
};
// Launch a new conversation to help the user understand the repo
createConversationAndSubscribe({
query: formData.query,
@@ -286,6 +292,7 @@ export function MicroagentManagementContent() {
branch: formData.selectedBranch,
gitProvider,
},
createMicroagent,
onSuccessCallback: () => {
hideLearnThisRepoModal();
},
@@ -8,7 +8,7 @@ import { BrandButton } from "../settings/brand-button";
import { I18nKey } from "#/i18n/declaration";
import { RootState } from "#/store";
import XIcon from "#/icons/x.svg?react";
import { cn } from "#/utils/utils";
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
import { LearnThisRepoFormData } from "#/types/microagent-management";
import { Branch } from "#/types/git";
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
@@ -76,23 +76,25 @@ export function MicroagentManagementLearnThisRepoModal({
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!query.trim()) {
return;
}
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
onConfirm({
query: query.trim(),
query: finalQuery,
selectedBranch: selectedBranch?.name || "",
});
};
const handleConfirm = () => {
if (!query.trim()) {
return;
}
const finalQuery = getRepoMdCreatePrompt(
selectedRepository?.git_provider || "github",
query.trim(),
);
onConfirm({
query: query.trim(),
query: finalQuery,
selectedBranch: selectedBranch?.name || "",
});
};
@@ -244,7 +246,6 @@ export function MicroagentManagementLearnThisRepoModal({
onClick={handleConfirm}
testId="confirm-button"
isDisabled={
!query.trim() ||
isLoading ||
isLoadingBranches ||
!selectedBranch ||
@@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
import { GitRepository } from "#/types/git";
import { RootState } from "#/store";
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
@@ -42,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
data: conversations,
isLoading: isLoadingConversations,
isError: isErrorConversations,
} = useSearchConversations(
} = useMicroagentManagementConversations(
repositoryName,
"microagent_management",
undefined,
1000,
true,
);
@@ -0,0 +1,27 @@
import { useQuery } from "@tanstack/react-query";
import OpenHands from "#/api/open-hands";
export const useMicroagentManagementConversations = (
selectedRepository: string,
pageId?: string,
limit: number = 100,
cacheDisabled: boolean = false,
) =>
useQuery({
queryKey: [
"conversations",
"microagent-management",
pageId,
limit,
selectedRepository,
],
queryFn: () =>
OpenHands.getMicroagentManagementConversations(
selectedRepository,
pageId,
limit,
),
enabled: !!selectedRepository,
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
});
+2 -5
View File
@@ -131,7 +131,6 @@ export enum I18nKey {
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
@@ -479,7 +478,6 @@ export enum I18nKey {
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
PROJECT_MENU_CARD$OPEN = "PROJECT_MENU_CARD$OPEN",
ACTION_BUTTON$RESUME = "ACTION_BUTTON$RESUME",
ACTION_BUTTON$PAUSE = "ACTION_BUTTON$PAUSE",
BROWSER$SCREENSHOT_ALT = "BROWSER$SCREENSHOT_ALT",
ERROR_TOAST$CLOSE_BUTTON_LABEL = "ERROR_TOAST$CLOSE_BUTTON_LABEL",
FILE_EXPLORER$UPLOAD = "FILE_EXPLORER$UPLOAD",
@@ -518,7 +516,6 @@ export enum I18nKey {
STATUS$CONNECTED = "STATUS$CONNECTED",
BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED",
USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER",
ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
@@ -578,8 +575,6 @@ export enum I18nKey {
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
FEEDBACK$TITLE = "FEEDBACK$TITLE",
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
@@ -828,4 +823,6 @@ export enum I18nKey {
SETTINGS$SECURITY_ANALYZER_NONE = "SETTINGS$SECURITY_ANALYZER_NONE",
SETTINGS$SECURITY_ANALYZER_INVARIANT = "SETTINGS$SECURITY_ANALYZER_INVARIANT",
COMMON$HIGH_RISK = "COMMON$HIGH_RISK",
MICROAGENT$DEFINITION = "MICROAGENT$DEFINITION",
MICROAGENT$ADD_TO_MEMORY = "MICROAGENT$ADD_TO_MEMORY",
}
+46 -14
View File
@@ -1568,20 +1568,20 @@
"uk": "Максимальний розмір історії конденсатора пам'яті"
},
"SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP": {
"en": "After this many events, the condenser will summarize history. Minimum 10.",
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 10。",
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 10。",
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 10。",
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 10.",
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 10.",
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 10.",
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 10.",
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 10.",
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 10.",
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 10.",
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 10.",
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 10.",
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 10."
"en": "After this many events, the condenser will summarize history. Minimum 20.",
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 20。",
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 20。",
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 20。",
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 20.",
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 20.",
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 20.",
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 20.",
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 20.",
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 20.",
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 20.",
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 20.",
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 20.",
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20."
},
"SETTINGS$LANGUAGE": {
"en": "Language",
@@ -13166,5 +13166,37 @@
"tr": "Yüksek Risk",
"de": "Hohes Risiko",
"uk": "Високий ризик"
},
"MICROAGENT$DEFINITION": {
"en": "Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge. They provide expert guidance, automate common tasks, and ensure consistent practices across projects.",
"ja": "マイクロエージェントは、OpenHandsにドメイン固有の知識を追加するための専門的なプロンプトです。専門的なガイダンスを提供し、一般的なタスクを自動化し、プロジェクト全体で一貫した実践を保証します。",
"zh-CN": "微代理是增强 OpenHands 领域知识的专用提示。它们提供专家指导,自动化常见任务,并确保项目中的一致实践。",
"zh-TW": "微代理是增強 OpenHands 領域知識的專用提示。它們提供專家指導,自動化常見任務,並確保專案中的一致實踐。",
"ko-KR": "마이크로에이전트는 OpenHands에 도메인별 지식을 추가하는 특화된 프롬프트입니다. 전문가의 안내를 제공하고, 일반적인 작업을 자동화하며, 프로젝트 전반에 걸쳐 일관된 관행을 보장합니다.",
"no": "Mikroagenter er spesialiserte prompt som forbedrer OpenHands med domenespesifikk kunnskap. De gir ekspertråd, automatiserer vanlige oppgaver og sikrer konsistente praksiser på tvers av prosjekter.",
"it": "I microagenti sono prompt specializzati che arricchiscono OpenHands con conoscenze specifiche di dominio. Forniscono guida esperta, automatizzano attività comuni e garantiscono pratiche coerenti tra i progetti.",
"pt": "Microagentes são prompts especializados que aprimoram o OpenHands com conhecimento específico de domínio. Eles fornecem orientação especializada, automatizam tarefas comuns e garantem práticas consistentes em todos os projetos.",
"es": "Los microagentes son prompts especializados que mejoran OpenHands con conocimientos específicos de dominio. Proporcionan orientación experta, automatizan tareas comunes y aseguran prácticas consistentes en los proyectos.",
"ar": "الميكرووكلاء هم مطالبات متخصصة تعزز OpenHands بمعرفة متخصصة في المجال. يقدمون إرشادات خبراء، ويؤتمتون المهام الشائعة، ويضمنون ممارسات متسقة عبر المشاريع.",
"fr": "Les microagents sont des invites spécialisées qui enrichissent OpenHands avec des connaissances spécifiques au domaine. Ils fournissent des conseils d'experts, automatisent les tâches courantes et garantissent des pratiques cohérentes dans les projets.",
"tr": "Mikro ajanlar, OpenHands'i alanına özgü bilgilerle geliştiren özel istemlerdir. Uzman rehberliği sağlar, yaygın görevleri otomatikleştirir ve projeler arasında tutarlı uygulamalar sunar.",
"de": "Microagents sind spezialisierte Prompts, die OpenHands mit domänenspezifischem Wissen erweitern. Sie bieten fachkundige Anleitung, automatisieren gängige Aufgaben und sorgen für konsistente Praktiken in Projekten.",
"uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах."
},
"MICROAGENT$ADD_TO_MEMORY": {
"en": "Add to Microagent Memory",
"ja": "マイクロエージェントメモリに追加",
"zh-CN": "添加到微代理记忆",
"zh-TW": "加入微代理記憶體",
"ko-KR": "마이크로에이전트 메모리에 추가",
"no": "Legg til i mikroagentminne",
"it": "Aggiungi alla memoria del microagente",
"pt": "Adicionar à Memória do Microagente",
"es": "Agregar a la memoria del microagente",
"ar": "أضف إلى ذاكرة الميكرووكيل",
"fr": "Ajouter à la mémoire du microagent",
"tr": "Mikroajan Hafızasına Ekle",
"de": "Zur Microagent-Speicher hinzufügen",
"uk": "Додати до пам'яті мікроагента"
}
}
+8 -3
View File
@@ -186,9 +186,13 @@ function LlmSettingsScreen() {
const condenserMaxSizeStr = formData
.get("condenser-max-size-input")
?.toString();
const condenserMaxSize = condenserMaxSizeStr
const condenserMaxSizeRaw = condenserMaxSizeStr
? Number.parseInt(condenserMaxSizeStr, 10)
: undefined;
const condenserMaxSize =
condenserMaxSizeRaw !== undefined
? Math.max(20, condenserMaxSizeRaw)
: undefined;
const securityAnalyzer = formData
.get("security-analyzer-input")
@@ -322,8 +326,9 @@ function LlmSettingsScreen() {
const handleCondenserMaxSizeIsDirty = (value: string) => {
const parsed = value ? Number.parseInt(value, 10) : undefined;
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
const condenserMaxSizeIsDirty =
(parsed ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
(bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
(settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
setDirtyInputs((prev) => ({
...prev,
@@ -593,7 +598,7 @@ function LlmSettingsScreen() {
testId="condenser-max-size-input"
name="condenser-max-size-input"
type="number"
min={10}
min={20}
step={1}
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
defaultValue={(
+1
View File
@@ -127,6 +127,7 @@ export interface RecallObservation extends OpenHandsObservationEvent<"recall"> {
runtime_hosts?: Record<string, number>;
custom_secrets_descriptions?: Record<string, string>;
additional_agent_instructions?: string;
conversation_instructions?: string;
date?: string;
microagent_knowledge?: MicroagentKnowledge[];
};
+28
View File
@@ -244,3 +244,31 @@ export const extractRepositoryInfo = (
return { owner, repo, filePath };
};
/**
* Get the repository markdown creation prompt with additional PR creation instructions
* @param gitProvider The git provider to use for generating provider-specific text
* @param query Optional custom query to use instead of the default prompt
* @returns The complete prompt for creating repository markdown and PR instructions
*/
export const getRepoMdCreatePrompt = (
gitProvider: Provider,
query?: string,
): string => {
const providerName = getProviderName(gitProvider);
const pr = getPR(gitProvider === "gitlab");
const prShort = getPRShort(gitProvider === "gitlab");
return `Please explore this repository. Create the file .openhands/microagents/repo.md with:
${
query
? `- ${query}`
: `- A description of the project
- An overview of the file structure
- Any information on how to run tests or other relevant commands
- Any other information that would be helpful to a brand new developer
Keep it short--just a few paragraphs will do.`
}
Please push the changes to your branch on ${providerName} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`;
};
@@ -217,7 +217,7 @@ class BrowsingAgent(Agent):
messages.append(Message(role='user', content=[TextContent(text=prompt)]))
response = self.llm.completion(
messages=self.llm.format_messages_for_llm(messages),
messages=messages,
stop=[')```', ')\n```'],
)
return self.response_parser.parse(response)
@@ -204,7 +204,7 @@ class CodeActAgent(Agent):
initial_user_message = self._get_initial_user_message(state.history)
messages = self._get_messages(condensed_history, initial_user_message)
params: dict = {
'messages': self.llm.format_messages_for_llm(messages),
'messages': messages,
}
params['tools'] = check_tools(self.tools, self.llm.config)
params['extra_body'] = {
@@ -301,10 +301,8 @@ You are an agent trying to solve a web task based on the content of the page and
messages.append(Message(role='system', content=[TextContent(text=system_msg)]))
messages.append(Message(role='user', content=human_prompt))
flat_messages = self.llm.format_messages_for_llm(messages)
response = self.llm.completion(
messages=flat_messages,
messages=messages,
temperature=0.0,
stop=[')```', ')\n```'],
)
+1 -1
View File
@@ -522,7 +522,7 @@ def display_task_tracking_action(event: TaskTrackingAction) -> None:
"""Display a TaskTracking action in the CLI."""
# Display thought first if present
if hasattr(event, 'thought') and event.thought:
display_message(event.thought)
display_thought_if_new(event.thought)
# Format the command and task list for display
display_text = f'Command: {event.command}'
@@ -65,6 +65,24 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
def provider(self) -> str:
return ProviderType.BITBUCKET.value
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
"""Extract owner and repo from repository string.
Args:
repository: Repository name in format 'workspace/repo_slug'
Returns:
Tuple of (owner, repo)
Raises:
ValueError: If repository format is invalid
"""
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
return parts[-2], parts[-1]
async def get_latest_token(self) -> SecretStr | None:
"""Get latest working token of the user."""
return self.token
@@ -495,13 +513,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
self, repository: str
) -> Repository:
"""Gets all repository details from repository name."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
owner, repo = self._extract_owner_and_repo(repository)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
data, _ = await self._make_request(url)
@@ -510,13 +522,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
async def get_branches(self, repository: str) -> list[Branch]:
"""Get branches for a repository."""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repository.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repository}')
owner = parts[-2]
repo = parts[-1]
owner, repo = self._extract_owner_and_repo(repository)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
@@ -567,13 +573,7 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
Returns:
The URL of the created pull request
"""
# Extract owner and repo from the repository string (e.g., "owner/repo")
parts = repo_name.split('/')
if len(parts) < 2:
raise ValueError(f'Invalid repository name: {repo_name}')
owner = parts[-2]
repo = parts[-1]
owner, repo = self._extract_owner_and_repo(repo_name)
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
@@ -593,6 +593,21 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
# Return the URL to the pull request
return data.get('links', {}).get('html', {}).get('href', '')
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw Bitbucket API response for the pull request
"""
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
@@ -628,6 +643,40 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(response, file_path)
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a Bitbucket pull request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (OPEN), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# Bitbucket API response structure
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
if 'state' in pr_details:
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
return pr_details['state'] == 'OPEN'
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
bitbucket_service_cls = os.environ.get(
'OPENHANDS_BITBUCKET_SERVICE_CLS',
@@ -9,12 +9,16 @@ from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.github.queries import (
get_review_threads_graphql_query,
get_thread_comments_graphql_query,
get_thread_from_comment_graphql_query,
suggested_task_issue_graphql_query,
suggested_task_pr_graphql_query,
)
from openhands.integrations.service_types import (
BaseGitService,
Branch,
Comment,
GitService,
InstallationsService,
OwnerType,
@@ -672,6 +676,21 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
# Return the HTML URL of the created PR
return response['html_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The pull request number
Returns:
Raw GitHub API response for the pull request
"""
url = f'{self.BASE_URL}/repos/{repository}/pulls/{pr_number}'
pr_data, _ = await self._make_request(url)
return pr_data
async def get_microagent_content(
self, repository: str, file_path: str
) -> MicroagentContentResponse:
@@ -695,6 +714,258 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
# Parse the content to extract triggers from frontmatter
return self._parse_microagent_content(file_content, file_path)
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitHub PR is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (open), False if closed/merged
"""
try:
pr_details = await self.get_pr_details(repository, pr_number)
# GitHub API response structure
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
if 'state' in pr_details:
return pr_details['state'] == 'open'
elif 'merged' in pr_details and 'closed_at' in pr_details:
# Check if PR is merged or closed
return not (pr_details['merged'] or pr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitHub PR status for {repository}#{pr_number}. '
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitHub PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
async def get_issue_or_pr_comments(
self, repository: str, issue_number: int, max_comments: int = 10
) -> list[Comment]:
"""Get comments for an issue.
Args:
repository: Repository name in format 'owner/repo'
issue_number: The issue number
discussion_id: Not used for GitHub (kept for compatibility with GitLab)
Returns:
List of Comment objects ordered by creation date
"""
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}/comments'
page = 1
all_comments: list[dict] = []
while len(all_comments) < max_comments:
params = {
'per_page': 10,
'sort': 'created',
'direction': 'asc',
'page': page,
}
response, headers = await self._make_request(url, params=params)
all_comments.extend(response or [])
# Parse the Link header for rel="next"
link_header = headers.get('Link', '')
if 'rel="next"' not in link_header:
break
page += 1
return self._process_raw_comments(all_comments)
async def get_issue_or_pr_title_and_body(
self, repository: str, issue_number: int
) -> tuple[str, str]:
"""Get the title and body of an issue.
Args:
repository: Repository name in format 'owner/repo'
issue_number: The issue number
Returns:
A tuple of (title, body)
"""
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}'
response, _ = await self._make_request(url)
title = response.get('title') or ''
body = response.get('body') or ''
return title, body
async def get_review_thread_comments(
self,
comment_id: str,
repository: str,
pr_number: int,
) -> list[Comment]:
"""Get all comments in a review thread starting from a specific comment.
Uses GraphQL to traverse the reply chain from the given comment up to the root
comment, then finds the review thread and returns all comments in the thread.
Args:
comment_id: The GraphQL node ID of any comment in the thread
repo: Repository name
pr_number: Pull request number
Returns:
List of Comment objects representing the entire thread
"""
# Step 1: Use existing GraphQL query to get the comment and check for replyTo
variables = {'commentId': comment_id}
data = await self.execute_graphql_query(
get_thread_from_comment_graphql_query, variables
)
comment_node = data.get('data', {}).get('node')
if not comment_node:
return []
# Step 2: If replyTo exists, traverse to the root comment
root_comment_id = comment_id
reply_to = comment_node.get('replyTo')
if reply_to:
root_comment_id = reply_to['id']
# Step 3: Get all review threads and find the one containing our root comment
owner, repo = repository.split('/')
thread_id = None
after_cursor = None
has_next_page = True
while has_next_page and not thread_id:
threads_variables: dict[str, Any] = {
'owner': owner,
'repo': repo,
'number': pr_number,
'first': 50,
}
if after_cursor:
threads_variables['after'] = after_cursor
threads_data = await self.execute_graphql_query(
get_review_threads_graphql_query, threads_variables
)
review_threads_data = (
threads_data.get('data', {})
.get('repository', {})
.get('pullRequest', {})
.get('reviewThreads', {})
)
review_threads = review_threads_data.get('nodes', [])
page_info = review_threads_data.get('pageInfo', {})
# Search for the thread containing our root comment
for thread in review_threads:
first_comments = thread.get('comments', {}).get('nodes', [])
for first_comment in first_comments:
if first_comment.get('id') == root_comment_id:
thread_id = thread.get('id')
break
if thread_id:
break
# Update pagination variables
has_next_page = page_info.get('hasNextPage', False)
after_cursor = page_info.get('endCursor')
if not thread_id:
# Fallback: return just the comments we found during traversal
logger.warning(
f'Could not find review thread for comment {comment_id}, returning traversed comments'
)
return []
# Step 4: Get all comments from the review thread using the thread ID
all_thread_comments = []
after_cursor = None
has_next_page = True
while has_next_page:
comments_variables: dict[str, Any] = {}
comments_variables['threadId'] = thread_id
comments_variables['page'] = 50
if after_cursor:
comments_variables['after'] = after_cursor
thread_comments_data = await self.execute_graphql_query(
get_thread_comments_graphql_query, comments_variables
)
thread_node = thread_comments_data.get('data', {}).get('node')
if not thread_node:
break
comments_data = thread_node.get('comments', {})
comments_nodes = comments_data.get('nodes', [])
page_info = comments_data.get('pageInfo', {})
all_thread_comments.extend(comments_nodes)
has_next_page = page_info.get('hasNextPage', False)
after_cursor = page_info.get('endCursor')
return self._process_raw_comments(all_thread_comments)
def _truncate_comment(
self, comment_body: str, max_comment_length: int = 500
) -> str:
"""Truncate comment body to a maximum length."""
if len(comment_body) > max_comment_length:
return comment_body[:max_comment_length] + '...'
return comment_body
def _process_raw_comments(
self, comments_data: list, max_comments: int = 10
) -> list[Comment]:
"""Convert raw comment data to Comment objects."""
comments: list[Comment] = []
for comment in comments_data:
author = 'unknown'
if comment.get('author'):
author = comment.get('author', {}).get('login', 'unknown')
elif comment.get('user'):
author = comment.get('user', {}).get('login', 'unknown')
comments.append(
Comment(
id=str(comment.get('id', 'unknown')),
body=self._truncate_comment(comment.get('body', '')),
author=author,
created_at=datetime.fromisoformat(
comment.get('createdAt', '').replace('Z', '+00:00')
)
if comment.get('createdAt')
else datetime.fromtimestamp(0),
updated_at=datetime.fromisoformat(
comment.get('updatedAt', '').replace('Z', '+00:00')
)
if comment.get('updatedAt')
else datetime.fromtimestamp(0),
system=False,
)
)
# Sort comments by creation date to maintain chronological order
comments.sort(key=lambda c: c.created_at)
return comments[-max_comments:]
github_service_cls = os.environ.get(
'OPENHANDS_GITHUB_SERVICE_CLS',
+77
View File
@@ -45,3 +45,80 @@ suggested_task_issue_graphql_query = """
}
}
"""
get_thread_from_comment_graphql_query = """
query GetThreadFromComment($commentId: ID!) {
node(id: $commentId) {
... on PullRequestReviewComment {
id
body
author {
login
}
createdAt
updatedAt
replyTo {
id
body
author {
login
}
createdAt
updatedAt
}
}
}
}
"""
get_review_threads_graphql_query = """
query($owner: String!, $repo: String!, $number: Int!, $first: Int = 50, $after: String) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
reviewThreads(first: $first, after: $after) {
nodes {
id
path
isResolved
comments(first: 1) {
nodes {
id
databaseId
body
author {
login
}
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
}
"""
get_thread_comments_graphql_query = """
query ($threadId: ID!, $page: Int = 50, $after: String) {
node(id: $threadId) {
... on PullRequestReviewThread {
id
path
isResolved
comments(first: $page, after: $after) {
nodes {
id
databaseId
body
author { login }
createdAt
}
pageInfo { hasNextPage endCursor }
}
}
}
}
"""
@@ -5,6 +5,7 @@ from typing import Any
import httpx
from pydantic import SecretStr
from openhands.core.logger import openhands_logger as logger
from openhands.integrations.service_types import (
BaseGitService,
Branch,
@@ -626,6 +627,22 @@ class GitLabService(BaseGitService, GitService):
return response['web_url']
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific merge request
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
Raw GitLab API response for the merge request
"""
project_id = self._extract_project_id(repository)
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
mr_data, _ = await self._make_request(url)
return mr_data
def _extract_project_id(self, repository: str) -> str:
"""Extract project_id from repository name for GitLab API calls.
@@ -727,7 +744,7 @@ class GitLabService(BaseGitService, GitService):
continue
comment = Comment(
id=comment_data['id'],
id=str(comment_data['id']),
body=comment_data['body'],
author=comment_data.get('author', {}).get('username', 'unknown'),
created_at=datetime.fromisoformat(
@@ -749,6 +766,42 @@ class GitLabService(BaseGitService, GitService):
return all_comments
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a GitLab merge request is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The merge request number (iid)
Returns:
True if MR is active (opened), False if closed/merged
"""
try:
mr_details = await self.get_pr_details(repository, pr_number)
# GitLab API response structure
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
if 'state' in mr_details:
return mr_details['state'] == 'opened'
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
# Check if MR is merged or closed
return not (mr_details['merged_at'] or mr_details['closed_at'])
# If we can't determine the state, assume it's active (safer default)
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
)
return True
except Exception as e:
logger.warning(
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the MR status, include the conversation to be safe
return True
gitlab_service_cls = os.environ.get(
'OPENHANDS_GITLAB_SERVICE_CLS',
+57 -3
View File
@@ -1,8 +1,10 @@
from __future__ import annotations
import os
from types import MappingProxyType
from typing import Annotated, Any, Coroutine, Literal, cast, overload
import httpx
from pydantic import (
BaseModel,
ConfigDict,
@@ -28,6 +30,7 @@ from openhands.integrations.service_types import (
Repository,
ResourceNotFoundError,
SuggestedTask,
TokenResponse,
User,
)
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
@@ -112,6 +115,8 @@ class ProviderHandler:
external_auth_id: str | None = None,
external_auth_token: SecretStr | None = None,
external_token_manager: bool = False,
session_api_key: str | None = None,
sid: str | None = None,
):
if not isinstance(provider_tokens, MappingProxyType):
raise TypeError(
@@ -127,7 +132,13 @@ class ProviderHandler:
self.external_auth_id = external_auth_id
self.external_auth_token = external_auth_token
self.external_token_manager = external_token_manager
self.session_api_key = session_api_key
self.sid = sid
self._provider_tokens = provider_tokens
WEB_HOST = os.getenv('WEB_HOST', '').strip()
self.REFRESH_TOKEN_URL = (
f'https://{WEB_HOST}/api/refresh-tokens' if WEB_HOST else None
)
@property
def provider_tokens(self) -> PROVIDER_TOKEN_TYPE:
@@ -161,8 +172,24 @@ class ProviderHandler:
self, provider: ProviderType
) -> SecretStr | None:
"""Get latest token from service"""
service = self._get_service(provider)
return await service.get_latest_token()
try:
async with httpx.AsyncClient() as client:
resp = await client.get(
self.REFRESH_TOKEN_URL,
headers={
'X-Session-API-Key': self.session_api_key,
},
params={'provider': provider.value, 'sid': self.sid},
)
resp.raise_for_status()
data = TokenResponse.model_validate_json(resp.text)
return SecretStr(data.token)
except Exception as e:
logger.warning(f'Failed to fetch latest token for provider {provider}: {e}')
return None
async def get_github_installations(self) -> list[str]:
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
@@ -356,7 +383,7 @@ class ProviderHandler:
else SecretStr('')
)
if get_latest:
if get_latest and self.REFRESH_TOKEN_URL and self.sid:
token = await self._get_latest_provider_token(provider)
if token:
@@ -613,3 +640,30 @@ class ProviderHandler:
remote_url = f'https://{domain}/{repo_name}.git'
return remote_url
async def is_pr_open(
self, repository: str, pr_number: int, git_provider: ProviderType
) -> bool:
"""Check if a PR is still active (not closed/merged).
This method checks the PR status using the provider's service method.
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
git_provider: The Git provider type for this repository
Returns:
True if PR is active (open), False if closed/merged, True if can't determine
"""
try:
service = self._get_service(git_provider)
return await service.is_pr_open(repository, pr_number)
except Exception as e:
logger.warning(
f'Could not determine PR status for {repository}#{pr_number}: {e}. '
f'Including conversation to be safe.'
)
# If we can't determine the PR status, include the conversation to be safe
return True
+29 -1
View File
@@ -14,6 +14,10 @@ from openhands.microagent.types import MicroagentContentResponse, MicroagentResp
from openhands.server.types import AppMode
class TokenResponse(BaseModel):
token: str
class ProviderType(Enum):
GITHUB = 'github'
GITLAB = 'gitlab'
@@ -141,7 +145,7 @@ class Repository(BaseModel):
class Comment(BaseModel):
id: int
id: str
body: str
author: str
created_at: datetime
@@ -520,3 +524,27 @@ class GitService(Protocol):
MicroagentContentResponse with parsed content and triggers
"""
...
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
"""Get detailed information about a specific pull request/merge request
Args:
repository: Repository name in format specific to the provider
pr_number: The pull request/merge request number
Returns:
Raw API response from the git provider
"""
...
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
"""Check if a PR is still active (not closed/merged).
Args:
repository: Repository name in format 'owner/repo'
pr_number: The PR number to check
Returns:
True if PR is active (open), False if closed/merged
"""
...
@@ -1,22 +0,0 @@
You are requested to fix issue number #{{ issue_number }} in a repository.
A comment on the issue has been addressed to you.
# Steps to Handle the Comment
1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context
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.
When you're done, make sure to
1. Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements.
2. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
3. Commit your changes with a clear commit message
4. Push the branch to GitHub
5. Use the `create_pr` tool to open a new PR
6. The PR description should:
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
- Mention that it "fixes" or "closes" the issue number
- Include all required sections from the template
@@ -1 +0,0 @@
{{ issue_comment }}
@@ -0,0 +1,41 @@
{% if issue_number %}
You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository.
A comment on the issue has been addressed to you.
{% else %}
Your task is to fix the issue: "{{ issue_title }}".
{% endif %}
# Issue Body
{{ issue_body }}
{% if previous_comments %}
# Previous Comments
For reference, here are the previous comments on the issue:
{% for comment in previous_comments %}
- @{{ comment.author }} said:
{{ comment.body }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
{% endif %}
# Guidelines
1. Review the task carefully.
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.
# Final Checklist
Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements.
Use the $GITHUB_TOKEN and GitHub APIs to
1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
2. Commit your changes with a clear commit message
3. Push the branch to GitHub
4. Use the `create_pr` tool to open a new PR
5. The PR description should:
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
- Mention that it "fixes" or "closes" the issue number
- Include all required sections from the template
@@ -1,17 +0,0 @@
Your tasking is to fix an issue in your repository. Do the following
1. Read the issue body and comments using the $GITHUB_TOKEN and Github API
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.
When you're done, make sure to
1. Create a new branch with a descriptive name (e.g., `openhands/fix-issue-123`)
2. Commit your changes with a clear commit message
3. Push the branch to GitHub
4. Use the `create_pr` tool to open a new PR
5. The PR description should:
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
- Mention that it "fixes" or "closes" the issue number
- Include all required sections from the template
@@ -1 +0,0 @@
Please fix issue number #{{ issue_number }} in your repository.
@@ -0,0 +1,5 @@
{% if issue_comment %}
{{ issue_comment }}
{% else %}
Please fix issue number #{{ issue_number }}.
{% endif %}
@@ -1,7 +1,23 @@
You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}.
A comment on the PR has been addressed to you. Do NOT respond to this comment via the GitHub API.
You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}".
A comment on the PR has been addressed to you.
{% if file_location %} The comment is in the file `{{ file_location }}` on line #{{ line_number }}{% endif %}.
# PR Description
{{ pr_body }}
{% if comments %}
# Previous Comments
You may find these other comments relevant:
{% for comment in comments %}
- @{{ comment.author }} said at {{ comment.created_at }}:
{{ comment.body }}
{% if not loop.last %}\n\n{% endif %}
{% endfor %}
{% endif %}
{% if file_location %}
# Comment location
The comment is in the file `{{ file_location }}` on line #{{ line_number }}
{% endif %}.
# Steps to Handle the Comment
+15 -3
View File
@@ -3,7 +3,7 @@ import os
import time
import warnings
from functools import partial
from typing import Any, Callable
from typing import Any, Callable, cast
import httpx
@@ -220,7 +220,9 @@ class LLM(RetryMixin, DebugMixin):
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
from openhands.io import json
messages_kwarg: list[dict[str, Any]] | dict[str, Any] = []
messages_kwarg: (
dict[str, Any] | Message | list[dict[str, Any]] | list[Message]
) = []
mock_function_calling = not self.is_function_calling_active()
# some callers might send the model and messages directly
@@ -239,9 +241,19 @@ class LLM(RetryMixin, DebugMixin):
messages_kwarg = kwargs['messages']
# ensure we work with a list of messages
messages: list[dict[str, Any]] = (
messages_list = (
messages_kwarg if isinstance(messages_kwarg, list) else [messages_kwarg]
)
# Format Message objects to dict format if needed
messages: list[dict] = []
if messages_list and isinstance(messages_list[0], Message):
messages = self.format_messages_for_llm(
cast(list[Message], messages_list)
)
else:
messages = cast(list[dict[str, Any]], messages_list)
kwargs['messages'] = messages
# handle conversion of to non-function calling messages if needed
original_fncall_messages = copy.deepcopy(messages)
+2 -3
View File
@@ -84,6 +84,7 @@ FUNCTION_CALLING_PATTERNS: list[str] = [
'kimi-k2-instruct',
'qwen3-coder*',
'qwen3-coder-480b-a35b-instruct',
'deepseek-chat',
]
REASONING_EFFORT_PATTERNS: list[str] = [
@@ -98,9 +99,7 @@ REASONING_EFFORT_PATTERNS: list[str] = [
'o4-mini-2025-04-16',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gpt-5',
'gpt-5-2025-08-07',
'gpt-5-mini-2025-08-07',
'gpt-5*',
# DeepSeek reasoning family
'deepseek-r1-0528*',
]
+29 -27
View File
@@ -302,10 +302,12 @@ class ConversationMemory:
elif isinstance(action, MessageAction):
role = 'user' if action.source == 'user' else 'assistant'
content = [TextContent(text=action.content or '')]
if vision_is_active and action.image_urls:
if action.image_urls:
if role == 'user':
for idx, url in enumerate(action.image_urls):
content.append(TextContent(text=f'Image {idx + 1}:'))
# Only add descriptive text if vision is active
if vision_is_active:
content.append(TextContent(text=f'Image {idx + 1}:'))
content.append(ImageContent(image_urls=[url]))
else:
content.append(ImageContent(image_urls=action.image_urls))
@@ -414,8 +416,8 @@ class ConversationMemory:
# Create message content with text
content: list[TextContent | ImageContent] = [TextContent(text=text)]
# Add image URLs if available and vision is active
if vision_is_active and obs.image_urls:
# Add image URLs if available
if obs.image_urls:
# Filter out empty or invalid image URLs
valid_image_urls = [
url for url in obs.image_urls if self._is_valid_image_url(url)
@@ -424,7 +426,8 @@ class ConversationMemory:
if valid_image_urls:
content.append(ImageContent(image_urls=valid_image_urls))
if invalid_count > 0:
# Only add explanatory text if vision is active
if vision_is_active and invalid_count > 0:
# Add text indicating some images were filtered
content[
0
@@ -433,10 +436,12 @@ class ConversationMemory:
logger.debug(
'IPython observation has image URLs but none are valid'
)
# Add text indicating all images were filtered
content[
0
].text += f'\n\nNote: All {len(obs.image_urls)} image(s) in this output were invalid or empty and have been filtered. The agent should use alternative methods to access visual information.' # type: ignore[union-attr]
# Only add explanatory text if vision is active
if vision_is_active:
# Add text indicating all images were filtered
content[
0
].text += f'\n\nNote: All {len(obs.image_urls)} image(s) in this output were invalid or empty and have been filtered. The agent should use alternative methods to access visual information.' # type: ignore[union-attr]
message = Message(role='user', content=content)
elif isinstance(obs, FileEditObservation):
@@ -448,15 +453,21 @@ class ConversationMemory:
) # Content is already truncated by openhands-aci
elif isinstance(obs, BrowserOutputObservation):
text = obs.content
content = [TextContent(text=text)]
if (
obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE
and enable_som_visual_browsing
and vision_is_active
):
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. However, the Accessibility tree contains information from the entire webpage.)\n'
# Only add descriptive text if vision is active
if vision_is_active:
# We know content[0] is TextContent since we just created it above
text_content = content[0]
assert isinstance(text_content, TextContent)
text_content.text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. However, the Accessibility tree contains information from the entire webpage.)\n'
# Determine which image to use and validate it
image_url = None
image_type = None
if obs.set_of_marks is not None and len(obs.set_of_marks) > 0:
image_url = obs.set_of_marks
image_type = 'set of marks'
@@ -464,38 +475,29 @@ class ConversationMemory:
image_url = obs.screenshot
image_type = 'screenshot'
# Create message content with text
content = [TextContent(text=text)]
# Only add ImageContent if we have a valid image URL
# Always add ImageContent if we have a valid image URL
if self._is_valid_image_url(image_url):
content.append(ImageContent(image_urls=[image_url])) # type: ignore[list-item]
logger.debug(f'Vision enabled for browsing, showing {image_type}')
logger.debug(f'Adding {image_type} for browsing')
else:
if image_url:
if vision_is_active and image_url:
logger.warning(
f'Invalid image URL format for {image_type}: {image_url[:50]}...'
)
# Add text indicating the image was filtered
# Add text indicating the image was filtered (only if vision is active)
content[
0
].text += f'\n\nNote: The {image_type} for this webpage was invalid or empty and has been filtered. The agent should use alternative methods to access visual information about the webpage.' # type: ignore[union-attr]
else:
elif vision_is_active and not image_url:
logger.debug(
'Vision enabled for browsing, but no valid image available'
)
# Add text indicating no image was available
# Add text indicating no image was available (only if vision is active)
content[
0
].text += '\n\nNote: No visual information (screenshot or set of marks) is available for this webpage. The agent should rely on the text content above.' # type: ignore[union-attr]
message = Message(role='user', content=content)
else:
message = Message(
role='user',
content=[TextContent(text=text)],
)
logger.debug('Vision disabled for browsing, showing text')
message = Message(role='user', content=content)
elif isinstance(obs, AgentDelegateObservation):
text = truncate_content(
obs.outputs.get('content', obs.content),
+1 -4
View File
@@ -646,10 +646,7 @@ class ActionExecutor:
if __name__ == '__main__':
logger.debug('Starting Action Execution Server')
logger.debug('Arguments passed to script:')
for i, arg in enumerate(sys.argv):
logger.debug(f'Argument {i}: {arg}')
logger.warning('Starting Action Execution Server')
parser = argparse.ArgumentParser()
parser.add_argument('port', type=int, help='Port to listen on')
parser.add_argument('--working-dir', type=str, help='Working directory')
+33 -6
View File
@@ -323,9 +323,6 @@ class Runtime(FileEditRuntimeMixin):
async def _export_latest_git_provider_tokens(self, event: Action) -> None:
"""Refresh runtime provider tokens when agent attemps to run action with provider token"""
if not self.user_id:
return
providers_called = ProviderHandler.check_cmd_action_for_provider_token_ref(
event
)
@@ -333,8 +330,17 @@ class Runtime(FileEditRuntimeMixin):
if not providers_called:
return
provider_handler = ProviderHandler(
provider_tokens=self.git_provider_tokens
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
external_auth_id=self.user_id,
external_token_manager=True,
session_api_key=self.session_api_key,
sid=self.sid,
)
logger.info(f'Fetching latest provider tokens for runtime: {self.sid}')
env_vars = await self.provider_handler.get_env_vars(
env_vars = await provider_handler.get_env_vars(
providers=providers_called, expose_secrets=False, get_latest=True
)
@@ -343,10 +349,10 @@ class Runtime(FileEditRuntimeMixin):
try:
if self.event_stream:
await self.provider_handler.set_event_stream_secrets(
await provider_handler.set_event_stream_secrets(
self.event_stream, env_vars=env_vars
)
self.add_env_vars(self.provider_handler.expose_env_vars(env_vars))
self.add_env_vars(provider_handler.expose_env_vars(env_vars))
except Exception as e:
logger.warning(
f'Failed export latest github token to runtime: {self.sid}, {e}'
@@ -1142,6 +1148,27 @@ fi
self.git_handler.set_cwd(cwd)
return self.git_handler.get_git_diff(file_path)
def get_workspace_branch(self, primary_repo_path: str | None = None) -> str | None:
"""
Get the current branch of the workspace.
Args:
primary_repo_path: Path to the primary repository within the workspace.
If None, uses the workspace root.
Returns:
str | None: The current branch name, or None if not a git repository or error occurs.
"""
if primary_repo_path:
# Use the primary repository path
git_cwd = str(self.workspace_root / primary_repo_path)
else:
# Use the workspace root
git_cwd = str(self.workspace_root)
self.git_handler.set_cwd(git_cwd)
return self.git_handler.get_current_branch()
@property
def additional_agent_instructions(self) -> str:
return ''
-6
View File
@@ -1,5 +1,3 @@
import traceback
from openhands.core.config import OpenHandsConfig
from openhands.core.logger import openhands_logger as logger
from openhands.runtime.plugins import PluginRequirement
@@ -67,9 +65,5 @@ def get_action_execution_server_startup_command(
if not app_config.enable_browser:
base_cmd.append('--no-enable-browser')
logger.debug(f'get_action_execution_server_startup_command: {base_cmd}')
logger.debug(
'get_action_execution_server_startup_command stack:\n%s',
''.join(traceback.format_stack()),
)
return base_cmd
+24
View File
@@ -10,6 +10,7 @@ GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.p
GIT_DIFF_CMD = (
'python3 /openhands/code/openhands/runtime/utils/git_diff.py "{file_path}"'
)
GIT_BRANCH_CMD = 'git branch --show-current'
@dataclass
@@ -38,6 +39,7 @@ class GitHandler:
self.cwd: str | None = None
self.git_changes_cmd = GIT_CHANGES_CMD
self.git_diff_cmd = GIT_DIFF_CMD
self.git_branch_cmd = GIT_BRANCH_CMD
def set_cwd(self, cwd: str) -> None:
"""Sets the current working directory for Git operations.
@@ -55,6 +57,28 @@ class GitHandler:
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
return script_file
def get_current_branch(self) -> str | None:
"""
Retrieves the current branch name of the git repository.
Returns:
str | None: The current branch name, or None if not a git repository or error occurs.
"""
# If cwd is not set, return None
if not self.cwd:
return None
result = self.execute(self.git_branch_cmd, self.cwd)
if result.exit_code == 0:
branch = result.content.strip()
# git branch --show-current returns empty string if not on any branch (detached HEAD)
if branch:
return branch
return None
# If not a git repository or other error, return None
return None
def get_git_changes(self) -> list[dict[str, str]] | None:
"""Retrieves the list of changed files in Git repositories.
Examines each direct subdirectory of the workspace directory looking for git repositories
@@ -203,7 +203,7 @@ RUN \
# Install Playwright browsers in shared location accessible to all users
export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers && \
mkdir -p /opt/playwright-browsers && \
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install chromium && \
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
# Set proper permissions for shared access
chmod -R 755 /opt/playwright-browsers && \
# Create cache directories and symlinks for both users
@@ -308,24 +308,19 @@ COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openh
# Ensure openhands user/group and base dirs exist even when not building from scratch
USER root
RUN \
# Remove existing user/group by name or UID/GID 1000
if getent passwd openhands >/dev/null 2>&1; then userdel -r -f openhands || true; fi && \
if getent passwd 1000 >/dev/null 2>&1; then userdel -r -f "$(getent passwd 1000 | cut -d: -f1)" || true; fi && \
if getent group openhands >/dev/null 2>&1; then groupdel openhands || true; fi && \
if getent group 1000 >/dev/null 2>&1; then groupdel "$(getent group 1000 | cut -d: -f1)" || true; fi && \
\
# Recreate group with GID 1000
groupadd -g 1000 openhands && \
\
# Recreate user with UID 1000
useradd -u 1000 -g openhands -m -s /bin/bash openhands && \
\
# Ensure home and required directories exist
# Ensure group exists (prefer GID 1000 if available)
if ! getent group openhands >/dev/null 2>&1; then \
if getent group 1000 >/dev/null 2>&1; then groupadd openhands; else groupadd -g 1000 openhands; fi; \
fi && \
# Ensure user exists (prefer UID 1000 if available)
if ! id -u openhands >/dev/null 2>&1; then \
if getent passwd 1000 >/dev/null 2>&1; then useradd -m -s /bin/bash -g openhands openhands; else useradd -u 1000 -g openhands -m -s /bin/bash openhands; fi; \
fi && \
# Ensure home and required directories exist before later steps
mkdir -p /home/openhands && \
mkdir -p /openhands && \
mkdir -p $(dirname ${OPENVSCODE_SERVER_ROOT}) && \
\
# Ensure ownership is correct
# Ensure ownership is correct for all OpenHands paths
chown -R openhands:openhands /home/openhands || true && \
chown -R openhands:openhands /openhands || true
@@ -2,7 +2,7 @@ import asyncio
import time
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Callable, Iterable
from typing import Any, Callable, Iterable
import socketio
@@ -11,7 +11,9 @@ from openhands.core.config.openhands_config import OpenHandsConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.core.schema.observation import ObservationType
from openhands.events.action import MessageAction
from openhands.events.observation.commands import CmdOutputObservation
from openhands.events.stream import EventStreamSubscriber, session_exists
from openhands.llm.llm_registry import LLMRegistry
from openhands.runtime import get_runtime_cls
@@ -516,6 +518,18 @@ class StandaloneConversationManager(ConversationManager):
conversation.total_tokens = (
token_usage.prompt_tokens + token_usage.completion_tokens
)
# Check for branch changes if this is a git-related event
if event and self._is_git_related_event(event):
logger.info(
f'Git-related event detected, updating conversation branch for {conversation_id}',
extra={
'session_id': conversation_id,
'command': getattr(event, 'command', 'unknown'),
},
)
await self._update_conversation_branch(conversation)
default_title = get_default_conversation_title(conversation_id)
if (
conversation.title == default_title
@@ -548,6 +562,154 @@ class StandaloneConversationManager(ConversationManager):
await conversation_store.save_metadata(conversation)
def _is_git_related_event(self, event) -> bool:
"""
Determine if an event is related to git operations that could change the branch.
Args:
event: The event to check
Returns:
True if the event is git-related and could change the branch, False otherwise
"""
# Early return if event is None or not the correct type
if not event or not isinstance(event, CmdOutputObservation):
return False
# Check CmdOutputObservation for git commands that change branches
# We check the observation result, not the action request, to ensure the command actually succeeded
if (
event.observation == ObservationType.RUN
and event.metadata.exit_code == 0 # Only consider successful commands
):
command = event.command.lower()
# Check if any git command that changes branches is present anywhere in the command
# This handles compound commands like "cd workspace && git checkout feature-branch"
git_commands = [
'git checkout',
'git switch',
'git merge',
'git rebase',
'git reset',
'git branch',
]
is_git_related = any(git_cmd in command for git_cmd in git_commands)
if is_git_related:
logger.debug(
f'Detected git-related command: {command} with exit code {event.metadata.exit_code}',
extra={'command': command, 'exit_code': event.metadata.exit_code},
)
return is_git_related
return False
async def _update_conversation_branch(self, conversation: ConversationMetadata):
"""
Update the conversation's current branch if it has changed.
Args:
conversation: The conversation metadata to update
"""
try:
# Get the session and runtime for this conversation
session, runtime = self._get_session_and_runtime(
conversation.conversation_id
)
if not session or not runtime:
return
# Get the current branch from the workspace
current_branch = self._get_current_workspace_branch(
runtime, conversation.selected_repository
)
# Update branch if it has changed
if self._should_update_branch(conversation.selected_branch, current_branch):
self._update_branch_in_conversation(conversation, current_branch)
except Exception as e:
# Log an error that occurred during branch update
logger.warning(
f'Failed to update conversation branch: {e}',
extra={'session_id': conversation.conversation_id},
)
def _get_session_and_runtime(
self, conversation_id: str
) -> tuple[Session | None, Any | None]:
"""
Get the session and runtime for a conversation.
Args:
conversation_id: The conversation ID
Returns:
Tuple of (session, runtime) or (None, None) if not found
"""
session = self._local_agent_loops_by_sid.get(conversation_id)
if not session or not session.agent_session.runtime:
return None, None
return session, session.agent_session.runtime
def _get_current_workspace_branch(
self, runtime: Any, selected_repository: str | None
) -> str | None:
"""
Get the current branch from the workspace.
Args:
runtime: The runtime instance
selected_repository: The selected repository path or None
Returns:
The current branch name or None if not found
"""
# Extract the repository name from the full repository path
if not selected_repository:
primary_repo_path = None
else:
# Extract the repository name from the full path (e.g., "org/repo" -> "repo")
primary_repo_path = selected_repository.split('/')[-1]
return runtime.get_workspace_branch(primary_repo_path)
def _should_update_branch(
self, current_branch: str | None, new_branch: str | None
) -> bool:
"""
Determine if the branch should be updated.
Args:
current_branch: The current branch in conversation metadata
new_branch: The new branch from the workspace
Returns:
True if the branch should be updated, False otherwise
"""
return new_branch is not None and new_branch != current_branch
def _update_branch_in_conversation(
self, conversation: ConversationMetadata, new_branch: str | None
):
"""
Update the branch in the conversation metadata.
Args:
conversation: The conversation metadata to update
new_branch: The new branch name
"""
old_branch = conversation.selected_branch
conversation.selected_branch = new_branch
logger.info(
f'Branch changed from {old_branch} to {new_branch}',
extra={'session_id': conversation.conversation_id},
)
async def get_agent_loop_info(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
):
+151 -45
View File
@@ -79,6 +79,85 @@ from openhands.utils.conversation_summary import get_default_conversation_title
app = APIRouter(prefix='/api', dependencies=get_dependencies())
def _filter_conversations_by_age(
conversations: list[ConversationMetadata], max_age_seconds: int
) -> list:
"""Filter conversations by age, removing those older than max_age_seconds.
Args:
conversations: List of conversations to filter
max_age_seconds: Maximum age in seconds for conversations to be included
Returns:
List of conversations that meet the age criteria
"""
now = datetime.now(timezone.utc)
filtered_results = []
for conversation in conversations:
# Skip conversations without created_at or older than max_age
if not hasattr(conversation, 'created_at'):
continue
age_seconds = (
now - conversation.created_at.replace(tzinfo=timezone.utc)
).total_seconds()
if age_seconds > max_age_seconds:
continue
filtered_results.append(conversation)
return filtered_results
async def _build_conversation_result_set(
filtered_conversations: list, next_page_id: str | None
) -> ConversationInfoResultSet:
"""Build a ConversationInfoResultSet from filtered conversations.
This function handles the common logic of getting conversation IDs, connections,
agent loop info, and building the final result set.
Args:
filtered_conversations: List of filtered conversations
next_page_id: Next page ID for pagination
Returns:
ConversationInfoResultSet with the processed conversations
"""
conversation_ids = set(
conversation.conversation_id for conversation in filtered_conversations
)
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
filter_to_sids=conversation_ids
)
agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=conversation_ids
)
agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in agent_loop_info
}
result = ConversationInfoResultSet(
results=await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in connection_ids_to_conversation_ids.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in filtered_conversations
),
next_page_id=next_page_id,
)
return result
class InitSessionRequest(BaseModel):
repository: str | None = None
git_provider: ProviderType | None = None
@@ -220,22 +299,14 @@ async def search_conversations(
) -> ConversationInfoResultSet:
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Apply filters at API level
filtered_results = []
now = datetime.now(timezone.utc)
max_age = config.conversation_max_age_seconds
for conversation in conversation_metadata_result_set.results:
# Skip conversations without created_at or older than max_age
if not hasattr(conversation, 'created_at'):
continue
age_seconds = (
now - conversation.created_at.replace(tzinfo=timezone.utc)
).total_seconds()
if age_seconds > max_age:
continue
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Apply repository filter
if (
selected_repository is not None
@@ -250,38 +321,11 @@ async def search_conversations(
):
continue
filtered_results.append(conversation)
final_filtered_results.append(conversation)
conversation_ids = set(
conversation.conversation_id for conversation in filtered_results
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
filter_to_sids=conversation_ids
)
agent_loop_info = await conversation_manager.get_agent_loop_info(
filter_to_sids=conversation_ids
)
agent_loop_info_by_conversation_id = {
info.conversation_id: info for info in agent_loop_info
}
result = ConversationInfoResultSet(
results=await wait_all(
_get_conversation_info(
conversation=conversation,
num_connections=sum(
1
for conversation_id in connection_ids_to_conversation_ids.values()
if conversation_id == conversation.conversation_id
),
agent_loop_info=agent_loop_info_by_conversation_id.get(
conversation.conversation_id
),
)
for conversation in filtered_results
),
next_page_id=conversation_metadata_result_set.next_page_id,
)
return result
@app.get('/conversations/{conversation_id}')
@@ -725,3 +769,65 @@ def add_experiment_config_for_conversation(
return True
return False
@app.get('/microagent-management/conversations')
async def get_microagent_management_conversations(
selected_repository: str,
page_id: str | None = None,
limit: int = 20,
conversation_store: ConversationStore = Depends(get_conversation_store),
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
) -> ConversationInfoResultSet:
"""Get conversations for the microagent management page with pagination support.
This endpoint returns conversations with conversation_trigger = 'microagent_management'
and only includes conversations with active PRs. Pagination is supported.
Args:
page_id: Optional page ID for pagination
limit: Maximum number of results per page (default: 20)
selected_repository: Optional repository filter to limit results to a specific repository
conversation_store: Conversation store dependency
provider_tokens: Provider tokens for checking PR status
"""
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
# Apply age filter first using common function
filtered_results = _filter_conversations_by_age(
conversation_metadata_result_set.results, config.conversation_max_age_seconds
)
# Check if the last PR is active (not closed/merged)
provider_handler = ProviderHandler(provider_tokens)
# Apply additional filters
final_filtered_results = []
for conversation in filtered_results:
# Only include microagent_management conversations
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
continue
# Apply repository filter if specified
if conversation.selected_repository != selected_repository:
continue
if (
conversation.pr_number
and len(conversation.pr_number) > 0
and conversation.selected_repository
and conversation.git_provider
and not await provider_handler.is_pr_open(
conversation.selected_repository,
conversation.pr_number[-1], # Get the last PR number
conversation.git_provider,
)
):
# Skip this conversation if the PR is closed/merged
continue
final_filtered_results.append(conversation)
return await _build_conversation_result_set(
final_filtered_results, conversation_metadata_result_set.next_page_id
)
+2 -2
View File
@@ -110,8 +110,8 @@ class Settings(BaseModel):
def validate_condenser_max_size(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 10:
raise ValueError('condenser_max_size must be at least 10')
if v < 20:
raise ValueError('condenser_max_size must be at least 20')
return v
@field_serializer('secrets_store')
@@ -489,7 +489,7 @@ def test_amortized_forgetting_condenser_keeps_first_and_last_events():
events = [create_test_event(f'Event {i}', id=i) for i in range(max_size * 10)]
# To ensure the most recent event is always recorded, track it in a non-local variable udpated
# To ensure the most recent event is always recorded, track it in a non-local variable updated
# with a closure we'll pass to the view generator as a callback.
most_recent_event: Event | None = None
@@ -773,7 +773,7 @@ def test_structured_summary_condenser_keeps_first_and_summary_events(
i, max_size
)
# Ensure that the prefix is appropiately maintained
# Ensure that the prefix is appropriately maintained
assert view[:keep_first] == events[: min(keep_first, i + 1)]
# If we've condensed, ensure that the summary event is present
@@ -1574,8 +1574,12 @@ def test_process_ipython_observation_with_vision_disabled(
vision_is_active=False,
)
# Check that the message contains only text content
# Check that the message contains both text and image content
# (ImageContent is always included, filtering happens at Message serialization level)
assert len(messages) == 1
message = messages[0]
assert len(message.content) == 1
assert len(message.content) == 2
assert isinstance(message.content[0], TextContent)
assert isinstance(message.content[1], ImageContent)
# Check that NO explanatory text about filtered images was added when vision is disabled
assert 'invalid or empty image(s) were filtered' not in message.content[0].text
+15 -9
View File
@@ -1,5 +1,5 @@
from types import MappingProxyType
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from pydantic import SecretStr
@@ -213,22 +213,28 @@ async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir):
@pytest.mark.asyncio
async def test_export_latest_git_provider_tokens_token_update(runtime):
async def test_export_latest_git_provider_tokens_token_update(runtime, monkeypatch):
"""Test that token updates are handled correctly"""
# First export with initial token
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
await runtime._export_latest_git_provider_tokens(cmd)
# Update the token
# Ensure refresh-token flow is enabled in ProviderHandler
monkeypatch.setenv('WEB_HOST', 'example.com')
# Simulate that provider handler will now fetch a new token from refresh endpoint
new_token = 'new_test_token'
runtime.provider_handler._provider_tokens = MappingProxyType(
{ProviderType.GITHUB: ProviderToken(token=SecretStr(new_token))}
)
# Export again with updated token
await runtime._export_latest_git_provider_tokens(cmd)
# Patch ProviderHandler._get_latest_provider_token to return new SecretStr
with patch.object(
ProviderHandler,
'_get_latest_provider_token',
new=AsyncMock(return_value=SecretStr(new_token)),
):
# Export again with updated token runtime should fetch latest and update EventStream secrets
await runtime._export_latest_git_provider_tokens(cmd)
# Verify that the new token was exported
# Verify that the new token was exported to the event stream
assert runtime.event_stream.secrets == {'github_token': new_token}
@@ -0,0 +1,642 @@
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhands.integrations.provider import ProviderHandler
from openhands.server.data_models.conversation_info_result_set import (
ConversationInfoResultSet,
)
from openhands.server.routes.manage_conversations import (
get_microagent_management_conversations,
)
from openhands.storage.conversation.conversation_store import ConversationStore
from openhands.storage.data_models.conversation_metadata import (
ConversationMetadata,
ConversationTrigger,
)
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_success():
"""Test successful retrieval of microagent management conversations."""
# Mock data
page_id = 'test_page_123'
limit = 10
selected_repository = 'owner/repo'
# Create mock conversations
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id='next_page_456')
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_456'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400 # 24 hours
# Call the function with correct parameter order
result = await get_microagent_management_conversations(
selected_repository=selected_repository,
page_id=page_id,
limit=limit,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify the result
assert isinstance(result, ConversationInfoResultSet)
assert result.next_page_id == 'next_page_456'
# Verify conversation store was called correctly
mock_conversation_store.search.assert_called_once_with(page_id, limit)
# Verify provider handler was created with correct tokens
mock_provider_handler.is_pr_open.assert_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_results():
"""Test when no conversations match the criteria."""
# Mock conversation store with empty results
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with required selected_repository parameter
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify the result
assert isinstance(result, ConversationInfoResultSet)
assert result.next_page_id is None
assert len(result.results) == 0
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_by_repository():
"""Test filtering conversations by selected repository."""
# Create mock conversations with different repositories
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo1',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo2',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only repo1 should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with repository filter
result = await get_microagent_management_conversations(
selected_repository='owner/repo1',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only conversations from the specified repository are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
assert result.results[0].selected_repository == 'owner/repo1'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_by_trigger():
"""Test that only microagent_management conversations are returned."""
# Create mock conversations with different triggers
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.GUI, # Different trigger
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only microagent_management should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only microagent_management conversations are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
assert result.results[0].trigger == ConversationTrigger.MICROAGENT_MANAGEMENT
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_filter_inactive_pr():
"""Test filtering out conversations with inactive PRs."""
# Create mock conversations
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
ConversationMetadata(
conversation_id='conv_2',
user_id='user_2',
title='Test Conversation 2',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler with one active and one inactive PR
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only active PR should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[mock_conversations[0]], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only conversations with active PRs are returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
# Verify provider handler was called for both PRs
assert mock_provider_handler.is_pr_open.call_count == 2
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_pr_number():
"""Test conversations without PR numbers are included."""
# Create mock conversations without PR numbers
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository='owner/repo',
git_provider='github',
pr_number=[], # No PR number
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=mock_conversations, next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify conversation without PR number is included
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_1'
# Verify provider handler was not called (no PR to check)
mock_provider_handler.is_pr_open.assert_not_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_no_repository():
"""Test conversations without selected repository are filtered out for PR checks."""
# Create mock conversations without repository
mock_conversations = [
ConversationMetadata(
conversation_id='conv_1',
user_id='user_1',
title='Test Conversation 1',
selected_repository=None, # No repository
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=datetime.now(timezone.utc),
last_updated_at=datetime.now(timezone.utc),
),
]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - conversation should be filtered out due to repository mismatch
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify conversation without repository is filtered out
assert len(result.results) == 0
# Verify provider handler was not called (no repository for PR check)
mock_provider_handler.is_pr_open.assert_not_called()
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_age_filter():
"""Test that conversations are filtered by age."""
# Create mock conversations with different ages
now = datetime.now(timezone.utc)
old_conversation = ConversationMetadata(
conversation_id='conv_old',
user_id='user_1',
title='Old Conversation',
selected_repository='owner/repo',
git_provider='github',
pr_number=['123'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=now.replace(year=now.year - 1), # Very old
last_updated_at=now.replace(year=now.year - 1),
)
recent_conversation = ConversationMetadata(
conversation_id='conv_recent',
user_id='user_2',
title='Recent Conversation',
selected_repository='owner/repo',
git_provider='github',
pr_number=['456'],
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
created_at=now, # Recent
last_updated_at=now,
)
mock_conversations = [old_conversation, recent_conversation]
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=mock_conversations, next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
# Mock provider handler
mock_provider_handler = MagicMock(spec=ProviderHandler)
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
with (
patch(
'openhands.server.routes.manage_conversations.ProviderHandler',
return_value=mock_provider_handler,
),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function - only recent conversation should be included
mock_build_result.return_value = ConversationInfoResultSet(
results=[recent_conversation], next_page_id=None
)
# Mock config with short max age
mock_config.conversation_max_age_seconds = 3600 # 1 hour
# Call the function
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify only recent conversation is returned
assert len(result.results) == 1
assert result.results[0].conversation_id == 'conv_recent'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_pagination():
"""Test pagination functionality."""
# Mock conversation store with pagination
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id='next_page_789')
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id='next_page_789'
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function with pagination parameters
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
page_id='test_page',
limit=5,
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify pagination parameters were passed correctly
mock_conversation_store.search.assert_called_once_with('test_page', 5)
assert result.next_page_id == 'next_page_789'
@pytest.mark.asyncio
async def test_get_microagent_management_conversations_default_parameters():
"""Test default parameter values."""
# Mock conversation store
mock_conversation_store = MagicMock(spec=ConversationStore)
mock_conversation_store.search = AsyncMock(
return_value=MagicMock(results=[], next_page_id=None)
)
# Mock provider tokens
mock_provider_tokens = {'github': 'token_123'}
with (
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
patch(
'openhands.server.routes.manage_conversations._build_conversation_result_set'
) as mock_build_result,
patch('openhands.server.routes.manage_conversations.config') as mock_config,
):
# Mock the build result function
mock_build_result.return_value = ConversationInfoResultSet(
results=[], next_page_id=None
)
# Mock config
mock_config.conversation_max_age_seconds = 86400
# Call the function without parameters (selected_repository is required)
result = await get_microagent_management_conversations(
selected_repository='owner/repo',
conversation_store=mock_conversation_store,
provider_tokens=mock_provider_tokens,
)
# Verify default values were used
mock_conversation_store.search.assert_called_once_with(None, 20)
assert isinstance(result, ConversationInfoResultSet)