mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cedd94009b | |||
| 23713bfe8c | |||
| 81829289ab | |||
| 9709431874 | |||
| 0e9906f41e | |||
| 9ac9a47207 | |||
| 75653e805a | |||
| 9630b536cd | |||
| 6f5c8186b8 | |||
| 36e0d8d3da | |||
| e68abf8d75 | |||
| 93ef1b0cda | |||
| 77b5c6b161 | |||
| 57aa7d5c12 | |||
| 50391ecdf3 | |||
| 672650d3d9 | |||
| 9afedea170 | |||
| c0bb84dfa2 | |||
| 18b5139237 | |||
| 4849369ede | |||
| b082ccc0fb | |||
| b0007076c0 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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())
|
||||
+30
-29
@@ -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
|
||||
|
||||
Generated
+246
-52
@@ -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
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
+7
@@ -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();
|
||||
},
|
||||
|
||||
+11
-10
@@ -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 ||
|
||||
|
||||
+3
-3
@@ -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
|
||||
});
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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": "Додати до пам'яті мікроагента"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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={(
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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```'],
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
...
|
||||
|
||||
-22
@@ -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
|
||||
-17
@@ -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 %}
|
||||
+19
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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*',
|
||||
]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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 ''
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user