mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aca425faa1 | |||
| 4d036e21ca | |||
| 287bd90222 | |||
| 391ba1d988 | |||
| 70f469b0c1 | |||
| 8a5c6d3bef | |||
| 998e04e51b | |||
| da7041b5e9 | |||
| e4b7b31f48 | |||
| 587c53f115 | |||
| 4d76f31610 |
@@ -4,7 +4,7 @@ const swaggerUiDist = require('swagger-ui-dist');
|
||||
|
||||
/**
|
||||
* This script manually sets up Swagger UI for the Docusaurus documentation.
|
||||
*
|
||||
*
|
||||
* Why we need this approach:
|
||||
* 1. Docusaurus doesn't have a built-in way to integrate Swagger UI
|
||||
* 2. We need to copy the necessary files from swagger-ui-dist to our static directory
|
||||
@@ -26,15 +26,15 @@ const files = fs.readdirSync(swaggerUiDistPath);
|
||||
files.forEach(file => {
|
||||
const sourcePath = path.join(swaggerUiDistPath, file);
|
||||
const targetPath = path.join(targetDir, file);
|
||||
|
||||
|
||||
// Skip directories and non-essential files
|
||||
if (fs.statSync(sourcePath).isDirectory() ||
|
||||
file === 'package.json' ||
|
||||
if (fs.statSync(sourcePath).isDirectory() ||
|
||||
file === 'package.json' ||
|
||||
file === 'README.md' ||
|
||||
file.endsWith('.map')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
fs.copyFileSync(sourcePath, targetPath);
|
||||
});
|
||||
|
||||
@@ -54,13 +54,13 @@ const indexHtml = `
|
||||
overflow: -moz-scrollbars-vertical;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
|
||||
*,
|
||||
*:before,
|
||||
*:after {
|
||||
box-sizing: inherit;
|
||||
}
|
||||
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #fafafa;
|
||||
@@ -99,4 +99,4 @@ const indexHtml = `
|
||||
|
||||
fs.writeFileSync(path.join(targetDir, 'index.html'), indexHtml);
|
||||
|
||||
console.log('Swagger UI files generated successfully in static/swagger-ui/');
|
||||
console.log('Swagger UI files generated successfully in static/swagger-ui/');
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
# Repository Customization
|
||||
|
||||
You can customize how OpenHands works with your repository by creating a
|
||||
You can customize how OpenHands interacts with your repository by creating a
|
||||
`.openhands` directory at the root level.
|
||||
|
||||
## Microagents
|
||||
You can use microagents to extend the OpenHands prompts with information
|
||||
about your project and how you want OpenHands to work. See
|
||||
[Repository Microagents](../prompting/microagents-repo) for more information.
|
||||
|
||||
Microagents allow you to extend OpenHands prompts with information specific to your project and define how OpenHands
|
||||
should function. See [Microagents Overview](../prompting/microagents-overview) for more information.
|
||||
|
||||
|
||||
## Setup Script
|
||||
You can add `.openhands/setup.sh`, which will be run every time OpenHands begins
|
||||
working with your repository. This is a good place to install dependencies, set
|
||||
environment variables, etc.
|
||||
You can add a `.openhands/setup.sh` file, which will run every time OpenHands begins working with your repository.
|
||||
This is an ideal location for installing dependencies, setting environment variables, and performing other setup tasks.
|
||||
|
||||
For example:
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Keyword-Triggered Microagents
|
||||
|
||||
## Purpose
|
||||
|
||||
Keyword-triggered microagents provide OpenHands with specific instructions that are activated when certain keywords
|
||||
appear in the prompt. This is useful for tailoring behavior based on particular tools, languages, or frameworks.
|
||||
|
||||
## Microagent File
|
||||
|
||||
Create a keyword-triggered microagent (example: `.openhands/microagents/trigger-keyword.md`) to include instructions
|
||||
that activate only for prompts with specific keywords.
|
||||
|
||||
## Frontmatter Syntax
|
||||
|
||||
Frontmatter is required for keyword-triggered microagents. It must be placed at the top of the file,
|
||||
above the guidelines.
|
||||
|
||||
Enclose the frontmatter in triple dashes (---) and include the following fields:
|
||||
|
||||
| Field | Description | Required | Default |
|
||||
|------------|--------------------------------------------------|----------|------------------|
|
||||
| `name` | A unique identifier for the microagent. | Yes | 'default' |
|
||||
| `type` | Type of microagent. Must be set to `knowledge`. | Yes | 'repo' |
|
||||
| `triggers` | A list of keywords that activate the microagent. | Yes | None |
|
||||
| `agent` | The agent this microagent applies to. | No | 'CodeActAgent' |
|
||||
|
||||
|
||||
## Example
|
||||
|
||||
```
|
||||
---
|
||||
name: magic_word
|
||||
type: knowledge
|
||||
triggers:
|
||||
- yummyhappy
|
||||
- happyyummy
|
||||
agent: CodeActAgent
|
||||
---
|
||||
|
||||
The user has said the magic word. Respond with "That was delicious!"
|
||||
```
|
||||
|
||||
Keyword-triggered microagents:
|
||||
- Monitor incoming prompts for specified trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow defined guidelines and restrictions.
|
||||
|
||||
[See examples of microagents triggered by keywords in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
@@ -1,31 +1,40 @@
|
||||
# Microagents Overview
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge, repository-specific context
|
||||
and task-specific workflows. They help by providing expert guidance, automating common tasks, and ensuring
|
||||
consistent practices across projects.
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge.
|
||||
They provide expert guidance, automate common tasks, and ensure consistent practices across projects.
|
||||
|
||||
## Microagent Categories
|
||||
## Microagent Types
|
||||
|
||||
Currently OpenHands supports two categories of microagents:
|
||||
Currently OpenHands supports the following types of microagents:
|
||||
|
||||
- [Repository-specific Microagents](./microagents-repo): Repository-specific context and guidelines for OpenHands.
|
||||
- [Public Microagents](./microagents-public): General guidelines triggered by keywords for all OpenHands users.
|
||||
- [General Repository Microagents](./microagents-repo): General guidelines for OpenHands about the repository.
|
||||
- [Keyword-Triggered Microagents](./microagents-keyword): Guidelines activated by specific keywords in prompts.
|
||||
|
||||
A microagent is classified as repository-specific or public depending on its location:
|
||||
To customize OpenHands' behavior, create a .openhands/microagents/ directory in the root of your repository and
|
||||
add `<microagent_name>.md` files inside.
|
||||
|
||||
- Repository-specific microagents are located in a repository's `.openhands/microagents/` directory
|
||||
- Public microagents are located in the official OpenHands repository inside the `/microagents` folder
|
||||
:::note
|
||||
Loaded microagents take up space in the context window.
|
||||
These microagents, alongside user messages, inform OpenHands about the task and the environment.
|
||||
:::
|
||||
|
||||
When OpenHands works with a repository, it:
|
||||
Example repository structure:
|
||||
|
||||
1. Loads **repository-specific** microagents from `.openhands/microagents/` if present in the repository.
|
||||
2. Loads **public knowledge** microagents triggered by keywords in conversations
|
||||
3. Loads **public tasks** microagents when explicitly requested by the user
|
||||
```
|
||||
some-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── repo.md # General repository guidelines
|
||||
└── trigger_this.md # Microagent triggered by specific keywords
|
||||
└── trigger_that.md # Microagent triggered by specific keywords
|
||||
```
|
||||
|
||||
You can check out the existing public microagents at the [official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/).
|
||||
## Microagents Frontmatter Requirements
|
||||
|
||||
## Microagent Format
|
||||
Each microagent file may include frontmatter that provides additional information. In some cases, this frontmatter
|
||||
is required:
|
||||
|
||||
All microagents use markdown files with YAML frontmatter that have special instructions to help OpenHands activate them.
|
||||
|
||||
Check out the [syntax documentation](./microagents-syntax) for a comprehensive guide on how to configure your microagents.
|
||||
| Microagent Type | Frontmatter Requirement |
|
||||
|----------------------------------|-------------------------------------------------------|
|
||||
| `General Repository Microagents` | Required only if more than one of this type exists. |
|
||||
| `Keyword-Triggered Microagents` | Required. |
|
||||
|
||||
@@ -1,35 +1,16 @@
|
||||
# Public Microagents
|
||||
# Global Microagents
|
||||
|
||||
## Overview
|
||||
|
||||
Public microagents provide specialized context and capabilities for all OpenHands users, regardless of their repository configuration. Unlike repository-specific microagents, public microagents are globally available across all repositories.
|
||||
Global microagents are [keyword-triggered microagents](./microagents-keyword) that apply to all OpenHands users.
|
||||
|
||||
Public microagents come in two types:
|
||||
## Contributing a Global Microagent
|
||||
|
||||
- **Knowledge microagents**: Automatically activated when keywords in conversations match their triggers
|
||||
- **Task microagents**: Explicitly invoked by users to guide through specific workflows
|
||||
|
||||
Both types follow the same syntax and structure as repository-specific microagents, using markdown files with YAML frontmatter that define their behavior and capabilities. They are located in the official OpenHands repository under:
|
||||
|
||||
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
|
||||
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
|
||||
|
||||
Public microagents:
|
||||
|
||||
- Monitor incoming commands for their trigger words.
|
||||
- Activate when relevant triggers are detected.
|
||||
- Apply their specialized knowledge and capabilities.
|
||||
- Follow their specific guidelines and restrictions.
|
||||
|
||||
When loading public microagents, OpenHands scans the official repository's microagents directories recursively, processing all markdown files except README.md. The system categorizes each microagent based on its `type` field in the YAML frontmatter, regardless of its exact file location within the knowledge or tasks directories.
|
||||
|
||||
## Contributing a Public Microagent
|
||||
|
||||
You can create public microagents and share with the community by opening a pull request to the official repository.
|
||||
You can create global microagents and share with the community by opening a pull request to the official repository.
|
||||
|
||||
See the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CONTRIBUTING.md) for specific instructions on how to contribute to OpenHands.
|
||||
|
||||
### Public Microagents Best Practices
|
||||
### Global Microagents Best Practices
|
||||
|
||||
- **Clear Scope**: Keep the microagent focused on a specific domain or task.
|
||||
- **Explicit Instructions**: Provide clear, unambiguous guidelines.
|
||||
@@ -37,11 +18,11 @@ See the [CONTRIBUTING.md](https://github.com/All-Hands-AI/OpenHands/blob/main/CO
|
||||
- **Safety First**: Include necessary warnings and constraints.
|
||||
- **Integration Awareness**: Consider how the microagent interacts with other components.
|
||||
|
||||
### Steps to Contribute a Public Microagent
|
||||
### Steps to Contribute a Global Microagent
|
||||
|
||||
#### 1. Plan the Public Microagent
|
||||
#### 1. Plan the Global Microagent
|
||||
|
||||
Before creating a public microagent, consider:
|
||||
Before creating a global microagent, consider:
|
||||
|
||||
- What specific problem or use case will it address?
|
||||
- What unique capabilities or knowledge should it have?
|
||||
@@ -51,23 +32,19 @@ Before creating a public microagent, consider:
|
||||
#### 2. Create File
|
||||
|
||||
Create a new Markdown file with a descriptive name in the appropriate directory:
|
||||
[`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
|
||||
- [`microagents/knowledge/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge) for knowledge microagents
|
||||
- [`microagents/tasks/`](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks) for task microagents
|
||||
#### 3. Testing the Global Microagent
|
||||
|
||||
Ensure it follows the correct [syntax](./microagents-syntax.md) and [best practices](./microagents-syntax.md#markdown-content-best-practices).
|
||||
|
||||
#### 3. Testing the Public Microagent
|
||||
|
||||
- Test the agent with various prompts
|
||||
- Verify trigger words activate the agent correctly
|
||||
- Ensure instructions are clear and comprehensive
|
||||
- Check for potential conflicts and overlaps with existing agents
|
||||
- Test the agent with various prompts.
|
||||
- Verify trigger words activate the agent correctly.
|
||||
- Ensure instructions are clear and comprehensive.
|
||||
- Check for potential conflicts and overlaps with existing agents.
|
||||
|
||||
#### 4. Submission Process
|
||||
|
||||
Submit a pull request with:
|
||||
|
||||
- The new microagent file
|
||||
- Updated documentation if needed
|
||||
- Description of the agent's purpose and capabilities
|
||||
- The new microagent file.
|
||||
- Updated documentation if needed.
|
||||
- Description of the agent's purpose and capabilities.
|
||||
|
||||
@@ -1,117 +1,38 @@
|
||||
# Repository-specific Microagents
|
||||
# General Repository Microagents
|
||||
|
||||
## Overview
|
||||
## Purpose
|
||||
|
||||
OpenHands can be customized to work more effectively with specific repositories by providing repository-specific context and guidelines.
|
||||
General guidelines for OpenHands to work more effectively with the repository.
|
||||
|
||||
This section explains how to optimize OpenHands for your project.
|
||||
## Microagent File
|
||||
|
||||
## Creating Repository Microagents
|
||||
Create a general repository microagent (example: `.openhands/microagents/repo.md`) to include
|
||||
project-specific instructions, team practices, coding standards, and architectural guidelines that are relevant for
|
||||
**all** prompts in that repository.
|
||||
|
||||
You can customize OpenHands' behavior for your repository by creating a `.openhands/microagents/` directory in your repository's root.
|
||||
## Frontmatter Syntax
|
||||
|
||||
You can enhance OpenHands' performance by adding custom microagents to your repository:
|
||||
The frontmatter for this type of microagent is optional, unless you plan to include more than one general
|
||||
repository microagent.
|
||||
|
||||
1. For overall repository-specific instructions, create a `.openhands/microagents/repo.md` file
|
||||
2. For reusable domain knowledge triggered by keywords, add multiple `.md` files to `.openhands/microagents/knowledge/`
|
||||
3. For common workflows and tasks, create multiple `.md` files to `.openhands/microagents/tasks/`
|
||||
Frontmatter should be enclosed in triple dashes (---) and may include the following fields:
|
||||
|
||||
Check out the [best practices](./microagents-syntax.md#markdown-content-best-practices) for formatting the content of your custom microagent.
|
||||
| Field | Description | Required | Default |
|
||||
|-----------|-----------------------------------------|--------------------------------------------------------------------|----------------|
|
||||
| `name` | A unique identifier for the microagent | Required only if using more than one general repository microagent | 'default' |
|
||||
| `agent` | The agent this microagent applies to | No | 'CodeActAgent' |
|
||||
|
||||
Keep in mind that loaded microagents take up space in the context window. It's crucial to strike a balance between the additional context provided by microagents and the instructions provided in the user's inputs.
|
||||
|
||||
Note that you can use OpenHands to create new microagents. The public microagent [`add_agent`](https://github.com/All-Hands-AI/OpenHands/blob/main/microagents/knowledge/add_agent.md) is loaded to all OpenHands instance and can support you on this.
|
||||
|
||||
## Types of Microagents
|
||||
|
||||
OpenHands supports three primary types of microagents, each with specific purposes and features to enhance agent performance:
|
||||
|
||||
- [repository](#repository-microagents)
|
||||
- [knowledge](#knowledge-microagents)
|
||||
- [tasks](#tasks-microagents)
|
||||
|
||||
The standard directory structure within a repository is:
|
||||
|
||||
- One main `repo.md` file containing repository-specific instructions
|
||||
- Additional `Knowledge` agents in `.openhands/microagents/knowledge/` directory
|
||||
- Additional `Task` agents in `.openhands/microagents/tasks/` directory
|
||||
|
||||
When processing the `.openhands/microagents/` directory, OpenHands will recursively scan all subfolders and process any `.md` files (except `README.md`) it finds. The system determines the microagent type based on the `type` field in the YAML frontmatter, not by the file's location. However, for organizational clarity, it's recommended to follow the standard directory structure.
|
||||
|
||||
### Repository Microagents
|
||||
|
||||
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
|
||||
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
|
||||
without requiring any keyword matching or explicit call from the user.
|
||||
|
||||
OpenHands does not support multiple `repo.md` files in different locations or multiple microagents with type `repo`.
|
||||
|
||||
If you need to organize different types of repository information, the recommended approach is to use a single `repo.md` file with well-structured sections rather than trying to create multiple microagents with the type `repo`.
|
||||
|
||||
The best practice is to include project-specific instructions, team practices, coding standards, and architectural guidelines that are relevant for **all** prompts in that repository.
|
||||
|
||||
Example structure:
|
||||
## Example
|
||||
|
||||
```
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── repo.md # Repository-specific instructions
|
||||
---
|
||||
name: repo
|
||||
---
|
||||
|
||||
This project is a TODO application that allows users to track TODO items.
|
||||
|
||||
To set it up, you can run `npm run build`.
|
||||
Always make sure the tests are passing before committing changes. You can run the tests by running `npm run test`.
|
||||
```
|
||||
|
||||
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
|
||||
|
||||
### Knowledge Microagents
|
||||
|
||||
Knowledge microagents provide specialized domain expertise:
|
||||
|
||||
- Recommended to be located in `.openhands/microagents/knowledge/`
|
||||
- Triggered by specific keywords in conversations
|
||||
- Contain expertise on tools, languages, frameworks, and common practices
|
||||
|
||||
Use knowledge microagents to trigger additional context relevant to specific technologies, tools, or workflows. For example, mentioning "git" in your conversation will automatically trigger git-related expertise to help with Git operations.
|
||||
|
||||
Examples structure:
|
||||
|
||||
```
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── knowledge/
|
||||
└── git.md
|
||||
└── docker.md
|
||||
└── python.md
|
||||
└── ...
|
||||
└── repo.md
|
||||
```
|
||||
|
||||
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
|
||||
### Tasks Microagents
|
||||
|
||||
Task microagents guide users through interactive workflows:
|
||||
|
||||
- Recommended to be located in `.openhands/microagents/tasks/`
|
||||
- Provide step-by-step processes for common development tasks
|
||||
- Accept inputs and adapt to different scenarios
|
||||
- Ensure consistent outcomes for complex operations
|
||||
|
||||
Task microagents are a convenient way to store multi-step processes you perform regularly. For instance, you can create a `update_pr_description.md` microagent to automatically generate better pull request descriptions based on code changes.
|
||||
|
||||
Examples structure:
|
||||
|
||||
```
|
||||
your-repository/
|
||||
└── .openhands/
|
||||
└── microagents/
|
||||
└── tasks/
|
||||
└── update_pr_description.md
|
||||
└── address_pr_comments.md
|
||||
└── get_test_to_pass.md
|
||||
└── ...
|
||||
└── knowledge/
|
||||
└── ...
|
||||
└── repo.md
|
||||
```
|
||||
|
||||
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
|
||||
[See more examples of general repository microagents here.](https://github.com/All-Hands-AI/OpenHands/tree/main/.openhands/microagents)
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
# Microagents Syntax
|
||||
|
||||
Microagents are defined using markdown files with YAML frontmatter that specify their behavior, triggers, and capabilities.
|
||||
|
||||
Find below a comprehensive description of the frontmatter syntax and other details about how to use each type of microagent available at OpenHands.
|
||||
|
||||
## Frontmatter Schema
|
||||
|
||||
Every microagent requires a YAML frontmatter section at the beginning of the file, enclosed by triple dashes (`---`). The fields are:
|
||||
|
||||
| Field | Description | Required | Used By |
|
||||
| ---------- | -------------------------------------------------- | ------------------------ | ---------------- |
|
||||
| `name` | Unique identifier for the microagent | Yes | All types |
|
||||
| `type` | Type of microagent: `repo`, `knowledge`, or `task` | Yes | All types |
|
||||
| `version` | Version number (Semantic versioning recommended) | Yes | All types |
|
||||
| `agent` | The agent type (typically `CodeActAgent`) | Yes | All types |
|
||||
| `author` | Creator of the microagent | No | All types |
|
||||
| `triggers` | List of keywords that activate the microagent | Yes for knowledge agents | Knowledge agents |
|
||||
| `inputs` | Defines required user inputs for task execution | Yes for task agents | Task agents |
|
||||
|
||||
## Core Fields
|
||||
|
||||
### `agent`
|
||||
|
||||
**Purpose**: Specifies which agent implementation processes the microagent (typically `CodeActAgent`).
|
||||
|
||||
- Defines a single agent responsible for processing the microagent
|
||||
- Must be available in the OpenHands system (see the [agent hub](https://github.com/All-Hands-AI/OpenHands/tree/main/openhands/agenthub))
|
||||
- If the specified agent is not active, the microagent will not be used
|
||||
|
||||
### `triggers`
|
||||
|
||||
**Purpose**: Defines keywords that activate the `knowledge` microagent.
|
||||
|
||||
**Example**:
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- kubernetes
|
||||
- k8s
|
||||
- docker
|
||||
- security
|
||||
- containers cluster
|
||||
```
|
||||
|
||||
**Key points**:
|
||||
|
||||
- Can include both single words and multi-word phrases
|
||||
- Case-insensitive matching is typically used
|
||||
- More specific triggers (like "docker compose") prevent false activations
|
||||
- Multiple triggers increase the chance of activation in relevant contexts
|
||||
- Unique triggers like "flarglebargle" can be used for testing or special functionality
|
||||
- Triggers should be carefully chosen to avoid unwanted activations or conflicts with other microagents
|
||||
- Common terms used in many conversations may cause the microagent to be activated too frequently
|
||||
|
||||
When using multiple triggers, the microagent will be activated if any of the trigger words or phrases appear in the
|
||||
conversation.
|
||||
|
||||
### `inputs`
|
||||
|
||||
**Purpose**: Defines parameters required from the user when a `task` microagent is activated.
|
||||
|
||||
**Schema**:
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
- name: INPUT_NAME # Used with {{ INPUT_NAME }}
|
||||
description: 'Description of what this input is for'
|
||||
required: true # Optional, defaults to true
|
||||
```
|
||||
|
||||
**Key points**:
|
||||
|
||||
- The `name` and `description` properties are required for each input
|
||||
- The `required` property is optional and defaults to `true`
|
||||
- Input values are referenced in the microagent body using double curly braces (e.g., `{{ INPUT_NAME }}`)
|
||||
- All inputs defined will be collected from the user before the task microagent executes
|
||||
|
||||
**Variable Usage**: Reference input values using double curly braces `{{ INPUT_NAME }}`.
|
||||
|
||||
## Example Formats
|
||||
|
||||
### Repository Microagent
|
||||
|
||||
Repository microagents provide context and guidelines for a specific repository.
|
||||
|
||||
- Located at: `.openhands/microagents/repo.md`
|
||||
- Automatically loaded when working with the repository
|
||||
- Only one per repository
|
||||
|
||||
The `Repository` microagent is loaded specifically from `.openhands/microagents/repo.md` and serves as the main
|
||||
repository-specific instruction file. This single file is automatically loaded whenever OpenHands works with that repository
|
||||
without requiring any keyword matching or explicit call from the user.
|
||||
|
||||
[See the example in the official OpenHands repository](https://github.com/All-Hands-AI/OpenHands/blob/main/.openhands/microagents/repo.md?plain=1)
|
||||
|
||||
### Knowledge Microagent
|
||||
|
||||
Provides specialized domain expertise triggered by keywords.
|
||||
|
||||
You can find several real examples of `Knowledge` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/knowledge)
|
||||
|
||||
### Task Microagent
|
||||
|
||||
When explicitly asked by the user, will guide through interactive workflows with specific inputs.
|
||||
|
||||
You can find several real examples of `Tasks` microagents in the [offical OpenHands repository](https://github.com/All-Hands-AI/OpenHands/tree/main/microagents/tasks)
|
||||
|
||||
## Markdown Content Best Practices
|
||||
|
||||
After the frontmatter, compose the microagent body using Markdown syntax. Examples of elements you can include are:
|
||||
|
||||
- Clear, concise instructions outlining the microagent's purpose and responsibilities
|
||||
- Specific guidelines and constraints the microagent should adhere to
|
||||
- Relevant code snippets and practical examples to illustrate key points
|
||||
- Step-by-step procedures for task agents, guiding users through workflows
|
||||
|
||||
**Design Tips**:
|
||||
|
||||
- Keep microagents focused with a clear purpose
|
||||
- Provide specific guidelines rather than general advice
|
||||
- Use distinctive triggers for knowledge agents
|
||||
- Keep content concise to minimize context window usage
|
||||
- Break large microagents into smaller, focused ones
|
||||
|
||||
Aim for clarity, brevity, and practicality in your writing. Use formatting like bullet points, code blocks, and emphasis to enhance readability and comprehension.
|
||||
|
||||
Remember that balancing microagents details with user input space is important for maintaining effective interactions.
|
||||
+6
-6
@@ -66,18 +66,18 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Repository-specific',
|
||||
label: 'General Repository Microagents',
|
||||
id: 'usage/prompting/microagents-repo',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Public',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
label: 'Keyword-Triggered Microagents',
|
||||
id: 'usage/prompting/microagents-keyword',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'Syntax',
|
||||
id: 'usage/prompting/microagents-syntax',
|
||||
label: 'Global Microagents',
|
||||
id: 'usage/prompting/microagents-public',
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -268,4 +268,4 @@ const sidebars: SidebarsConfig = {
|
||||
],
|
||||
};
|
||||
|
||||
export default sidebars;
|
||||
export default sidebars;
|
||||
|
||||
Vendored
+1
-1
@@ -12,4 +12,4 @@ This file is used by the Swagger UI interface, which is accessible at `/swagger-
|
||||
|
||||
The OpenAPI specification is placed in the static directory so that it's accessible at a predictable URL in the deployed site. This allows the Swagger UI to reference it directly.
|
||||
|
||||
We only need one copy of the OpenAPI spec file, which is this one in the static directory.
|
||||
We only need one copy of the OpenAPI spec file, which is this one in the static directory.
|
||||
|
||||
@@ -46,10 +46,12 @@ describe("HomeHeader", () => {
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
"gui",
|
||||
undefined,
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// expect to be redirected to /conversations/:conversationId
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("RepoConnector", () => {
|
||||
|
||||
// select a repository from the dropdown
|
||||
const dropdown = await waitFor(() =>
|
||||
within(repoConnector).getByTestId("repo-dropdown")
|
||||
within(repoConnector).getByTestId("repo-dropdown"),
|
||||
);
|
||||
await userEvent.click(dropdown);
|
||||
|
||||
@@ -164,6 +164,7 @@ describe("RepoConnector", () => {
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledExactlyOnceWith(
|
||||
"gui",
|
||||
{
|
||||
full_name: "rbren/polaris",
|
||||
git_provider: "github",
|
||||
@@ -173,6 +174,7 @@ describe("RepoConnector", () => {
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,12 +11,6 @@ import { AuthProvider } from "#/context/auth-context";
|
||||
import { TaskCard } from "#/components/features/home/tasks/task-card";
|
||||
import * as GitService from "#/api/git";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import {
|
||||
getFailingChecksPrompt,
|
||||
getMergeConflictPrompt,
|
||||
getOpenIssuePrompt,
|
||||
getUnresolvedCommentsPrompt,
|
||||
} from "#/components/features/home/tasks/get-prompt-for-query";
|
||||
|
||||
const MOCK_TASK_1: SuggestedTask = {
|
||||
issue_number: 123,
|
||||
@@ -101,7 +95,7 @@ describe("TaskCard", () => {
|
||||
expect(createConversationSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("creating conversation prompts", () => {
|
||||
describe("creating suggested task conversation", () => {
|
||||
beforeEach(() => {
|
||||
const retrieveUserGitRepositoriesSpy = vi.spyOn(
|
||||
GitService,
|
||||
@@ -113,7 +107,7 @@ describe("TaskCard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should call create conversation with the merge conflict prompt", async () => {
|
||||
it("should call create conversation with suggest task trigger and selected suggested task", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard(MOCK_TASK_1);
|
||||
@@ -122,74 +116,12 @@ describe("TaskCard", () => {
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
"suggested_task",
|
||||
MOCK_RESPOSITORIES[0],
|
||||
getMergeConflictPrompt(
|
||||
MOCK_TASK_1.git_provider,
|
||||
MOCK_TASK_1.issue_number,
|
||||
MOCK_TASK_1.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call create conversation with the failing checks prompt", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard(MOCK_TASK_2);
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[1],
|
||||
getFailingChecksPrompt(
|
||||
MOCK_TASK_2.git_provider,
|
||||
MOCK_TASK_2.issue_number,
|
||||
MOCK_TASK_2.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call create conversation with the unresolved comments prompt", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard(MOCK_TASK_3);
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[2],
|
||||
getUnresolvedCommentsPrompt(
|
||||
MOCK_TASK_3.git_provider,
|
||||
MOCK_TASK_3.issue_number,
|
||||
MOCK_TASK_3.repo,
|
||||
),
|
||||
[],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("should call create conversation with the open issue prompt", async () => {
|
||||
const createConversationSpy = vi.spyOn(OpenHands, "createConversation");
|
||||
|
||||
renderTaskCard(MOCK_TASK_4);
|
||||
|
||||
const launchButton = screen.getByTestId("task-launch-button");
|
||||
await userEvent.click(launchButton);
|
||||
|
||||
expect(createConversationSpy).toHaveBeenCalledWith(
|
||||
MOCK_RESPOSITORIES[3],
|
||||
getOpenIssuePrompt(
|
||||
MOCK_TASK_4.git_provider,
|
||||
MOCK_TASK_4.issue_number,
|
||||
MOCK_TASK_4.repo,
|
||||
),
|
||||
undefined,
|
||||
[],
|
||||
undefined,
|
||||
MOCK_TASK_1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { describe, afterEach, vi, it, expect } from "vitest";
|
||||
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
|
||||
|
||||
const FILES = ["file-1-1.ts", "folder-1-2"];
|
||||
|
||||
describe.skip("ExplorerTree", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render the explorer", () => {
|
||||
renderWithProviders(<ExplorerTree files={FILES} defaultOpen />);
|
||||
|
||||
expect(screen.getByText("file-1-1.ts")).toBeInTheDocument();
|
||||
expect(screen.getByText("folder-1-2")).toBeInTheDocument();
|
||||
// TODO: make sure children render
|
||||
});
|
||||
|
||||
it("should render the explorer given the defaultExpanded prop", () => {
|
||||
renderWithProviders(<ExplorerTree files={FILES} />);
|
||||
|
||||
expect(screen.queryByText("file-1-1.ts")).toBeInTheDocument();
|
||||
expect(screen.queryByText("folder-1-2")).toBeInTheDocument();
|
||||
// TODO: make sure children don't render
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { AgentState } from "#/types/agent-state";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
const getFilesSpy = vi.spyOn(FileService, "getFiles");
|
||||
|
||||
vi.mock("../../services/fileService", async () => ({
|
||||
uploadFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
const renderFileExplorerWithRunningAgentState = () =>
|
||||
renderWithProviders(<FileExplorer isOpen onToggle={() => {}} />, {
|
||||
preloadedState: {
|
||||
agent: {
|
||||
curAgentState: AgentState.RUNNING,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
describe.skip("FileExplorer", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should get the workspace directory", async () => {
|
||||
renderFileExplorerWithRunningAgentState();
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
|
||||
});
|
||||
|
||||
it("should refetch the workspace when clicking the refresh button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFileExplorerWithRunningAgentState();
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file1.ts")).toBeInTheDocument();
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(1); // once for root
|
||||
|
||||
const refreshButton = screen.getByTestId("refresh");
|
||||
await user.click(refreshButton);
|
||||
|
||||
expect(getFilesSpy).toHaveBeenCalledTimes(2); // once for root, once for refresh button
|
||||
});
|
||||
|
||||
it("should toggle the explorer visibility when clicking the toggle button", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderFileExplorerWithRunningAgentState();
|
||||
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
expect(folder1).toBeInTheDocument();
|
||||
|
||||
const toggleButton = screen.getByTestId("toggle");
|
||||
await user.click(toggleButton);
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(folder1).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "test-utils";
|
||||
import { vi, describe, afterEach, it, expect } from "vitest";
|
||||
import TreeNode from "#/components/features/file-explorer/tree-node";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
const getFileSpy = vi.spyOn(FileService, "getFile");
|
||||
const getFilesSpy = vi.spyOn(FileService, "getFiles");
|
||||
|
||||
vi.mock("../../services/fileService", async () => ({
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe.skip("TreeNode", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render a file if property has no children", () => {
|
||||
renderWithProviders(<TreeNode path="/file.ts" defaultOpen />);
|
||||
expect(screen.getByText("file.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render a folder if it's in a subdir", async () => {
|
||||
renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(await screen.findByText("folder1")).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should close a folder when clicking on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/folder1/" defaultOpen />);
|
||||
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
const file2 = await screen.findByText("file2.ts");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(file2).toBeInTheDocument();
|
||||
|
||||
await user.click(folder1);
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should open a folder when clicking on it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/folder1/" />);
|
||||
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(folder1);
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call `OpenHands.getFile` and return the full path of a file when clicking on a file", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/folder1/file2.ts" defaultOpen />);
|
||||
|
||||
const file2 = screen.getByText("file2.ts");
|
||||
await user.click(file2);
|
||||
|
||||
expect(getFileSpy).toHaveBeenCalledWith("/folder1/file2.ts");
|
||||
});
|
||||
|
||||
it("should render the full explorer given the defaultOpen prop", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<TreeNode path="/" defaultOpen />);
|
||||
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/");
|
||||
|
||||
const file1 = await screen.findByText("file1.ts");
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
|
||||
expect(file1).toBeInTheDocument();
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await user.click(folder1);
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("folder1/");
|
||||
|
||||
expect(file1).toBeInTheDocument();
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render all children as collapsed when defaultOpen is false", async () => {
|
||||
renderWithProviders(<TreeNode path="/folder1/" defaultOpen={false} />);
|
||||
|
||||
const folder1 = await screen.findByText("folder1");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(screen.queryByText("file2.ts")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(folder1);
|
||||
expect(getFilesSpy).toHaveBeenCalledWith("/folder1/");
|
||||
|
||||
expect(folder1).toBeInTheDocument();
|
||||
expect(await screen.findByText("file2.ts")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@ const IGNORE_PATHS = [
|
||||
"entry.client.tsx", // Client entry point
|
||||
"utils/scan-unlocalized-strings.ts", // Original scanner
|
||||
"utils/scan-unlocalized-strings-ast.ts", // This file itself
|
||||
"frontend/src/components/features/home/tasks/get-prompt-for-query.ts", // Only contains agent prompts
|
||||
];
|
||||
|
||||
// Extensions to scan
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
GetTrajectoryResponse,
|
||||
GitChangeDiff,
|
||||
GitChange,
|
||||
ConversationTrigger,
|
||||
} from "./open-hands.types";
|
||||
import { openHands } from "./open-hands-axios";
|
||||
import { ApiSettings, PostApiSettings } from "#/types/settings";
|
||||
import { GitUser, GitRepository } from "#/types/git";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
|
||||
class OpenHands {
|
||||
/**
|
||||
@@ -149,17 +151,21 @@ class OpenHands {
|
||||
}
|
||||
|
||||
static async createConversation(
|
||||
conversation_trigger: ConversationTrigger = "gui",
|
||||
selectedRepository?: GitRepository,
|
||||
initialUserMsg?: string,
|
||||
imageUrls?: string[],
|
||||
replayJson?: string,
|
||||
suggested_task?: SuggestedTask,
|
||||
): Promise<Conversation> {
|
||||
const body = {
|
||||
conversation_trigger,
|
||||
selected_repository: selectedRepository,
|
||||
selected_branch: undefined,
|
||||
initial_user_msg: initialUserMsg,
|
||||
image_urls: imageUrls,
|
||||
replay_json: replayJson,
|
||||
suggested_task,
|
||||
};
|
||||
|
||||
const { data } = await openHands.post<Conversation>(
|
||||
|
||||
@@ -70,7 +70,7 @@ export interface AuthenticateResponse {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ConversationTrigger = "resolver" | "gui";
|
||||
export type ConversationTrigger = "resolver" | "gui" | "suggested_task";
|
||||
|
||||
export interface Conversation {
|
||||
conversation_id: string;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import TreeNode from "./tree-node";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ExplorerTreeProps {
|
||||
files: string[] | null;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
export function ExplorerTree({
|
||||
files,
|
||||
defaultOpen = false,
|
||||
}: ExplorerTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!files?.length) {
|
||||
const message = !files
|
||||
? I18nKey.EXPLORER$LOADING_WORKSPACE_MESSAGE
|
||||
: I18nKey.EXPLORER$EMPTY_WORKSPACE_MESSAGE;
|
||||
return <div className="text-sm text-gray-400 pt-4">{t(message)}</div>;
|
||||
}
|
||||
return (
|
||||
<div className="w-full h-full pt-[4px]">
|
||||
{files.map((file) => (
|
||||
<TreeNode key={file} path={file} defaultOpen={defaultOpen} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { RefreshIconButton } from "#/components/shared/buttons/refresh-icon-button";
|
||||
import { ToggleWorkspaceIconButton } from "#/components/shared/buttons/toggle-workspace-icon-button";
|
||||
import { cn } from "#/utils/utils";
|
||||
|
||||
interface ExplorerActionsProps {
|
||||
onRefresh: () => void;
|
||||
toggleHidden: () => void;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
export function ExplorerActions({
|
||||
toggleHidden,
|
||||
onRefresh,
|
||||
isHidden,
|
||||
}: ExplorerActionsProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-[24px] items-center gap-1",
|
||||
isHidden ? "right-3" : "right-2",
|
||||
)}
|
||||
>
|
||||
{!isHidden && <RefreshIconButton onClick={onRefresh} />}
|
||||
|
||||
<ToggleWorkspaceIconButton isHidden={isHidden} onClick={toggleHidden} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { ExplorerActions } from "./file-explorer-actions";
|
||||
|
||||
interface FileExplorerHeaderProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
onRefreshWorkspace: () => void;
|
||||
}
|
||||
|
||||
export function FileExplorerHeader({
|
||||
isOpen,
|
||||
onToggle,
|
||||
onRefreshWorkspace,
|
||||
}: FileExplorerHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"sticky top-0 bg-base-secondary",
|
||||
"flex items-center",
|
||||
!isOpen ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{isOpen && (
|
||||
<div className="text-neutral-300 font-bold text-sm">
|
||||
{t(I18nKey.EXPLORER$LABEL_WORKSPACE)}
|
||||
</div>
|
||||
)}
|
||||
<ExplorerActions
|
||||
isHidden={!isOpen}
|
||||
toggleHidden={onToggle}
|
||||
onRefresh={onRefreshWorkspace}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { ExplorerTree } from "#/components/features/file-explorer/explorer-tree";
|
||||
import toast from "#/utils/toast";
|
||||
import { RootState } from "#/store";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useListFiles } from "#/hooks/query/use-list-files";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { FileExplorerHeader } from "./file-explorer-header";
|
||||
import { useVSCodeUrl } from "#/hooks/query/use-vscode-url";
|
||||
import { BrandButton } from "../settings/brand-button";
|
||||
import VSCodeIcon from "#/assets/vscode-alt.svg?react";
|
||||
|
||||
interface FileExplorerProps {
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function FileExplorer({ isOpen, onToggle }: FileExplorerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const { data: paths, refetch, error } = useListFiles();
|
||||
const { data: vscodeUrl } = useVSCodeUrl({
|
||||
enabled: !RUNTIME_INACTIVE_STATES.includes(curAgentState),
|
||||
});
|
||||
|
||||
const handleOpenVSCode = () => {
|
||||
if (vscodeUrl?.vscode_url) {
|
||||
window.open(vscodeUrl.vscode_url, "_blank");
|
||||
} else if (vscodeUrl?.error) {
|
||||
toast.error(
|
||||
`open-vscode-error-${new Date().getTime()}`,
|
||||
t(I18nKey.EXPLORER$VSCODE_SWITCHING_ERROR_MESSAGE, {
|
||||
error: vscodeUrl.error,
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshWorkspace = () => {
|
||||
if (!RUNTIME_INACTIVE_STATES.includes(curAgentState)) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
refreshWorkspace();
|
||||
}, [curAgentState]);
|
||||
|
||||
return (
|
||||
<div data-testid="file-explorer" className="relative h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"bg-base-secondary h-full border-r-1 border-r-neutral-600 flex flex-col",
|
||||
!isOpen ? "w-12" : "w-60",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col relative h-full px-3 py-2 overflow-hidden">
|
||||
<FileExplorerHeader
|
||||
isOpen={isOpen}
|
||||
onToggle={onToggle}
|
||||
onRefreshWorkspace={refreshWorkspace}
|
||||
/>
|
||||
{!error && (
|
||||
<div className="overflow-auto flex-grow min-h-0">
|
||||
<div style={{ display: !isOpen ? "none" : "block" }}>
|
||||
<ExplorerTree files={paths || []} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<p className="text-neutral-300 text-sm">{error.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<BrandButton
|
||||
testId="open-vscode-button"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full text-content border-content"
|
||||
isDisabled={RUNTIME_INACTIVE_STATES.includes(curAgentState)}
|
||||
onClick={handleOpenVSCode}
|
||||
startContent={<VSCodeIcon width={20} height={20} />}
|
||||
>
|
||||
{t(I18nKey.VSCODE$OPEN)}
|
||||
</BrandButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { FaFile } from "react-icons/fa";
|
||||
import { getExtension } from "#/utils/utils";
|
||||
import { EXTENSION_ICON_MAP } from "../../extension-icon-map.constant";
|
||||
|
||||
interface FileIconProps {
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function FileIcon({ filename }: FileIconProps) {
|
||||
const extension = getExtension(filename);
|
||||
return EXTENSION_ICON_MAP[extension] || <FaFile />;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { FolderIcon } from "./folder-icon";
|
||||
import { FileIcon } from "./file-icon";
|
||||
|
||||
interface FilenameProps {
|
||||
name: string;
|
||||
type: "folder" | "file";
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function Filename({ name, type, isOpen }: FilenameProps) {
|
||||
return (
|
||||
<div className="cursor-pointer text-nowrap rounded-[5px] p-1 nowrap flex items-center gap-2 aria-selected:bg-neutral-600 aria-selected:text-white hover:text-white">
|
||||
<div className="flex-shrink-0">
|
||||
{type === "folder" && <FolderIcon isOpen={isOpen} />}
|
||||
{type === "file" && <FileIcon filename={name} />}
|
||||
</div>
|
||||
<div className="flex-grow">{name}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { FaFolder, FaFolderOpen } from "react-icons/fa";
|
||||
|
||||
interface FolderIconProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export function FolderIcon({ isOpen }: FolderIconProps) {
|
||||
return isOpen ? (
|
||||
<FaFolderOpen color="D9D3D0" className="icon" />
|
||||
) : (
|
||||
<FaFolder color="D9D3D0" className="icon" />
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import React from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
|
||||
import { useFiles } from "#/context/files";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useListFiles } from "#/hooks/query/use-list-files";
|
||||
import { useListFile } from "#/hooks/query/use-list-file";
|
||||
import { Filename } from "./filename";
|
||||
import { RootState } from "#/store";
|
||||
|
||||
interface TreeNodeProps {
|
||||
path: string;
|
||||
defaultOpen?: boolean;
|
||||
}
|
||||
|
||||
function TreeNode({ path, defaultOpen = false }: TreeNodeProps) {
|
||||
const { setFileContent, setSelectedPath, files, selectedPath } = useFiles();
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
|
||||
const isDirectory = path.endsWith("/");
|
||||
|
||||
const { data: paths } = useListFiles({
|
||||
path,
|
||||
enabled: isDirectory && isOpen,
|
||||
});
|
||||
|
||||
const { data: fileContent, refetch } = useListFile({ path });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (fileContent) {
|
||||
if (fileContent !== files[path]) {
|
||||
setFileContent(path, fileContent);
|
||||
}
|
||||
}
|
||||
}, [fileContent, path]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedPath === path && !isDirectory) {
|
||||
refetch();
|
||||
}
|
||||
}, [curAgentState, selectedPath, path, isDirectory]);
|
||||
|
||||
const fileParts = path.split("/");
|
||||
const filename =
|
||||
fileParts[fileParts.length - 1] || fileParts[fileParts.length - 2];
|
||||
|
||||
const handleClick = async () => {
|
||||
if (isDirectory) setIsOpen((prev) => !prev);
|
||||
else {
|
||||
setSelectedPath(path);
|
||||
await refetch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"text-sm text-neutral-400",
|
||||
path === selectedPath && "bg-gray-700",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type={isDirectory ? "button" : "submit"}
|
||||
name="file"
|
||||
value={path}
|
||||
onClick={handleClick}
|
||||
className="flex items-center justify-between w-full px-1"
|
||||
>
|
||||
<Filename
|
||||
name={filename}
|
||||
type={isDirectory ? "folder" : "file"}
|
||||
isOpen={isOpen}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isOpen && paths && (
|
||||
<div className="ml-5">
|
||||
{paths.map((child, index) => (
|
||||
<TreeNode key={index} path={child} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(TreeNode);
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
|
||||
const INITIAL_PROMPT = "";
|
||||
|
||||
export function CodeNotInGitLink() {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { mutate: createConversation } = useCreateConversation();
|
||||
|
||||
const handleStartFromScratch = () => {
|
||||
// Set the initial prompt and create a new conversation
|
||||
dispatch(setInitialPrompt(INITIAL_PROMPT));
|
||||
createConversation({ q: INITIAL_PROMPT });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-xs text-neutral-400">
|
||||
{t(I18nKey.GITHUB$CODE_NOT_IN_GITHUB)}{" "}
|
||||
<span
|
||||
onClick={handleStartFromScratch}
|
||||
className="underline cursor-pointer"
|
||||
>
|
||||
{t(I18nKey.GITHUB$START_FROM_SCRATCH)}
|
||||
</span>{" "}
|
||||
{t(I18nKey.GITHUB$VSCODE_LINK_DESCRIPTION)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export function HomeHeader() {
|
||||
testId="header-launch-button"
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={() => createConversation({})}
|
||||
onClick={() => createConversation({ conversation_trigger: "gui" })}
|
||||
isDisabled={isCreatingConversation}
|
||||
>
|
||||
{!isCreatingConversation && "Launch from Scratch"}
|
||||
|
||||
@@ -142,7 +142,12 @@ export function RepositorySelectionForm({
|
||||
isLoadingRepositories ||
|
||||
isRepositoriesError
|
||||
}
|
||||
onClick={() => createConversation({ selectedRepository })}
|
||||
onClick={() =>
|
||||
createConversation({
|
||||
selectedRepository,
|
||||
conversation_trigger: "gui",
|
||||
})
|
||||
}
|
||||
>
|
||||
{!isCreatingConversation && "Launch"}
|
||||
{isCreatingConversation && t("HOME$LOADING")}
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import { Provider } from "#/types/settings";
|
||||
import { SuggestedTaskType } from "./task.types";
|
||||
|
||||
// Helper function to get provider-specific terminology
|
||||
const getProviderTerms = (git_provider: Provider) => {
|
||||
if (git_provider === "gitlab") {
|
||||
return {
|
||||
requestType: "Merge Request",
|
||||
requestTypeShort: "MR",
|
||||
apiName: "GitLab API",
|
||||
tokenEnvVar: "GITLAB_TOKEN",
|
||||
ciSystem: "CI pipelines",
|
||||
ciProvider: "GitLab",
|
||||
requestVerb: "merge request",
|
||||
};
|
||||
}
|
||||
return {
|
||||
requestType: "Pull Request",
|
||||
requestTypeShort: "PR",
|
||||
apiName: "GitHub API",
|
||||
tokenEnvVar: "GITHUB_TOKEN",
|
||||
ciSystem: "GitHub Actions",
|
||||
ciProvider: "GitHub",
|
||||
requestVerb: "pull request",
|
||||
};
|
||||
};
|
||||
|
||||
export const getMergeConflictPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the merge conflicts.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.`;
|
||||
};
|
||||
|
||||
export const getFailingChecksPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to fix the failing CI checks.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to look at the ${terms.ciSystem} that are failing on the most recent commit. Try and reproduce the failure locally.
|
||||
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the ${terms.ciProvider} ${terms.ciSystem.toLowerCase()} have run again. If they are still failing, repeat the process.`;
|
||||
};
|
||||
|
||||
export const getUnresolvedCommentsPrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on ${terms.requestType} #${issueNumber} in repository ${repo}. You need to resolve the remaining comments from reviewers.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the ${terms.requestTypeShort} details. Check out the branch from that ${terms.requestVerb} and look at the diff versus the base branch of the ${terms.requestTypeShort} to understand the ${terms.requestTypeShort}'s intention.
|
||||
Then use the ${terms.apiName} to retrieve all the feedback on the ${terms.requestTypeShort} so far. If anything hasn't been addressed, address it and commit your changes back to the same branch.`;
|
||||
};
|
||||
|
||||
export const getOpenIssuePrompt = (
|
||||
git_provider: Provider,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
const terms = getProviderTerms(git_provider);
|
||||
|
||||
return `You are working on Issue #${issueNumber} in repository ${repo}. Your goal is to fix the issue.
|
||||
Use the ${terms.apiName} with the ${terms.tokenEnvVar} environment variable to retrieve the issue details and any comments on the issue. Then check out a new branch and investigate what changes will need to be made.
|
||||
Finally, make the required changes and open up a ${terms.requestVerb}. Be sure to reference the issue in the ${terms.requestTypeShort} description.`;
|
||||
};
|
||||
|
||||
export const getPromptForQuery = (
|
||||
git_provider: Provider,
|
||||
type: SuggestedTaskType,
|
||||
issueNumber: number,
|
||||
repo: string,
|
||||
) => {
|
||||
switch (type) {
|
||||
case "MERGE_CONFLICTS":
|
||||
return getMergeConflictPrompt(git_provider, issueNumber, repo);
|
||||
case "FAILING_CHECKS":
|
||||
return getFailingChecksPrompt(git_provider, issueNumber, repo);
|
||||
case "UNRESOLVED_COMMENTS":
|
||||
return getUnresolvedCommentsPrompt(git_provider, issueNumber, repo);
|
||||
case "OPEN_ISSUE":
|
||||
return getOpenIssuePrompt(git_provider, issueNumber, repo);
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
@@ -4,7 +4,6 @@ import { useIsCreatingConversation } from "#/hooks/use-is-creating-conversation"
|
||||
import { useCreateConversation } from "#/hooks/mutation/use-create-conversation";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { useUserRepositories } from "#/hooks/query/use-user-repositories";
|
||||
import { getPromptForQuery } from "./get-prompt-for-query";
|
||||
import { TaskIssueNumber } from "./task-issue-number";
|
||||
import { Provider } from "#/types/settings";
|
||||
|
||||
@@ -40,16 +39,11 @@ export function TaskCard({ task }: TaskCardProps) {
|
||||
|
||||
const handleLaunchConversation = () => {
|
||||
const repo = getRepo(task.repo, task.git_provider);
|
||||
const query = getPromptForQuery(
|
||||
task.git_provider,
|
||||
task.task_type,
|
||||
task.issue_number,
|
||||
task.repo,
|
||||
);
|
||||
|
||||
return createConversation({
|
||||
conversation_trigger: "suggested_task",
|
||||
selectedRepository: repo,
|
||||
q: query,
|
||||
suggested_task: task,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ function Terminal() {
|
||||
return (
|
||||
<div className="h-full p-2 min-h-0 flex-grow">
|
||||
{isRuntimeInactive && (
|
||||
<div className="text-sm text-gray-400 mb-2">
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ContainerProps {
|
||||
icon?: React.ReactNode;
|
||||
isBeta?: boolean;
|
||||
isLoading?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
}[];
|
||||
children: React.ReactNode;
|
||||
className?: React.HTMLAttributes<HTMLDivElement>["className"];
|
||||
@@ -30,16 +31,19 @@ export function Container({
|
||||
>
|
||||
{labels && (
|
||||
<div className="flex text-xs h-[36px]">
|
||||
{labels.map(({ label: l, to, icon, isBeta, isLoading }) => (
|
||||
<NavTab
|
||||
key={to}
|
||||
to={to}
|
||||
label={l}
|
||||
icon={icon}
|
||||
isBeta={isBeta}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
))}
|
||||
{labels.map(
|
||||
({ label: l, to, icon, isBeta, isLoading, rightContent }) => (
|
||||
<NavTab
|
||||
key={to}
|
||||
to={to}
|
||||
label={l}
|
||||
icon={icon}
|
||||
isBeta={isBeta}
|
||||
isLoading={isLoading}
|
||||
rightContent={rightContent}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!labels && label && (
|
||||
|
||||
@@ -9,9 +9,17 @@ interface NavTabProps {
|
||||
icon: React.ReactNode;
|
||||
isBeta?: boolean;
|
||||
isLoading?: boolean;
|
||||
rightContent?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NavTab({ to, label, icon, isBeta, isLoading }: NavTabProps) {
|
||||
export function NavTab({
|
||||
to,
|
||||
label,
|
||||
icon,
|
||||
isBeta,
|
||||
isLoading,
|
||||
rightContent,
|
||||
}: NavTabProps) {
|
||||
return (
|
||||
<NavLink
|
||||
end
|
||||
@@ -24,15 +32,17 @@ export function NavTab({ to, label, icon, isBeta, isLoading }: NavTabProps) {
|
||||
)}
|
||||
>
|
||||
{({ isActive }) => (
|
||||
<>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn(isActive && "text-logo")}>{icon}</div>
|
||||
{label}
|
||||
{isBeta && <BetaBadge />}
|
||||
</div>
|
||||
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</>
|
||||
<div className="flex items-center gap-2">
|
||||
{rightContent}
|
||||
{isLoading && <LoadingSpinner size="small" />}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,7 @@ export function RefreshIconButton({ onClick }: RefreshIconButtonProps) {
|
||||
/>
|
||||
}
|
||||
testId="refresh"
|
||||
ariaLabel={t(I18nKey.WORKSPACE$REFRESH)}
|
||||
ariaLabel={t("BUTTON$REFRESH" as I18nKey)}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { IoIosArrowForward, IoIosArrowBack } from "react-icons/io";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconButton } from "./icon-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
interface ToggleWorkspaceIconButtonProps {
|
||||
onClick: () => void;
|
||||
isHidden: boolean;
|
||||
}
|
||||
|
||||
export function ToggleWorkspaceIconButton({
|
||||
onClick,
|
||||
isHidden,
|
||||
}: ToggleWorkspaceIconButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
icon={
|
||||
isHidden ? (
|
||||
<IoIosArrowForward
|
||||
size={20}
|
||||
className="text-neutral-400 hover:text-neutral-100 transition"
|
||||
/>
|
||||
) : (
|
||||
<IoIosArrowBack
|
||||
size={20}
|
||||
className="text-neutral-400 hover:text-neutral-100 transition"
|
||||
/>
|
||||
)
|
||||
}
|
||||
testId="toggle"
|
||||
ariaLabel={
|
||||
isHidden ? t(I18nKey.WORKSPACE$OPEN) : t(I18nKey.WORKSPACE$CLOSE)
|
||||
}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ export function TaskForm({ ref }: TaskFormProps) {
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
||||
const q = formData.get("q")?.toString();
|
||||
createConversation({ q });
|
||||
createConversation({ q, conversation_trigger: "gui" });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import React from "react";
|
||||
|
||||
interface FilesContextType {
|
||||
/**
|
||||
* List of file paths in the workspace
|
||||
*/
|
||||
paths: string[];
|
||||
/**
|
||||
* Set the list of file paths in the workspace
|
||||
* @param paths The list of file paths in the workspace
|
||||
* @returns void
|
||||
*/
|
||||
setPaths: (paths: string[]) => void;
|
||||
/**
|
||||
* A map of file paths to their contents
|
||||
*/
|
||||
files: Record<string, string>;
|
||||
/**
|
||||
* Set the content of a file
|
||||
* @param path The path of the file
|
||||
* @param content The content of the file
|
||||
* @returns void
|
||||
*/
|
||||
setFileContent: (path: string, content: string) => void;
|
||||
selectedPath: string | null;
|
||||
setSelectedPath: (path: string | null) => void;
|
||||
}
|
||||
|
||||
const FilesContext = React.createContext<FilesContextType | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
interface FilesProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FilesProvider({ children }: FilesProviderProps) {
|
||||
const [paths, setPaths] = React.useState<string[]>([]);
|
||||
const [files, setFiles] = React.useState<Record<string, string>>({});
|
||||
const [selectedPath, setSelectedPath] = React.useState<string | null>(null);
|
||||
|
||||
const setFileContent = React.useCallback((path: string, content: string) => {
|
||||
setFiles((prev) => ({ ...prev, [path]: content }));
|
||||
}, []);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
paths,
|
||||
setPaths,
|
||||
files,
|
||||
setFileContent,
|
||||
selectedPath,
|
||||
setSelectedPath,
|
||||
}),
|
||||
[paths, setPaths, files, setFileContent, selectedPath, setSelectedPath],
|
||||
);
|
||||
|
||||
return <FilesContext value={value}>{children}</FilesContext>;
|
||||
}
|
||||
|
||||
function useFiles() {
|
||||
const context = React.useContext(FilesContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useFiles must be used within a FilesProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { FilesProvider, useFiles };
|
||||
@@ -6,6 +6,8 @@ import OpenHands from "#/api/open-hands";
|
||||
import { setInitialPrompt } from "#/state/initial-query-slice";
|
||||
import { RootState } from "#/store";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { ConversationTrigger } from "#/api/open-hands.types";
|
||||
import { SuggestedTask } from "#/components/features/home/tasks/task.types";
|
||||
|
||||
export const useCreateConversation = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -19,16 +21,20 @@ export const useCreateConversation = () => {
|
||||
return useMutation({
|
||||
mutationKey: ["create-conversation"],
|
||||
mutationFn: async (variables: {
|
||||
conversation_trigger: ConversationTrigger;
|
||||
q?: string;
|
||||
selectedRepository?: GitRepository | null;
|
||||
suggested_task?: SuggestedTask;
|
||||
}) => {
|
||||
if (variables.q) dispatch(setInitialPrompt(variables.q));
|
||||
|
||||
return OpenHands.createConversation(
|
||||
variables.conversation_trigger,
|
||||
variables.selectedRepository || undefined,
|
||||
variables.q,
|
||||
files,
|
||||
replayJson || undefined,
|
||||
variables.suggested_task || undefined,
|
||||
);
|
||||
},
|
||||
onSuccess: async ({ conversation_id: conversationId }, { q }) => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
interface UseListFileConfig {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export const useListFile = (config: UseListFileConfig) => {
|
||||
const { conversationId } = useConversation();
|
||||
return useQuery({
|
||||
queryKey: ["files", conversationId, config.path],
|
||||
queryFn: () => FileService.getFile(conversationId, config.path),
|
||||
enabled: false, // don't fetch by default, trigger manually via `refetch`
|
||||
});
|
||||
};
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import { RootState } from "#/store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import { FileService } from "#/api/file-service/file-service.api";
|
||||
|
||||
interface UseListFilesConfig {
|
||||
path?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: UseListFilesConfig = {
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export const useListFiles = (config: UseListFilesConfig = DEFAULT_CONFIG) => {
|
||||
const { conversationId } = useConversation();
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const runtimeIsActive = !RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["files", conversationId, config?.path],
|
||||
queryFn: () => FileService.getFiles(conversationId, config?.path),
|
||||
enabled: runtimeIsActive && !!config?.enabled,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
};
|
||||
@@ -34,8 +34,11 @@ export enum I18nKey {
|
||||
SETTINGS$TITLE = "SETTINGS$TITLE",
|
||||
CONVERSATION$START_NEW = "CONVERSATION$START_NEW",
|
||||
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
|
||||
WORKSPACE$TITLE = "WORKSPACE$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",
|
||||
WORKSPACE$CODE_EDITOR_TAB_LABEL = "WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
WORKSPACE$TITLE = "WORKSPACE$TITLE",
|
||||
TERMINAL$WAITING_FOR_CLIENT = "TERMINAL$WAITING_FOR_CLIENT",
|
||||
CODE_EDITOR$FILE_SAVED_SUCCESSFULLY = "CODE_EDITOR$FILE_SAVED_SUCCESSFULLY",
|
||||
CODE_EDITOR$SAVING_LABEL = "CODE_EDITOR$SAVING_LABEL",
|
||||
@@ -63,14 +66,12 @@ export enum I18nKey {
|
||||
ERROR$GENERIC = "ERROR$GENERIC",
|
||||
GITHUB$AUTH_SCOPE = "GITHUB$AUTH_SCOPE",
|
||||
FILE_SERVICE$INVALID_FILE_PATH = "FILE_SERVICE$INVALID_FILE_PATH",
|
||||
WORKSPACE$PLANNER_TAB_LABEL = "WORKSPACE$PLANNER_TAB_LABEL",
|
||||
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
WORKSPACE$CODE_EDITOR_TAB_LABEL = "WORKSPACE$CODE_EDITOR_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
WORKSPACE$REFRESH = "WORKSPACE$REFRESH",
|
||||
WORKSPACE$OPEN = "WORKSPACE$OPEN",
|
||||
WORKSPACE$CLOSE = "WORKSPACE$CLOSE",
|
||||
VSCODE$OPEN = "VSCODE$OPEN",
|
||||
VSCODE$TITLE = "VSCODE$TITLE",
|
||||
VSCODE$LOADING = "VSCODE$LOADING",
|
||||
VSCODE$URL_NOT_AVAILABLE = "VSCODE$URL_NOT_AVAILABLE",
|
||||
VSCODE$FETCH_ERROR = "VSCODE$FETCH_ERROR",
|
||||
VSCODE$IFRAME_PERMISSIONS = "VSCODE$IFRAME_PERMISSIONS",
|
||||
INCREASE_TEST_COVERAGE = "INCREASE_TEST_COVERAGE",
|
||||
AUTO_MERGE_PRS = "AUTO_MERGE_PRS",
|
||||
FIX_README = "FIX_README",
|
||||
@@ -134,10 +135,6 @@ export enum I18nKey {
|
||||
SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE = "SESSION$SOCKET_NOT_INITIALIZED_ERROR_MESSAGE",
|
||||
EXPLORER$UPLOAD_ERROR_MESSAGE = "EXPLORER$UPLOAD_ERROR_MESSAGE",
|
||||
EXPLORER$LABEL_DROP_FILES = "EXPLORER$LABEL_DROP_FILES",
|
||||
EXPLORER$LABEL_WORKSPACE = "EXPLORER$LABEL_WORKSPACE",
|
||||
EXPLORER$EMPTY_WORKSPACE_MESSAGE = "EXPLORER$EMPTY_WORKSPACE_MESSAGE",
|
||||
EXPLORER$LOADING_WORKSPACE_MESSAGE = "EXPLORER$LOADING_WORKSPACE_MESSAGE",
|
||||
EXPLORER$REFRESH_ERROR_MESSAGE = "EXPLORER$REFRESH_ERROR_MESSAGE",
|
||||
EXPLORER$UPLOAD_SUCCESS_MESSAGE = "EXPLORER$UPLOAD_SUCCESS_MESSAGE",
|
||||
EXPLORER$NO_FILES_UPLOADED_MESSAGE = "EXPLORER$NO_FILES_UPLOADED_MESSAGE",
|
||||
EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE = "EXPLORER$UPLOAD_PARTIAL_SUCCESS_MESSAGE",
|
||||
@@ -288,6 +285,7 @@ export enum I18nKey {
|
||||
BUTTON$CREATE = "BUTTON$CREATE",
|
||||
BUTTON$DELETE = "BUTTON$DELETE",
|
||||
BUTTON$COPY_TO_CLIPBOARD = "BUTTON$COPY_TO_CLIPBOARD",
|
||||
BUTTON$REFRESH = "BUTTON$REFRESH",
|
||||
ERROR$REQUIRED_FIELD = "ERROR$REQUIRED_FIELD",
|
||||
PLANNER$EMPTY_MESSAGE = "PLANNER$EMPTY_MESSAGE",
|
||||
FEEDBACK$PUBLIC_LABEL = "FEEDBACK$PUBLIC_LABEL",
|
||||
@@ -348,9 +346,6 @@ export enum I18nKey {
|
||||
BROWSER$SCREENSHOT_ALT = "BROWSER$SCREENSHOT_ALT",
|
||||
ERROR_TOAST$CLOSE_BUTTON_LABEL = "ERROR_TOAST$CLOSE_BUTTON_LABEL",
|
||||
FILE_EXPLORER$UPLOAD = "FILE_EXPLORER$UPLOAD",
|
||||
FILE_EXPLORER$REFRESH_WORKSPACE = "FILE_EXPLORER$REFRESH_WORKSPACE",
|
||||
FILE_EXPLORER$OPEN_WORKSPACE = "FILE_EXPLORER$OPEN_WORKSPACE",
|
||||
FILE_EXPLORER$CLOSE_WORKSPACE = "FILE_EXPLORER$CLOSE_WORKSPACE",
|
||||
ACTION_MESSAGE$RUN = "ACTION_MESSAGE$RUN",
|
||||
ACTION_MESSAGE$RUN_IPYTHON = "ACTION_MESSAGE$RUN_IPYTHON",
|
||||
ACTION_MESSAGE$READ = "ACTION_MESSAGE$READ",
|
||||
|
||||
+165
-225
@@ -511,21 +511,7 @@
|
||||
"tr": "Hesap ayarları",
|
||||
"de": "Kontoeinstellungen"
|
||||
},
|
||||
"WORKSPACE$TITLE": {
|
||||
"en": "Workspace",
|
||||
"zh-CN": "工作区",
|
||||
"de": "Arbeitsbereich",
|
||||
"ko-KR": "워크스페이스",
|
||||
"no": "Arbeidsområde",
|
||||
"zh-TW": "工作區",
|
||||
"ar": "مساحة العمل",
|
||||
"fr": "Espace de travail",
|
||||
"it": "Area di lavoro",
|
||||
"pt": "Espaço de trabalho",
|
||||
"es": "Espacio de trabajo",
|
||||
"ja": "ワークスペース",
|
||||
"tr": "Çalışma Alanı"
|
||||
},
|
||||
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL": {
|
||||
"en": "Terminal",
|
||||
"zh-CN": "终端",
|
||||
@@ -541,6 +527,66 @@
|
||||
"tr": "Terminal",
|
||||
"ja": "ターミナル"
|
||||
},
|
||||
"WORKSPACE$BROWSER_TAB_LABEL": {
|
||||
"en": "Browser",
|
||||
"zh-CN": "浏览器",
|
||||
"de": "Browser",
|
||||
"ko-KR": "브라우저",
|
||||
"no": "Nettleser",
|
||||
"zh-TW": "瀏覽器",
|
||||
"it": "Browser",
|
||||
"pt": "Navegador",
|
||||
"es": "Navegador",
|
||||
"ar": "المتصفح",
|
||||
"fr": "Navigateur",
|
||||
"tr": "Tarayıcı",
|
||||
"ja": "ブラウザ"
|
||||
},
|
||||
"WORKSPACE$JUPYTER_TAB_LABEL": {
|
||||
"en": "Jupyter",
|
||||
"zh-CN": "Jupyter",
|
||||
"de": "Jupyter",
|
||||
"ko-KR": "Jupyter",
|
||||
"no": "Jupyter",
|
||||
"zh-TW": "Jupyter",
|
||||
"it": "Jupyter",
|
||||
"pt": "Jupyter",
|
||||
"es": "Jupyter",
|
||||
"ar": "Jupyter",
|
||||
"fr": "Jupyter",
|
||||
"tr": "Jupyter",
|
||||
"ja": "Jupyter"
|
||||
},
|
||||
"WORKSPACE$CODE_EDITOR_TAB_LABEL": {
|
||||
"en": "Code Editor",
|
||||
"zh-CN": "代码编辑器",
|
||||
"de": "Code-Editor",
|
||||
"ko-KR": "코드 에디터",
|
||||
"no": "Kodeeditor",
|
||||
"zh-TW": "程式碼編輯器",
|
||||
"it": "Editor di codice",
|
||||
"pt": "Editor de código",
|
||||
"es": "Editor de código",
|
||||
"ar": "محرر الكود",
|
||||
"fr": "Éditeur de code",
|
||||
"tr": "Kod Düzenleyici",
|
||||
"ja": "コードエディタ"
|
||||
},
|
||||
"WORKSPACE$TITLE": {
|
||||
"en": "Workspace",
|
||||
"zh-CN": "工作区",
|
||||
"de": "Arbeitsbereich",
|
||||
"ko-KR": "작업 공간",
|
||||
"no": "Arbeidsområde",
|
||||
"zh-TW": "工作區",
|
||||
"it": "Area di lavoro",
|
||||
"pt": "Espaço de trabalho",
|
||||
"es": "Espacio de trabajo",
|
||||
"ar": "مساحة العمل",
|
||||
"fr": "Espace de travail",
|
||||
"tr": "Çalışma Alanı",
|
||||
"ja": "ワークスペース"
|
||||
},
|
||||
"TERMINAL$WAITING_FOR_CLIENT": {
|
||||
"en": "Waiting for client to become ready...",
|
||||
"ja": "クライアントの準備を待機中...",
|
||||
@@ -946,111 +992,13 @@
|
||||
"tr": "Geçersiz dosya yolu. Lütfen dosya adını kontrol edin ve tekrar deneyin.",
|
||||
"ja": "ファイルパスが無効です。ファイル名を確認して、もう一度お試しください。"
|
||||
},
|
||||
"WORKSPACE$PLANNER_TAB_LABEL": {
|
||||
"en": "Planner",
|
||||
"zh-CN": "规划器",
|
||||
"de": "Planer",
|
||||
"ko-KR": "플래너",
|
||||
"no": "Planlegger",
|
||||
"zh-TW": "規劃工具",
|
||||
"ar": "المخطط",
|
||||
"fr": "Planificateur",
|
||||
"it": "Pianificatore",
|
||||
"pt": "Planejador",
|
||||
"es": "Planificador",
|
||||
"ja": "プランナー",
|
||||
"tr": "Planlayıcı"
|
||||
},
|
||||
"WORKSPACE$JUPYTER_TAB_LABEL": {
|
||||
"en": "Jupyter",
|
||||
"zh-CN": "Jupyter",
|
||||
"de": "Jupyter",
|
||||
"ko-KR": "Jupyter",
|
||||
"no": "Jupyter",
|
||||
"zh-TW": "Jupyter",
|
||||
"ar": "Jupyter",
|
||||
"fr": "Jupyter",
|
||||
"it": "Jupyter",
|
||||
"pt": "Jupyter",
|
||||
"es": "Jupyter",
|
||||
"ja": "Jupyter",
|
||||
"tr": "Jupyter"
|
||||
},
|
||||
"WORKSPACE$CODE_EDITOR_TAB_LABEL": {
|
||||
"en": "Code Editor",
|
||||
"zh-CN": "代码编辑器",
|
||||
"de": "Code-Editor",
|
||||
"ko-KR": "코드 편집기",
|
||||
"no": "Kode editor",
|
||||
"zh-TW": "程式碼編輯器",
|
||||
"ar": "محرر الكود",
|
||||
"fr": "Éditeur de code",
|
||||
"it": "Editor di codice",
|
||||
"pt": "Editor de código",
|
||||
"es": "Editor de código",
|
||||
"ja": "コードエディタ",
|
||||
"tr": "Kod Editörü"
|
||||
},
|
||||
"WORKSPACE$BROWSER_TAB_LABEL": {
|
||||
"en": "Browser",
|
||||
"zh-CN": "浏览器",
|
||||
"de": "Browser",
|
||||
"ko-KR": "브라우저",
|
||||
"no": "Nettleser",
|
||||
"zh-TW": "瀏覽器",
|
||||
"it": "Browser",
|
||||
"pt": "Navegador",
|
||||
"es": "Navegador",
|
||||
"ar": "المتصفح",
|
||||
"fr": "Navigateur",
|
||||
"tr": "Tarayıcı",
|
||||
"ja": "ブラウザ"
|
||||
},
|
||||
"WORKSPACE$REFRESH": {
|
||||
"en": "Refresh",
|
||||
"de": "Aktualisieren",
|
||||
"zh-CN": "刷新",
|
||||
"zh-TW": "重新整理",
|
||||
"ko-KR": "새로고침",
|
||||
"no": "Oppdater",
|
||||
"it": "Aggiorna",
|
||||
"pt": "Atualizar",
|
||||
"es": "Actualizar",
|
||||
"ar": "تحديث",
|
||||
"fr": "Rafraîchir",
|
||||
"tr": "Yenile",
|
||||
"ja": "更新"
|
||||
},
|
||||
"WORKSPACE$OPEN": {
|
||||
"en": "Open workspace",
|
||||
"de": "Arbeitsbereich öffnen",
|
||||
"zh-CN": "打开工作区",
|
||||
"zh-TW": "開啟工作區",
|
||||
"ko-KR": "작업 공간 열기",
|
||||
"no": "Åpne arbeidsområde",
|
||||
"it": "Apri area di lavoro",
|
||||
"pt": "Abrir espaço de trabalho",
|
||||
"es": "Abrir espacio de trabajo",
|
||||
"ar": "فتح مساحة العمل",
|
||||
"fr": "Ouvrir l'espace de travail",
|
||||
"tr": "Çalışma alanını aç",
|
||||
"ja": "ワークスペースを開く"
|
||||
},
|
||||
"WORKSPACE$CLOSE": {
|
||||
"en": "Close workspace",
|
||||
"de": "Arbeitsbereich schließen",
|
||||
"zh-CN": "关闭工作区",
|
||||
"zh-TW": "關閉工作區",
|
||||
"ko-KR": "작업 공간 닫기",
|
||||
"no": "Lukk arbeidsområde",
|
||||
"it": "Chiudi area di lavoro",
|
||||
"pt": "Fechar espaço de trabalho",
|
||||
"es": "Cerrar espacio de trabajo",
|
||||
"ar": "إغلاق مساحة العمل",
|
||||
"fr": "Fermer l'espace de travail",
|
||||
"tr": "Çalışma alanını kapat",
|
||||
"ja": "ワークスペースを閉じる"
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
"VSCODE$OPEN": {
|
||||
"en": "Open in VS Code",
|
||||
"ja": "VS Codeで開く",
|
||||
@@ -1066,6 +1014,81 @@
|
||||
"fr": "Ouvrir dans VS Code",
|
||||
"tr": "VS Code'da aç"
|
||||
},
|
||||
"VSCODE$TITLE": {
|
||||
"en": "VS Code",
|
||||
"ja": "VS Code",
|
||||
"zh-CN": "VS Code",
|
||||
"zh-TW": "VS Code",
|
||||
"ko-KR": "VS Code",
|
||||
"de": "VS Code",
|
||||
"no": "VS Code",
|
||||
"it": "VS Code",
|
||||
"pt": "VS Code",
|
||||
"es": "VS Code",
|
||||
"ar": "VS Code",
|
||||
"fr": "VS Code",
|
||||
"tr": "VS Code"
|
||||
},
|
||||
"VSCODE$LOADING": {
|
||||
"en": "Loading VS Code...",
|
||||
"ja": "VS Codeを読み込み中...",
|
||||
"zh-CN": "正在加载VS Code...",
|
||||
"zh-TW": "正在加載VS Code...",
|
||||
"ko-KR": "VS Code 로딩 중...",
|
||||
"de": "VS Code wird geladen...",
|
||||
"no": "Laster VS Code...",
|
||||
"it": "Caricamento di VS Code...",
|
||||
"pt": "Carregando VS Code...",
|
||||
"es": "Cargando VS Code...",
|
||||
"ar": "جاري تحميل VS Code...",
|
||||
"fr": "Chargement de VS Code...",
|
||||
"tr": "VS Code yükleniyor..."
|
||||
},
|
||||
"VSCODE$URL_NOT_AVAILABLE": {
|
||||
"en": "VS Code URL not available",
|
||||
"ja": "VS Code URLが利用できません",
|
||||
"zh-CN": "VS Code URL不可用",
|
||||
"zh-TW": "VS Code URL不可用",
|
||||
"ko-KR": "VS Code URL을 사용할 수 없습니다",
|
||||
"de": "VS Code URL nicht verfügbar",
|
||||
"no": "VS Code URL ikke tilgjengelig",
|
||||
"it": "URL di VS Code non disponibile",
|
||||
"pt": "URL do VS Code não disponível",
|
||||
"es": "URL de VS Code no disponible",
|
||||
"ar": "رابط VS Code غير متوفر",
|
||||
"fr": "URL VS Code non disponible",
|
||||
"tr": "VS Code URL'si mevcut değil"
|
||||
},
|
||||
"VSCODE$FETCH_ERROR": {
|
||||
"en": "Failed to fetch VS Code URL",
|
||||
"ja": "VS Code URLの取得に失敗しました",
|
||||
"zh-CN": "获取VS Code URL失败",
|
||||
"zh-TW": "獲取VS Code URL失敗",
|
||||
"ko-KR": "VS Code URL을 가져오지 못했습니다",
|
||||
"de": "Fehler beim Abrufen der VS Code URL",
|
||||
"no": "Kunne ikke hente VS Code URL",
|
||||
"it": "Impossibile recuperare l'URL di VS Code",
|
||||
"pt": "Falha ao buscar URL do VS Code",
|
||||
"es": "Error al obtener la URL de VS Code",
|
||||
"ar": "فشل في جلب رابط VS Code",
|
||||
"fr": "Échec de la récupération de l'URL VS Code",
|
||||
"tr": "VS Code URL'si alınamadı"
|
||||
},
|
||||
"VSCODE$IFRAME_PERMISSIONS": {
|
||||
"en": "clipboard-read; clipboard-write",
|
||||
"ja": "clipboard-read; clipboard-write",
|
||||
"zh-CN": "clipboard-read; clipboard-write",
|
||||
"zh-TW": "clipboard-read; clipboard-write",
|
||||
"ko-KR": "clipboard-read; clipboard-write",
|
||||
"de": "clipboard-read; clipboard-write",
|
||||
"no": "clipboard-read; clipboard-write",
|
||||
"it": "clipboard-read; clipboard-write",
|
||||
"pt": "clipboard-read; clipboard-write",
|
||||
"es": "clipboard-read; clipboard-write",
|
||||
"ar": "clipboard-read; clipboard-write",
|
||||
"fr": "clipboard-read; clipboard-write",
|
||||
"tr": "clipboard-read; clipboard-write"
|
||||
},
|
||||
"INCREASE_TEST_COVERAGE": {
|
||||
"en": "Increase test coverage",
|
||||
"ja": "テストカバレッジを向上",
|
||||
@@ -1990,66 +2013,10 @@
|
||||
"tr": "Dosyaları buraya bırakın",
|
||||
"ja": "ここにファイルをドロップ"
|
||||
},
|
||||
"EXPLORER$LABEL_WORKSPACE": {
|
||||
"en": "Workspace",
|
||||
"zh-CN": "工作区",
|
||||
"de": "Arbeitsbereich",
|
||||
"zh-TW": "工作區",
|
||||
"es": "Espacio de trabajo",
|
||||
"fr": "Espace de travail",
|
||||
"it": "Area di lavoro",
|
||||
"pt": "Espaço de trabalho",
|
||||
"ko-KR": "워크스페이스",
|
||||
"ar": "مساحة العمل",
|
||||
"tr": "Çalışma alanı",
|
||||
"no": "Arbeidsområde",
|
||||
"ja": "ワークスペース"
|
||||
},
|
||||
"EXPLORER$EMPTY_WORKSPACE_MESSAGE": {
|
||||
"en": "No files in workspace",
|
||||
"zh-CN": "工作区为空",
|
||||
"de": "Keine Dateien im Arbeitsbereich",
|
||||
"zh-TW": "工作區為空",
|
||||
"es": "No hay archivos en el espacio de trabajo",
|
||||
"fr": "Aucun fichier dans l'espace de travail",
|
||||
"it": "Nessun file nell'area di lavoro",
|
||||
"pt": "Nenhum arquivo no espaço de trabalho",
|
||||
"ko-KR": "워크스페이스가 비어 있습니다",
|
||||
"ar": "لا توجد ملفات في مساحة العمل",
|
||||
"tr": "Çalışma alanında dosya yok",
|
||||
"no": "Ingen filer i arbeidsområdet",
|
||||
"ja": "ワークスペースにファイルがありません"
|
||||
},
|
||||
"EXPLORER$LOADING_WORKSPACE_MESSAGE": {
|
||||
"en": "Loading workspace...",
|
||||
"zh-CN": "加载工作区中...",
|
||||
"de": "Arbeitsbereich wird geladen...",
|
||||
"zh-TW": "載入工作區中...",
|
||||
"es": "Cargando espacio de trabajo...",
|
||||
"fr": "Chargement de l'espace de travail...",
|
||||
"it": "Caricamento dell'area di lavoro...",
|
||||
"pt": "Carregando espaço de trabalho...",
|
||||
"ko-KR": "워크스페이스 로딩 중...",
|
||||
"ar": "جارٍ تحميل مساحة العمل...",
|
||||
"tr": "Çalışma alanı yükleniyor...",
|
||||
"no": "Laster arbeidsområde...",
|
||||
"ja": "ワークスペースを読み込み中..."
|
||||
},
|
||||
"EXPLORER$REFRESH_ERROR_MESSAGE": {
|
||||
"en": "Error refreshing workspace",
|
||||
"zh-CN": "刷新时出错",
|
||||
"de": "Fehler beim Aktualisieren des Arbeitsbereichs",
|
||||
"zh-TW": "重新整理時發生錯誤",
|
||||
"es": "Error al actualizar el espacio de trabajo",
|
||||
"fr": "Erreur lors de l'actualisation de l'espace de travail",
|
||||
"it": "Errore durante l'aggiornamento dell'area di lavoro",
|
||||
"pt": "Erro ao atualizar o espaço de trabalho",
|
||||
"ko-KR": "새로고침 중 오류 발생",
|
||||
"ar": "خطأ في تحديث مساحة العمل",
|
||||
"tr": "Çalışma alanı yenilenirken hata oluştu",
|
||||
"no": "Feil ved oppdatering av arbeidsområde",
|
||||
"ja": "ワークスペースの更新中にエラーが発生しました"
|
||||
},
|
||||
|
||||
|
||||
|
||||
|
||||
"EXPLORER$UPLOAD_SUCCESS_MESSAGE": {
|
||||
"en": "Successfully uploaded {{count}} file(s)",
|
||||
"zh-CN": "上传成功",
|
||||
@@ -4058,6 +4025,21 @@
|
||||
"BUTTON$COPY_TO_CLIPBOARD": {
|
||||
"en": "Copy to Clipboard"
|
||||
},
|
||||
"BUTTON$REFRESH": {
|
||||
"en": "Refresh",
|
||||
"ja": "更新",
|
||||
"zh-CN": "刷新",
|
||||
"zh-TW": "重新整理",
|
||||
"ko-KR": "새로고침",
|
||||
"no": "Oppdater",
|
||||
"it": "Aggiorna",
|
||||
"pt": "Atualizar",
|
||||
"es": "Actualizar",
|
||||
"ar": "تحديث",
|
||||
"fr": "Rafraîchir",
|
||||
"tr": "Yenile",
|
||||
"de": "Aktualisieren"
|
||||
},
|
||||
"ERROR$REQUIRED_FIELD": {
|
||||
"en": "This field is required"
|
||||
},
|
||||
@@ -4935,51 +4917,9 @@
|
||||
"es": "Subir archivo",
|
||||
"tr": "Dosya yükle"
|
||||
},
|
||||
"FILE_EXPLORER$REFRESH_WORKSPACE": {
|
||||
"en": "Refresh workspace",
|
||||
"zh-CN": "刷新工作区",
|
||||
"zh-TW": "重新整理工作區",
|
||||
"ko-KR": "워크스페이스 새로고침",
|
||||
"ja": "ワークスペースを更新",
|
||||
"no": "Oppdater arbeidsområde",
|
||||
"ar": "تحديث مساحة العمل",
|
||||
"de": "Arbeitsbereich aktualisieren",
|
||||
"fr": "Actualiser l'espace de travail",
|
||||
"it": "Aggiorna area di lavoro",
|
||||
"pt": "Atualizar área de trabalho",
|
||||
"es": "Actualizar espacio de trabajo",
|
||||
"tr": "Çalışma alanını yenile"
|
||||
},
|
||||
"FILE_EXPLORER$OPEN_WORKSPACE": {
|
||||
"en": "Open workspace",
|
||||
"zh-CN": "打开工作区",
|
||||
"zh-TW": "開啟工作區",
|
||||
"ko-KR": "워크스페이스 열기",
|
||||
"ja": "ワークスペースを開く",
|
||||
"no": "Åpne arbeidsområde",
|
||||
"ar": "فتح مساحة العمل",
|
||||
"de": "Arbeitsbereich öffnen",
|
||||
"fr": "Ouvrir l'espace de travail",
|
||||
"it": "Apri area di lavoro",
|
||||
"pt": "Abrir área de trabalho",
|
||||
"es": "Abrir espacio de trabajo",
|
||||
"tr": "Çalışma alanını aç"
|
||||
},
|
||||
"FILE_EXPLORER$CLOSE_WORKSPACE": {
|
||||
"en": "Close workspace",
|
||||
"zh-CN": "关闭工作区",
|
||||
"zh-TW": "關閉工作區",
|
||||
"ko-KR": "워크스페이스 닫기",
|
||||
"ja": "ワークスペースを閉じる",
|
||||
"no": "Lukk arbeidsområde",
|
||||
"ar": "إغلاق مساحة العمل",
|
||||
"de": "Arbeitsbereich schließen",
|
||||
"fr": "Fermer l'espace de travail",
|
||||
"it": "Chiudi area di lavoro",
|
||||
"pt": "Fechar área de trabalho",
|
||||
"es": "Cerrar espacio de trabajo",
|
||||
"tr": "Çalışma alanını kapat"
|
||||
},
|
||||
|
||||
|
||||
|
||||
"ACTION_MESSAGE$RUN": {
|
||||
"en": "Running <cmd>{{action.payload.args.command}}</cmd>",
|
||||
"zh-CN": "运行 <cmd>{{action.payload.args.command}}</cmd>",
|
||||
|
||||
@@ -15,11 +15,11 @@ export default [
|
||||
]),
|
||||
route("conversations/:conversationId", "routes/conversation.tsx", [
|
||||
index("routes/editor.tsx"),
|
||||
route("workspace", "routes/editor-tab.tsx"),
|
||||
route("browser", "routes/browser-tab.tsx"),
|
||||
route("jupyter", "routes/jupyter-tab.tsx"),
|
||||
route("served", "routes/served-tab.tsx"),
|
||||
route("terminal", "routes/terminal-tab.tsx"),
|
||||
route("vscode", "routes/vscode-tab.tsx"),
|
||||
]),
|
||||
]),
|
||||
] satisfies RouteConfig;
|
||||
|
||||
@@ -2,10 +2,12 @@ import { useDisclosure } from "@heroui/react";
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { FaServer } from "react-icons/fa";
|
||||
import { FaServer, FaExternalLinkAlt } from "react-icons/fa";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DiGit } from "react-icons/di";
|
||||
import { VscCode } from "react-icons/vsc";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
import {
|
||||
ConversationProvider,
|
||||
useConversation,
|
||||
@@ -14,12 +16,12 @@ import { Controls } from "#/components/features/controls/controls";
|
||||
import { clearMessages, addUserMessage } from "#/state/chat-slice";
|
||||
import { clearTerminal } from "#/state/command-slice";
|
||||
import { useEffectOnce } from "#/hooks/use-effect-once";
|
||||
import CodeIcon from "#/icons/code.svg?react";
|
||||
|
||||
import GlobeIcon from "#/icons/globe.svg?react";
|
||||
import JupyterIcon from "#/icons/jupyter.svg?react";
|
||||
import TerminalIcon from "#/icons/terminal.svg?react";
|
||||
import { clearJupyter } from "#/state/jupyter-slice";
|
||||
import { FilesProvider } from "#/context/files";
|
||||
|
||||
import { ChatInterface } from "../components/features/chat/chat-interface";
|
||||
import { WsClientProvider } from "#/context/ws-client-provider";
|
||||
import { EventHandler } from "../wrapper/event-handler";
|
||||
@@ -50,6 +52,7 @@ function AppContent() {
|
||||
const { initialPrompt, files } = useSelector(
|
||||
(state: RootState) => state.initialQuery,
|
||||
);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const dispatch = useDispatch();
|
||||
const endSession = useEndSession();
|
||||
|
||||
@@ -134,9 +137,37 @@ function AppContent() {
|
||||
icon: <DiGit className="w-6 h-6" />,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TITLE),
|
||||
to: "workspace",
|
||||
icon: <CodeIcon />,
|
||||
label: (
|
||||
<div className="flex items-center gap-1">
|
||||
{t(I18nKey.VSCODE$TITLE)}
|
||||
</div>
|
||||
),
|
||||
to: "vscode",
|
||||
icon: <VscCode className="w-5 h-5" />,
|
||||
rightContent: !RUNTIME_INACTIVE_STATES.includes(
|
||||
curAgentState,
|
||||
) ? (
|
||||
<FaExternalLinkAlt
|
||||
className="w-3 h-3 text-neutral-400 cursor-pointer"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (conversationId) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/conversations/${conversationId}/vscode-url`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (data.vscode_url) {
|
||||
window.open(data.vscode_url, "_blank");
|
||||
}
|
||||
} catch (err) {
|
||||
// Silently handle the error
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
label: t(I18nKey.WORKSPACE$TERMINAL_TAB_LABEL),
|
||||
@@ -160,9 +191,7 @@ function AppContent() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<FilesProvider>
|
||||
<Outlet />
|
||||
</FilesProvider>
|
||||
<Outlet />
|
||||
</Container>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import React from "react";
|
||||
import { useRouteError } from "react-router";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { vscDarkPlus } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FileExplorer } from "#/components/features/file-explorer/file-explorer";
|
||||
import { useFiles } from "#/context/files";
|
||||
import { getLanguageFromPath } from "#/utils/get-language-from-path";
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full border border-danger rounded-b-xl flex flex-col items-center justify-center gap-2 bg-red-500/5">
|
||||
<h1 className="text-3xl font-bold">{t("ERROR$GENERIC")}</h1>
|
||||
{error instanceof Error && <pre>{error.message}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileViewer() {
|
||||
const [fileExplorerIsOpen, setFileExplorerIsOpen] = React.useState(true);
|
||||
const { selectedPath, files } = useFiles();
|
||||
|
||||
const toggleFileExplorer = () => {
|
||||
setFileExplorerIsOpen((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-base-secondary relative">
|
||||
<FileExplorer isOpen={fileExplorerIsOpen} onToggle={toggleFileExplorer} />
|
||||
<div className="w-full h-full flex flex-col">
|
||||
{selectedPath && files[selectedPath] && (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
<SyntaxHighlighter
|
||||
language={getLanguageFromPath(selectedPath)}
|
||||
style={vscDarkPlus}
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
padding: "10px",
|
||||
height: "100%",
|
||||
background: "#171717",
|
||||
fontSize: "0.875rem",
|
||||
borderRadius: 0,
|
||||
}}
|
||||
>
|
||||
{files[selectedPath]}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileViewer;
|
||||
@@ -0,0 +1,81 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSelector } from "react-redux";
|
||||
import { useConversation } from "#/context/conversation-context";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
|
||||
|
||||
function VSCodeTab() {
|
||||
const { t } = useTranslation();
|
||||
const { conversationId } = useConversation();
|
||||
const [vsCodeUrl, setVsCodeUrl] = React.useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const { curAgentState } = useSelector((state: RootState) => state.agent);
|
||||
const isRuntimeInactive = RUNTIME_INACTIVE_STATES.includes(curAgentState);
|
||||
|
||||
React.useEffect(() => {
|
||||
async function fetchVSCodeUrl() {
|
||||
if (!conversationId || isRuntimeInactive) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetch(
|
||||
`/api/conversations/${conversationId}/vscode-url`,
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.vscode_url) {
|
||||
setVsCodeUrl(data.vscode_url);
|
||||
} else {
|
||||
setError(t(I18nKey.VSCODE$URL_NOT_AVAILABLE));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t(I18nKey.VSCODE$FETCH_ERROR));
|
||||
// Error is handled by setting the error state
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchVSCodeUrl();
|
||||
}, [conversationId, isRuntimeInactive, t]);
|
||||
|
||||
if (isRuntimeInactive) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{t("DIFF_VIEWER$WAITING_FOR_RUNTIME")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !vsCodeUrl) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center text-center justify-center text-2xl text-tertiary-light">
|
||||
{error || t(I18nKey.VSCODE$URL_NOT_AVAILABLE)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full w-full">
|
||||
<iframe
|
||||
title={t(I18nKey.VSCODE$TITLE)}
|
||||
src={vsCodeUrl}
|
||||
className="w-full h-full border-0"
|
||||
allow={t(I18nKey.VSCODE$IFRAME_PERMISSIONS)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VSCodeTab;
|
||||
@@ -1,18 +1,18 @@
|
||||
enum TabOption {
|
||||
PLANNER = "planner",
|
||||
CODE = "code",
|
||||
BROWSER = "browser",
|
||||
JUPYTER = "jupyter",
|
||||
VSCODE = "vscode",
|
||||
}
|
||||
|
||||
type TabType =
|
||||
| TabOption.PLANNER
|
||||
| TabOption.CODE
|
||||
| TabOption.BROWSER
|
||||
| TabOption.JUPYTER;
|
||||
| TabOption.JUPYTER
|
||||
| TabOption.VSCODE;
|
||||
|
||||
const AllTabs = [
|
||||
TabOption.CODE,
|
||||
TabOption.VSCODE,
|
||||
TabOption.BROWSER,
|
||||
TabOption.PLANNER,
|
||||
TabOption.JUPYTER,
|
||||
|
||||
@@ -6,10 +6,17 @@
|
||||
*/
|
||||
export const generateAuthUrl = (identityProvider: string, requestUrl: URL) => {
|
||||
const redirectUri = `${requestUrl.origin}/oauth/keycloak/callback`;
|
||||
const authUrl = requestUrl.hostname
|
||||
let authUrl = requestUrl.hostname
|
||||
.replace(/(^|\.)staging\.all-hands\.dev$/, "$1auth.staging.all-hands.dev")
|
||||
.replace(/(^|\.)app\.all-hands\.dev$/, "auth.app.all-hands.dev")
|
||||
.replace(/(^|\.)localhost$/, "auth.staging.all-hands.dev");
|
||||
.replace(/(^|\.)localhost$/, "localhost:8080");
|
||||
|
||||
// If no replacements matched, prepend "auth." (excluding localhost)
|
||||
if (authUrl === requestUrl.hostname && requestUrl.hostname !== "localhost") {
|
||||
authUrl = `auth.${requestUrl.hostname}`;
|
||||
}
|
||||
const scope = "openid email profile"; // OAuth scope - not user-facing
|
||||
return `https://${authUrl}/realms/allhands/protocol/openid-connect/auth?client_id=allhands&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
const isLocalhost = requestUrl.hostname === "localhost";
|
||||
const protocol = isLocalhost ? "http" : "https";
|
||||
return `${protocol}://${authUrl}/realms/testing/protocol/openid-connect/auth?client_id=testing&kc_idp_hint=${identityProvider}&response_type=code&redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}`;
|
||||
};
|
||||
|
||||
@@ -100,9 +100,8 @@ def initialize_repository_for_runtime(
|
||||
The repository directory path if a repository was cloned, None otherwise.
|
||||
"""
|
||||
# clone selected repository if provided
|
||||
github_token = (
|
||||
SecretStr(os.environ.get('GITHUB_TOKEN')) if not github_token else github_token
|
||||
)
|
||||
if github_token is None and 'GITHUB_TOKEN' in os.environ:
|
||||
github_token = SecretStr(os.environ['GITHUB_TOKEN'])
|
||||
|
||||
secret_store = (
|
||||
SecretStore(
|
||||
|
||||
@@ -16,11 +16,9 @@ class CmdRunAction(Action):
|
||||
)
|
||||
is_input: bool = False # if True, the command is an input to the running process
|
||||
thought: str = ''
|
||||
blocking: bool = False
|
||||
blocking: bool = False # if True, the command will be run in a blocking manner, but a timeout must be set through _set_hard_timeout
|
||||
is_static: bool = False # if True, runs the command in a separate process
|
||||
cwd: str | None = None # current working directory, only used if is_static is True
|
||||
# If blocking is True, the command will be run in a blocking manner.
|
||||
# e.g., it will NOT return early due to soft timeout.
|
||||
hidden: bool = False
|
||||
action: str = ActionType.RUN
|
||||
runnable: ClassVar[bool] = True
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import Event, EventSource
|
||||
from openhands.events.serialization.action import action_from_dict
|
||||
from openhands.events.serialization.observation import observation_from_dict
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
@@ -18,8 +19,6 @@ from openhands.integrations.service_types import (
|
||||
)
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.import_utils import get_impl
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
class GitHubService(BaseGitService, GitService):
|
||||
@@ -159,12 +158,9 @@ class GitHubService(BaseGitService, GitService):
|
||||
|
||||
return repos[:max_repos] # Trim to max_repos if needed
|
||||
|
||||
|
||||
def parse_pushed_at_date(self, repo):
|
||||
ts = repo.get("pushed_at")
|
||||
return datetime.strptime(ts, "%Y-%m-%dT%H:%M:%SZ") if ts else datetime.min
|
||||
|
||||
|
||||
ts = repo.get('pushed_at')
|
||||
return datetime.strptime(ts, '%Y-%m-%dT%H:%M:%SZ') if ts else datetime.min
|
||||
|
||||
async def get_repositories(self, sort: str, app_mode: AppMode) -> list[Repository]:
|
||||
MAX_REPOS = 1000
|
||||
@@ -192,11 +188,9 @@ class GitHubService(BaseGitService, GitService):
|
||||
# If we've already reached MAX_REPOS, no need to check other installations
|
||||
if len(all_repos) >= MAX_REPOS:
|
||||
break
|
||||
|
||||
if sort == "pushed":
|
||||
all_repos.sort(
|
||||
key=self.parse_pushed_at_date, reverse=True
|
||||
)
|
||||
|
||||
if sort == 'pushed':
|
||||
all_repos.sort(key=self.parse_pushed_at_date, reverse=True)
|
||||
else:
|
||||
# Original behavior for non-SaaS mode
|
||||
params = {'per_page': str(PER_PAGE), 'sort': sort}
|
||||
@@ -205,7 +199,6 @@ class GitHubService(BaseGitService, GitService):
|
||||
# Fetch user repositories
|
||||
all_repos = await self._fetch_paginated_repos(url, params, MAX_REPOS)
|
||||
|
||||
|
||||
# Convert to Repository objects
|
||||
return [
|
||||
Repository(
|
||||
|
||||
@@ -108,7 +108,9 @@ class GitLabService(BaseGitService, GitService):
|
||||
except httpx.HTTPError as e:
|
||||
raise self.handle_http_error(e)
|
||||
|
||||
async def execute_graphql_query(self, query: str, variables: dict[str, Any] = {}) -> Any:
|
||||
async def execute_graphql_query(
|
||||
self, query: str, variables: dict[str, Any]|None = None
|
||||
) -> Any:
|
||||
"""
|
||||
Execute a GraphQL query against the GitLab GraphQL API
|
||||
|
||||
@@ -119,6 +121,8 @@ class GitLabService(BaseGitService, GitService):
|
||||
Returns:
|
||||
The data portion of the GraphQL response
|
||||
"""
|
||||
if variables is None:
|
||||
variables = {}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
gitlab_headers = await self._get_gitlab_headers()
|
||||
@@ -294,7 +298,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
try:
|
||||
tasks: list[SuggestedTask] = []
|
||||
|
||||
|
||||
# Get merge requests using GraphQL
|
||||
response = await self.execute_graphql_query(query)
|
||||
data = response.get('currentUser', {})
|
||||
@@ -343,27 +347,27 @@ class GitLabService(BaseGitService, GitService):
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Get assigned issues using REST API
|
||||
url = f"{self.BASE_URL}/issues"
|
||||
url = f'{self.BASE_URL}/issues'
|
||||
params = {
|
||||
"assignee_username": username,
|
||||
"state": "opened",
|
||||
"scope": "assigned_to_me"
|
||||
'assignee_username': username,
|
||||
'state': 'opened',
|
||||
'scope': 'assigned_to_me',
|
||||
}
|
||||
|
||||
|
||||
issues_response, _ = await self._make_request(
|
||||
method=RequestMethod.GET,
|
||||
url=url,
|
||||
params=params
|
||||
method=RequestMethod.GET, url=url, params=params
|
||||
)
|
||||
|
||||
|
||||
# Process issues
|
||||
for issue in issues_response:
|
||||
repo_name = issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
repo_name = (
|
||||
issue.get('references', {}).get('full', '').split('#')[0].strip()
|
||||
)
|
||||
issue_number = issue.get('iid')
|
||||
title = issue.get('title', '')
|
||||
|
||||
|
||||
tasks.append(
|
||||
SuggestedTask(
|
||||
git_provider=ProviderType.GITLAB,
|
||||
|
||||
@@ -3,6 +3,7 @@ from enum import Enum
|
||||
from typing import Any, Protocol
|
||||
|
||||
from httpx import AsyncClient, HTTPError, HTTPStatusError
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
@@ -29,6 +30,57 @@ class SuggestedTask(BaseModel):
|
||||
issue_number: int
|
||||
title: str
|
||||
|
||||
def get_provider_terms(self) -> dict:
|
||||
if self.git_provider == ProviderType.GITLAB:
|
||||
return {
|
||||
'requestType': 'Merge Request',
|
||||
'requestTypeShort': 'MR',
|
||||
'apiName': 'GitLab API',
|
||||
'tokenEnvVar': 'GITLAB_TOKEN',
|
||||
'ciSystem': 'CI pipelines',
|
||||
'ciProvider': 'GitLab',
|
||||
'requestVerb': 'merge request',
|
||||
}
|
||||
elif self.git_provider == ProviderType.GITHUB:
|
||||
return {
|
||||
'requestType': 'Pull Request',
|
||||
'requestTypeShort': 'PR',
|
||||
'apiName': 'GitHub API',
|
||||
'tokenEnvVar': 'GITHUB_TOKEN',
|
||||
'ciSystem': 'GitHub Actions',
|
||||
'ciProvider': 'GitHub',
|
||||
'requestVerb': 'pull request',
|
||||
}
|
||||
|
||||
raise ValueError(f'Provider {self.git_provider} for suggested task prompts')
|
||||
|
||||
def get_prompt_for_task(
|
||||
self,
|
||||
) -> str:
|
||||
task_type = self.task_type
|
||||
issue_number = self.issue_number
|
||||
repo = self.repo
|
||||
|
||||
env = Environment(
|
||||
loader=FileSystemLoader('openhands/integrations/templates/suggested_task')
|
||||
)
|
||||
|
||||
template = None
|
||||
if task_type == TaskType.MERGE_CONFLICTS:
|
||||
template = env.get_template('merge_conflict_prompt.j2')
|
||||
elif task_type == TaskType.FAILING_CHECKS:
|
||||
template = env.get_template('failing_checks_prompt.j2')
|
||||
elif task_type == TaskType.UNRESOLVED_COMMENTS:
|
||||
template = env.get_template('unresolved_comments_prompt.j2')
|
||||
elif task_type == TaskType.OPEN_ISSUE:
|
||||
template = env.get_template('open_issue_prompt.j2')
|
||||
else:
|
||||
raise ValueError(f'Unsupported task type: {task_type}')
|
||||
|
||||
terms = self.get_provider_terms()
|
||||
|
||||
return template.render(issue_number=issue_number, repo=repo, **terms)
|
||||
|
||||
|
||||
class User(BaseModel):
|
||||
id: int
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to fix the failing CI checks.
|
||||
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
|
||||
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
|
||||
Then use the {{ apiName }} to look at the {{ ciSystem }} that are failing on the most recent commit. Try and reproduce the failure locally.
|
||||
Get things working locally, then push your changes. Sleep for 30 seconds at a time until the {{ ciProvider }} {{ ciSystem.lower() }} have run again.
|
||||
If they are still failing, repeat the process.
|
||||
@@ -0,0 +1,4 @@
|
||||
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to fix the merge conflicts.
|
||||
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
|
||||
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
|
||||
Then resolve the merge conflicts. If you aren't sure what the right solution is, look back through the commit history at the commits that introduced the conflict and resolve them accordingly.
|
||||
@@ -0,0 +1,4 @@
|
||||
You are working on Issue #{{ issue_number }} in repository {{ repo }}. Your goal is to fix the issue.
|
||||
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the issue details and any comments on the issue.
|
||||
Then check out a new branch and investigate what changes will need to be made.
|
||||
Finally, make the required changes and open up a {{ requestVerb }}. Be sure to reference the issue in the {{ requestTypeShort }} description.
|
||||
@@ -0,0 +1,5 @@
|
||||
You are working on {{ requestType }} #{{ issue_number }} in repository {{ repo }}. You need to resolve the remaining comments from reviewers.
|
||||
Use the {{ apiName }} with the {{ tokenEnvVar }} environment variable to retrieve the {{ requestTypeShort }} details.
|
||||
Check out the branch from that {{ requestVerb }} and look at the diff versus the base branch of the {{ requestTypeShort }} to understand the {{ requestTypeShort }}'s intention.
|
||||
Then use the {{ apiName }} to retrieve all the feedback on the {{ requestTypeShort }} so far.
|
||||
If anything hasn't been addressed, address it and commit your changes back to the same branch.
|
||||
+26
-40
@@ -97,8 +97,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
config: AppConfig
|
||||
initial_env_vars: dict[str, str]
|
||||
attach_to_existing: bool
|
||||
status_callback: Callable | None
|
||||
git_dir: str | None
|
||||
status_callback: Callable[[str, str, str], None] | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -107,17 +106,15 @@ class Runtime(FileEditRuntimeMixin):
|
||||
sid: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
status_callback: Callable[[str, str, str], None] | None = None,
|
||||
attach_to_existing: bool = False,
|
||||
headless_mode: bool = False,
|
||||
user_id: str | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
):
|
||||
# GitHandler will be initialized with an async function
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
)
|
||||
self.git_dir = None
|
||||
self.sid = sid
|
||||
self.event_stream = event_stream
|
||||
self.event_stream.subscribe(
|
||||
@@ -319,9 +316,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
selected_branch: str | None,
|
||||
repository_provider: ProviderType = ProviderType.GITHUB,
|
||||
) -> str:
|
||||
# Set the git_dir to the workspace mount path by default
|
||||
self.git_dir = self.config.workspace_mount_path_in_sandbox
|
||||
|
||||
if not selected_repository:
|
||||
# In SaaS mode (indicated by user_id being set), always run git init
|
||||
# In OSS mode, only run git init if workspace_base is not set
|
||||
@@ -333,7 +327,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
command='git init',
|
||||
)
|
||||
self.run_action(action)
|
||||
# git_dir is already set to workspace mount path
|
||||
else:
|
||||
logger.info(
|
||||
'In workspace mount mode, not initializing a new git repository.'
|
||||
@@ -351,12 +344,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
else selected_repository.git_provider
|
||||
)
|
||||
|
||||
if not git_provider_tokens:
|
||||
raise RuntimeError('Need git provider tokens to clone repo')
|
||||
git_token = git_provider_tokens[chosen_provider].token
|
||||
if not git_token:
|
||||
raise RuntimeError('Need a valid git token to clone repo')
|
||||
|
||||
domain = provider_domains[chosen_provider]
|
||||
repository = (
|
||||
selected_repository
|
||||
@@ -364,12 +351,18 @@ class Runtime(FileEditRuntimeMixin):
|
||||
else selected_repository.full_name
|
||||
)
|
||||
|
||||
if chosen_provider == ProviderType.GITLAB:
|
||||
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
# Try to use token if available, otherwise use public URL
|
||||
if git_provider_tokens and chosen_provider in git_provider_tokens:
|
||||
git_token = git_provider_tokens[chosen_provider].token
|
||||
if git_token:
|
||||
if chosen_provider == ProviderType.GITLAB:
|
||||
remote_repo_url = f'https://oauth2:{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
else:
|
||||
remote_repo_url = f'https://{domain}/{repository}.git'
|
||||
else:
|
||||
remote_repo_url = (
|
||||
f'https://{git_token.get_secret_value()}@{domain}/{repository}.git'
|
||||
)
|
||||
remote_repo_url = f'https://{domain}/{repository}.git'
|
||||
|
||||
if not remote_repo_url:
|
||||
raise ValueError('Missing either Git token or valid repository')
|
||||
@@ -402,13 +395,6 @@ class Runtime(FileEditRuntimeMixin):
|
||||
)
|
||||
self.log('info', f'Cloning repo: {selected_repository}')
|
||||
self.run_action(action)
|
||||
|
||||
# Update git_dir to point to the cloned repository directory
|
||||
self.git_dir = os.path.join(
|
||||
self.config.workspace_mount_path_in_sandbox, dir_name
|
||||
)
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
|
||||
return dir_name
|
||||
|
||||
def maybe_run_setup_script(self):
|
||||
@@ -423,7 +409,11 @@ class Runtime(FileEditRuntimeMixin):
|
||||
'info', 'STATUS$SETTING_UP_WORKSPACE', 'Setting up workspace...'
|
||||
)
|
||||
|
||||
action = CmdRunAction(f'chmod +x {setup_script} && source {setup_script}')
|
||||
# setup scripts time out after 10 minutes
|
||||
action = CmdRunAction(
|
||||
f'chmod +x {setup_script} && source {setup_script}', blocking=True
|
||||
)
|
||||
action.set_hard_timeout(600)
|
||||
obs = self.run_action(action)
|
||||
if isinstance(obs, CmdOutputObservation) and obs.exit_code != 0:
|
||||
self.log('error', f'Setup script failed: {obs.content}')
|
||||
@@ -626,15 +616,13 @@ class Runtime(FileEditRuntimeMixin):
|
||||
# Git
|
||||
# ====================================================================
|
||||
|
||||
async def _execute_shell_fn_git_handler(
|
||||
def _execute_shell_fn_git_handler(
|
||||
self, command: str, cwd: str | None
|
||||
) -> CommandResult:
|
||||
"""
|
||||
This function is used by the GitHandler to execute shell commands.
|
||||
"""
|
||||
obs = await call_sync_from_async(
|
||||
self.run, CmdRunAction(command=command, is_static=True, cwd=cwd)
|
||||
)
|
||||
obs = self.run(CmdRunAction(command=command, is_static=True, cwd=cwd))
|
||||
exit_code = 0
|
||||
content = ''
|
||||
|
||||
@@ -645,15 +633,13 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
return CommandResult(content=content, exit_code=exit_code)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_changes)
|
||||
def get_git_changes(self, cwd: str) -> list[dict[str, str]] | None:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_changes()
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
if self.git_dir:
|
||||
self.git_handler.set_cwd(self.git_dir)
|
||||
return await call_sync_from_async(self.git_handler.get_git_diff, file_path)
|
||||
def get_git_diff(self, file_path: str, cwd: str) -> dict[str, str]:
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
|
||||
@@ -253,6 +253,8 @@ class ActionExecutionClient(Runtime):
|
||||
|
||||
# set timeout to default if not set
|
||||
if action.timeout is None:
|
||||
if isinstance(action, CmdRunAction) and action.blocking:
|
||||
raise RuntimeError('Blocking command with no timeout set')
|
||||
# We don't block the command if this is a default timeout action
|
||||
action.set_hard_timeout(self.config.sandbox.timeout, blocking=False)
|
||||
|
||||
|
||||
@@ -505,9 +505,19 @@ class BashSession:
|
||||
)
|
||||
)
|
||||
|
||||
# Get initial state before sending command
|
||||
initial_pane_output = self._get_pane_content()
|
||||
initial_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(
|
||||
initial_pane_output
|
||||
)
|
||||
initial_ps1_count = len(initial_ps1_matches)
|
||||
logger.debug(f'Initial PS1 count: {initial_ps1_count}')
|
||||
|
||||
start_time = time.time()
|
||||
last_change_time = start_time
|
||||
last_pane_output = self._get_pane_content()
|
||||
last_pane_output = (
|
||||
initial_pane_output # Use initial output as the starting point
|
||||
)
|
||||
|
||||
# When prev command is still running, and we are trying to send a new command
|
||||
if (
|
||||
@@ -516,15 +526,20 @@ class BashSession:
|
||||
BashCommandStatus.HARD_TIMEOUT,
|
||||
BashCommandStatus.NO_CHANGE_TIMEOUT,
|
||||
}
|
||||
and not last_pane_output.endswith(
|
||||
CMD_OUTPUT_PS1_END
|
||||
and not last_pane_output.rstrip().endswith(
|
||||
CMD_OUTPUT_PS1_END.rstrip()
|
||||
) # prev command is not completed
|
||||
and not is_input
|
||||
and command != '' # not input and not empty command
|
||||
):
|
||||
_ps1_matches = CmdOutputMetadata.matches_ps1_metadata(last_pane_output)
|
||||
# Use initial_ps1_matches if _ps1_matches is empty, otherwise use _ps1_matches
|
||||
# This handles the case where the prompt might be scrolled off screen but existed before
|
||||
current_matches_for_output = (
|
||||
_ps1_matches if _ps1_matches else initial_ps1_matches
|
||||
)
|
||||
raw_command_output = self._combine_outputs_between_matches(
|
||||
last_pane_output, _ps1_matches
|
||||
last_pane_output, current_matches_for_output
|
||||
)
|
||||
metadata = CmdOutputMetadata() # No metadata available
|
||||
metadata.suffix = (
|
||||
@@ -577,23 +592,32 @@ class BashSession:
|
||||
logger.debug(f"BEGIN OF PANE CONTENT: {cur_pane_output.split('\n')[:10]}")
|
||||
logger.debug(f"END OF PANE CONTENT: {cur_pane_output.split('\n')[-10:]}")
|
||||
ps1_matches = CmdOutputMetadata.matches_ps1_metadata(cur_pane_output)
|
||||
current_ps1_count = len(ps1_matches)
|
||||
|
||||
if cur_pane_output != last_pane_output:
|
||||
last_pane_output = cur_pane_output
|
||||
last_change_time = time.time()
|
||||
logger.debug(f'CONTENT UPDATED DETECTED at {last_change_time}')
|
||||
|
||||
# 1) Execution completed
|
||||
# if the last command output contains the end marker
|
||||
if cur_pane_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip()):
|
||||
# 1) Execution completed:
|
||||
# Condition 1: A new prompt has appeared since the command started.
|
||||
# Condition 2: The prompt count hasn't increased (potentially because the initial one scrolled off),
|
||||
# BUT the *current* visible pane ends with a prompt, indicating completion.
|
||||
if (
|
||||
current_ps1_count > initial_ps1_count
|
||||
or cur_pane_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
|
||||
):
|
||||
return self._handle_completed_command(
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
ps1_matches=ps1_matches,
|
||||
)
|
||||
|
||||
# Timeout checks should only trigger if a new prompt hasn't appeared yet.
|
||||
|
||||
# 2) Execution timed out since there's no change in output
|
||||
# for a while (self.NO_CHANGE_TIMEOUT_SECONDS)
|
||||
# We ignore this if the command is *blocking
|
||||
# We ignore this if the command is *blocking*
|
||||
time_since_last_change = time.time() - last_change_time
|
||||
logger.debug(
|
||||
f'CHECKING NO CHANGE TIMEOUT ({self.NO_CHANGE_TIMEOUT_SECONDS}s): elapsed {time_since_last_change}. Action blocking: {action.blocking}'
|
||||
@@ -609,10 +633,12 @@ class BashSession:
|
||||
)
|
||||
|
||||
# 3) Execution timed out due to hard timeout
|
||||
elapsed_time = time.time() - start_time
|
||||
logger.debug(
|
||||
f'CHECKING HARD TIMEOUT ({action.timeout}s): elapsed {time.time() - start_time}'
|
||||
f'CHECKING HARD TIMEOUT ({action.timeout}s): elapsed {elapsed_time:.2f}'
|
||||
)
|
||||
if action.timeout and time.time() - start_time >= action.timeout:
|
||||
if action.timeout and elapsed_time >= action.timeout:
|
||||
logger.debug('Hard timeout triggered.')
|
||||
return self._handle_hard_timeout_command(
|
||||
command,
|
||||
pane_content=cur_pane_output,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Awaitable, Callable
|
||||
from typing import Callable
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -23,7 +23,7 @@ class GitHandler:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
execute_shell_fn: Callable[[str, str | None], Awaitable[CommandResult]],
|
||||
execute_shell_fn: Callable[[str, str | None], CommandResult],
|
||||
):
|
||||
self.execute = execute_shell_fn
|
||||
self.cwd: str | None = None
|
||||
@@ -37,11 +37,7 @@ class GitHandler:
|
||||
"""
|
||||
self.cwd = cwd
|
||||
|
||||
async def _execute_async(self, cmd: str, cwd: str | None) -> CommandResult:
|
||||
"""Execute the command asynchronously."""
|
||||
return await self.execute(cmd, cwd)
|
||||
|
||||
async def _is_git_repo(self) -> bool:
|
||||
def _is_git_repo(self) -> bool:
|
||||
"""
|
||||
Checks if the current directory is a Git repository.
|
||||
|
||||
@@ -49,10 +45,10 @@ class GitHandler:
|
||||
bool: True if inside a Git repository, otherwise False.
|
||||
"""
|
||||
cmd = 'git rev-parse --is-inside-work-tree'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip() == 'true'
|
||||
|
||||
async def _get_current_file_content(self, file_path: str) -> str:
|
||||
def _get_current_file_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the current content of a given file.
|
||||
|
||||
@@ -62,10 +58,10 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The file content.
|
||||
"""
|
||||
output = await self._execute_async(f'cat {file_path}', self.cwd)
|
||||
output = self.execute(f'cat {file_path}', self.cwd)
|
||||
return output.content
|
||||
|
||||
async def _verify_ref_exists(self, ref: str) -> bool:
|
||||
def _verify_ref_exists(self, ref: str) -> bool:
|
||||
"""
|
||||
Verifies whether a specific Git reference exists.
|
||||
|
||||
@@ -76,18 +72,18 @@ class GitHandler:
|
||||
bool: True if the reference exists, otherwise False.
|
||||
"""
|
||||
cmd = f'git rev-parse --verify {ref}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.exit_code == 0
|
||||
|
||||
async def _get_valid_ref(self) -> str | None:
|
||||
def _get_valid_ref(self) -> str | None:
|
||||
"""
|
||||
Determines a valid Git reference for comparison.
|
||||
|
||||
Returns:
|
||||
str | None: A valid Git reference or None if no valid reference is found.
|
||||
"""
|
||||
current_branch = await self._get_current_branch()
|
||||
default_branch = await self._get_default_branch()
|
||||
current_branch = self._get_current_branch()
|
||||
default_branch = self._get_default_branch()
|
||||
|
||||
ref_current_branch = f'origin/{current_branch}'
|
||||
ref_non_default_branch = f'$(git merge-base HEAD "$(git rev-parse --abbrev-ref origin/{default_branch})")'
|
||||
@@ -101,12 +97,12 @@ class GitHandler:
|
||||
ref_new_repo,
|
||||
]
|
||||
for ref in refs:
|
||||
if await self._verify_ref_exists(ref):
|
||||
if self._verify_ref_exists(ref):
|
||||
return ref
|
||||
|
||||
return None
|
||||
|
||||
async def _get_ref_content(self, file_path: str) -> str:
|
||||
def _get_ref_content(self, file_path: str) -> str:
|
||||
"""
|
||||
Retrieves the content of a file from a valid Git reference.
|
||||
|
||||
@@ -116,15 +112,15 @@ class GitHandler:
|
||||
Returns:
|
||||
str: The content of the file from the reference, or an empty string if unavailable.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return ''
|
||||
|
||||
cmd = f'git show {ref}:{file_path}'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content if output.exit_code == 0 else ''
|
||||
|
||||
async def _get_default_branch(self) -> str:
|
||||
def _get_default_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the primary Git branch name of the repository.
|
||||
|
||||
@@ -132,10 +128,10 @@ class GitHandler:
|
||||
str: The name of the primary branch.
|
||||
"""
|
||||
cmd = 'git remote show origin | grep "HEAD branch"'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.split()[-1].strip()
|
||||
|
||||
async def _get_current_branch(self) -> str:
|
||||
def _get_current_branch(self) -> str:
|
||||
"""
|
||||
Retrieves the currently selected Git branch.
|
||||
|
||||
@@ -143,25 +139,25 @@ class GitHandler:
|
||||
str: The name of the current branch.
|
||||
"""
|
||||
cmd = 'git rev-parse --abbrev-ref HEAD'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
return output.content.strip()
|
||||
|
||||
async def _get_changed_files(self) -> list[str]:
|
||||
def _get_changed_files(self) -> list[str]:
|
||||
"""
|
||||
Retrieves a list of changed files compared to a valid Git reference.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of changed file paths.
|
||||
"""
|
||||
ref = await self._get_valid_ref()
|
||||
ref = self._get_valid_ref()
|
||||
if not ref:
|
||||
return []
|
||||
|
||||
diff_cmd = f'git diff --name-status {ref}'
|
||||
output = await self._execute_async(diff_cmd, self.cwd)
|
||||
output = self.execute(diff_cmd, self.cwd)
|
||||
return output.content.splitlines()
|
||||
|
||||
async def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
def _get_untracked_files(self) -> list[dict[str, str]]:
|
||||
"""
|
||||
Retrieves a list of untracked files in the repository. This is useful for detecting new files.
|
||||
|
||||
@@ -169,7 +165,7 @@ class GitHandler:
|
||||
list[dict[str, str]]: A list of dictionaries containing file paths and statuses.
|
||||
"""
|
||||
cmd = 'git ls-files --others --exclude-standard'
|
||||
output = await self._execute_async(cmd, self.cwd)
|
||||
output = self.execute(cmd, self.cwd)
|
||||
obs_list = output.content.splitlines()
|
||||
return (
|
||||
[{'status': 'A', 'path': path} for path in obs_list]
|
||||
@@ -177,24 +173,24 @@ class GitHandler:
|
||||
else []
|
||||
)
|
||||
|
||||
async def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""
|
||||
Retrieves the list of changed files in the Git repository.
|
||||
|
||||
Returns:
|
||||
list[dict[str, str]] | None: A list of dictionaries containing file paths and statuses. None if not a git repository.
|
||||
"""
|
||||
if not await self._is_git_repo():
|
||||
if not self._is_git_repo():
|
||||
return None
|
||||
|
||||
changes_list = await self._get_changed_files()
|
||||
changes_list = self._get_changed_files()
|
||||
result = parse_git_changes(changes_list)
|
||||
|
||||
# join with any untracked files
|
||||
result += await self._get_untracked_files()
|
||||
result += self._get_untracked_files()
|
||||
return result
|
||||
|
||||
async def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
def get_git_diff(self, file_path: str) -> dict[str, str]:
|
||||
"""
|
||||
Retrieves the original and modified content of a file in the repository.
|
||||
|
||||
@@ -204,8 +200,8 @@ class GitHandler:
|
||||
Returns:
|
||||
dict[str, str]: A dictionary containing the original and modified content.
|
||||
"""
|
||||
modified = await self._get_current_file_content(file_path)
|
||||
original = await self._get_ref_content(file_path)
|
||||
modified = self._get_current_file_content(file_path)
|
||||
original = self._get_ref_content(file_path)
|
||||
|
||||
return {
|
||||
'modified': modified,
|
||||
|
||||
@@ -4,6 +4,7 @@ from typing import Any
|
||||
import httpx
|
||||
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_exponential
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.http_session import HttpSession
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
|
||||
@@ -41,6 +42,7 @@ def send_request(
|
||||
timeout: int = 10,
|
||||
**kwargs: Any,
|
||||
) -> httpx.Response:
|
||||
logger.info(f'sending {method} request to {url} with args {kwargs}')
|
||||
response = session.request(method, url, timeout=timeout, **kwargs)
|
||||
try:
|
||||
response.raise_for_status()
|
||||
|
||||
@@ -20,7 +20,9 @@ class ServerConfig(ServerConfigInterface):
|
||||
)
|
||||
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
|
||||
monitoring_listener_class: str = 'openhands.server.monitoring.MonitoringListener'
|
||||
user_auth_class: str = 'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
user_auth_class: str = (
|
||||
'openhands.server.user_auth.default_user_auth.DefaultUserAuth'
|
||||
)
|
||||
|
||||
def verify_config(self):
|
||||
if self.config_cls:
|
||||
|
||||
@@ -22,10 +22,20 @@ from openhands.events.observation import (
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
@@ -185,10 +195,20 @@ async def git_changes(
|
||||
user_id: str = Depends(get_user_id),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git changes in {runtime.git_dir}')
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config,
|
||||
user_id,
|
||||
)
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
logger.info(f'Getting git changes in {cwd}')
|
||||
|
||||
try:
|
||||
changes = await call_sync_from_async(runtime.get_git_changes)
|
||||
changes = await call_sync_from_async(runtime.get_git_changes, cwd)
|
||||
if changes is None:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
@@ -214,12 +234,18 @@ async def git_diff(
|
||||
request: Request,
|
||||
path: str,
|
||||
conversation_id: str,
|
||||
conversation_store=Depends(get_conversation_store),
|
||||
):
|
||||
runtime: Runtime = request.state.conversation.runtime
|
||||
logger.info(f'Getting git diff for {path} in {runtime.git_dir}')
|
||||
|
||||
cwd = await get_cwd(
|
||||
conversation_store,
|
||||
conversation_id,
|
||||
runtime.config.workspace_mount_path_in_sandbox,
|
||||
)
|
||||
|
||||
try:
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path)
|
||||
diff = await call_sync_from_async(runtime.get_git_diff, path, cwd)
|
||||
return diff
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(f'Error getting diff: {e}')
|
||||
@@ -227,3 +253,46 @@ async def git_diff(
|
||||
status_code=500,
|
||||
content={'error': f'Error getting diff: {e}'},
|
||||
)
|
||||
|
||||
|
||||
async def get_cwd(
|
||||
conversation_store: ConversationStore,
|
||||
conversation_id: str,
|
||||
workspace_mount_path_in_sandbox: str,
|
||||
):
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
|
||||
conversation_info = await _get_conversation_info(metadata, is_running)
|
||||
|
||||
cwd = workspace_mount_path_in_sandbox
|
||||
if conversation_info and conversation_info.selected_repository:
|
||||
repo_dir = conversation_info.selected_repository.split('/')[-1]
|
||||
cwd = os.path.join(cwd, repo_dir)
|
||||
|
||||
return cwd
|
||||
|
||||
|
||||
async def _get_conversation_info(
|
||||
conversation: ConversationMetadata,
|
||||
is_running: bool,
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
title = conversation.title
|
||||
if not title:
|
||||
title = f'Conversation {conversation.conversation_id[:5]}'
|
||||
return ConversationInfo(
|
||||
conversation_id=conversation.conversation_id,
|
||||
title=title,
|
||||
last_updated_at=conversation.last_updated_at,
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=ConversationStatus.RUNNING
|
||||
if is_running
|
||||
else ConversationStatus.STOPPED,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f'Error loading conversation {conversation.conversation_id}: {str(e)}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -2,11 +2,9 @@ from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
@@ -166,4 +164,4 @@ async def get_suggested_tasks(
|
||||
return JSONResponse(
|
||||
content='No providers set.',
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.events.stream import EventStream
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
)
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.integrations.service_types import Repository, SuggestedTask
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
@@ -42,16 +42,19 @@ from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import wait_all
|
||||
from openhands.utils.conversation_summary import generate_conversation_title
|
||||
|
||||
|
||||
app = APIRouter(prefix='/api')
|
||||
|
||||
|
||||
class InitSessionRequest(BaseModel):
|
||||
conversation_trigger: ConversationTrigger = ConversationTrigger.GUI
|
||||
selected_repository: Repository | None = None
|
||||
selected_branch: str | None = None
|
||||
initial_user_msg: str | None = None
|
||||
image_urls: list[str] | None = None
|
||||
replay_json: str | None = None
|
||||
|
||||
suggested_task: SuggestedTask | None = None
|
||||
|
||||
|
||||
async def _create_new_conversation(
|
||||
user_id: str | None,
|
||||
@@ -64,9 +67,10 @@ async def _create_new_conversation(
|
||||
conversation_trigger: ConversationTrigger = ConversationTrigger.GUI,
|
||||
attach_convo_id: bool = False,
|
||||
):
|
||||
print("trigger", conversation_trigger)
|
||||
logger.info(
|
||||
'Creating conversation',
|
||||
extra={'signal': 'create_conversation', 'user_id': user_id},
|
||||
extra={'signal': 'create_conversation', 'user_id': user_id, 'trigger': conversation_trigger.value},
|
||||
)
|
||||
logger.info('Loading settings')
|
||||
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
|
||||
@@ -169,17 +173,24 @@ async def new_conversation(
|
||||
initial_user_msg = data.initial_user_msg
|
||||
image_urls = data.image_urls or []
|
||||
replay_json = data.replay_json
|
||||
suggested_task = data.suggested_task
|
||||
conversation_trigger = data.conversation_trigger
|
||||
|
||||
if suggested_task:
|
||||
initial_user_msg = suggested_task.get_prompt_for_task()
|
||||
conversation_trigger = ConversationTrigger.SUGGESTED_TASK
|
||||
|
||||
try:
|
||||
# Create conversation with initial message
|
||||
conversation_id = await _create_new_conversation(
|
||||
user_id,
|
||||
provider_tokens,
|
||||
selected_repository,
|
||||
selected_branch,
|
||||
initial_user_msg,
|
||||
image_urls,
|
||||
replay_json,
|
||||
user_id=user_id,
|
||||
git_provider_tokens=provider_tokens,
|
||||
selected_repository=selected_repository,
|
||||
selected_branch=selected_branch,
|
||||
initial_user_msg=initial_user_msg,
|
||||
image_urls=image_urls,
|
||||
replay_json=replay_json,
|
||||
conversation_trigger=conversation_trigger
|
||||
)
|
||||
|
||||
return JSONResponse(
|
||||
@@ -344,9 +355,7 @@ async def update_conversation(
|
||||
title: str = Body(embed=True),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
if not metadata:
|
||||
return False
|
||||
@@ -369,9 +378,7 @@ async def delete_conversation(
|
||||
conversation_id: str,
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
) -> bool:
|
||||
conversation_store = await ConversationStoreImpl.get_instance(
|
||||
config, user_id
|
||||
)
|
||||
conversation_store = await ConversationStoreImpl.get_instance(config, user_id)
|
||||
try:
|
||||
await conversation_store.get_metadata(conversation_id)
|
||||
except FileNotFoundError:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
|
||||
@@ -17,8 +17,7 @@ from openhands.server.settings import (
|
||||
POSTSettingsModel,
|
||||
Settings,
|
||||
)
|
||||
from openhands.server.shared import SettingsStoreImpl, config, server_config
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import (
|
||||
get_provider_tokens,
|
||||
get_user_id,
|
||||
@@ -59,7 +58,8 @@ async def load_settings(
|
||||
|
||||
settings_with_token_data = GETSettingsModel(
|
||||
**settings.model_dump(exclude='secrets_store'),
|
||||
llm_api_key_set=settings.llm_api_key is not None and bool(settings.llm_api_key),
|
||||
llm_api_key_set=settings.llm_api_key is not None
|
||||
and bool(settings.llm_api_key),
|
||||
provider_tokens_set=provider_tokens_set,
|
||||
)
|
||||
settings_with_token_data.llm_api_key = None
|
||||
@@ -211,9 +211,7 @@ async def reset_settings() -> JSONResponse:
|
||||
"""
|
||||
Resets user settings. (Deprecated)
|
||||
"""
|
||||
logger.warning(
|
||||
f"Deprecated endpoint /api/reset-settings called by user"
|
||||
)
|
||||
logger.warning('Deprecated endpoint /api/reset-settings called by user')
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_410_GONE,
|
||||
content={'error': 'Reset settings functionality has been removed.'},
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
@@ -6,6 +6,7 @@ from enum import Enum
|
||||
class ConversationTrigger(Enum):
|
||||
RESOLVER = 'resolver'
|
||||
GUI = 'gui'
|
||||
SUGGESTED_TASK = 'suggested_task'
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -87,6 +87,33 @@ def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_bash_background_server(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
server_port = 8081
|
||||
try:
|
||||
# Start the server, expect it to timeout (run in background manner)
|
||||
action = CmdRunAction(f'python3 -m http.server {server_port} &')
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert isinstance(obs, CmdOutputObservation)
|
||||
assert obs.exit_code == 0 # Should not timeout since this runs in background
|
||||
|
||||
# Give the server a moment to be ready
|
||||
time.sleep(1)
|
||||
|
||||
# Verify the server is running by curling it
|
||||
curl_action = CmdRunAction(f'curl http://localhost:{server_port}')
|
||||
curl_obs = runtime.run_action(curl_action)
|
||||
logger.info(curl_obs, extra={'msg_type': 'OBSERVATION'})
|
||||
assert isinstance(curl_obs, CmdOutputObservation)
|
||||
assert curl_obs.exit_code == 0
|
||||
# Check for content typical of python http.server directory listing
|
||||
assert 'Directory listing for' in curl_obs.content
|
||||
|
||||
finally:
|
||||
_close_test_runtime(runtime)
|
||||
|
||||
|
||||
def test_multiline_commands(temp_dir, runtime_cls):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls)
|
||||
try:
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for the setup script."""
|
||||
|
||||
from conftest import (
|
||||
_load_runtime,
|
||||
)
|
||||
|
||||
from openhands.core.setup import initialize_repository_for_runtime
|
||||
from openhands.events.action import FileReadAction, FileWriteAction
|
||||
from openhands.events.observation import FileReadObservation, FileWriteObservation
|
||||
|
||||
|
||||
def test_initialize_repository_for_runtime(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test that the initialize_repository_for_runtime function works."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
repository_dir = initialize_repository_for_runtime(
|
||||
runtime, 'https://github.com/All-Hands-AI/OpenHands'
|
||||
)
|
||||
assert repository_dir is not None
|
||||
assert repository_dir == 'OpenHands'
|
||||
|
||||
|
||||
def test_maybe_run_setup_script(temp_dir, runtime_cls, run_as_openhands):
|
||||
"""Test that setup script is executed when it exists."""
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
|
||||
setup_script = '.openhands/setup.sh'
|
||||
write_obs = runtime.write(
|
||||
FileWriteAction(
|
||||
path=setup_script, content="#!/bin/bash\necho 'Hello World' >> README.md\n"
|
||||
)
|
||||
)
|
||||
assert isinstance(write_obs, FileWriteObservation)
|
||||
|
||||
# Run setup script
|
||||
runtime.maybe_run_setup_script()
|
||||
|
||||
# Verify script was executed by checking output
|
||||
read_obs = runtime.read(FileReadAction(path='README.md'))
|
||||
assert isinstance(read_obs, FileReadObservation)
|
||||
assert read_obs.content == 'Hello World\n'
|
||||
|
||||
|
||||
def test_maybe_run_setup_script_with_long_timeout(
|
||||
temp_dir, runtime_cls, run_as_openhands
|
||||
):
|
||||
"""Test that setup script is executed when it exists."""
|
||||
runtime, config = _load_runtime(
|
||||
temp_dir,
|
||||
runtime_cls,
|
||||
run_as_openhands,
|
||||
runtime_startup_env_vars={'NO_CHANGE_TIMEOUT_SECONDS': '1'},
|
||||
)
|
||||
|
||||
setup_script = '.openhands/setup.sh'
|
||||
write_obs = runtime.write(
|
||||
FileWriteAction(
|
||||
path=setup_script,
|
||||
content="#!/bin/bash\nsleep 3 && echo 'Hello World' >> README.md\n",
|
||||
)
|
||||
)
|
||||
assert isinstance(write_obs, FileWriteObservation)
|
||||
|
||||
# Run setup script
|
||||
runtime.maybe_run_setup_script()
|
||||
|
||||
# Verify script was executed by checking output
|
||||
read_obs = runtime.read(FileReadAction(path='README.md'))
|
||||
assert isinstance(read_obs, FileReadObservation)
|
||||
assert read_obs.content == 'Hello World\n'
|
||||
@@ -4,18 +4,31 @@ from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
)
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
InitSessionRequest,
|
||||
delete_conversation,
|
||||
get_conversation,
|
||||
new_conversation,
|
||||
search_conversations,
|
||||
update_conversation,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.storage.locations import get_conversation_metadata_filename
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
@@ -218,6 +231,213 @@ async def test_update_conversation():
|
||||
assert saved_metadata.title == 'New Title'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_success():
|
||||
"""Test successful creation of a new conversation."""
|
||||
with _patch_store():
|
||||
# Mock the _create_new_conversation function directly
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations._create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to return a conversation ID
|
||||
mock_create_conversation.return_value = 'test_conversation_id'
|
||||
|
||||
# Create test data
|
||||
test_repo = Repository(
|
||||
id=12345,
|
||||
full_name='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
selected_repository=test_repo,
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
image_urls=['https://example.com/image.jpg'],
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await new_conversation(
|
||||
data=test_request, user_id='test_user', provider_tokens={}
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.body.decode('utf-8')
|
||||
== '{"status":"ok","conversation_id":"test_conversation_id"}'
|
||||
)
|
||||
|
||||
# Verify that _create_new_conversation was called with the correct arguments
|
||||
mock_create_conversation.assert_called_once()
|
||||
call_args = mock_create_conversation.call_args[1]
|
||||
assert call_args['user_id'] == 'test_user'
|
||||
assert call_args['selected_repository'] == test_repo
|
||||
assert call_args['selected_branch'] == 'main'
|
||||
assert call_args['initial_user_msg'] == 'Hello, agent!'
|
||||
assert call_args['image_urls'] == ['https://example.com/image.jpg']
|
||||
assert call_args['conversation_trigger'] == ConversationTrigger.GUI
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_with_suggested_task():
|
||||
"""Test creating a new conversation with a suggested task."""
|
||||
with _patch_store():
|
||||
# Mock the _create_new_conversation function directly
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations._create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to return a conversation ID
|
||||
mock_create_conversation.return_value = 'test_conversation_id'
|
||||
|
||||
# Mock SuggestedTask.get_prompt_for_task
|
||||
with patch(
|
||||
'openhands.integrations.service_types.SuggestedTask.get_prompt_for_task'
|
||||
) as mock_get_prompt:
|
||||
mock_get_prompt.return_value = (
|
||||
'Please fix the failing checks in PR #123'
|
||||
)
|
||||
|
||||
# Create test data
|
||||
test_repo = Repository(
|
||||
id=12345,
|
||||
full_name='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
test_task = SuggestedTask(
|
||||
git_provider=ProviderType.GITHUB,
|
||||
task_type=TaskType.FAILING_CHECKS,
|
||||
repo='test/repo',
|
||||
issue_number=123,
|
||||
title='Fix failing checks',
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
conversation_trigger=ConversationTrigger.SUGGESTED_TASK,
|
||||
selected_repository=test_repo,
|
||||
selected_branch='main',
|
||||
suggested_task=test_task,
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await new_conversation(
|
||||
data=test_request, user_id='test_user', provider_tokens={}
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 200
|
||||
assert (
|
||||
response.body.decode('utf-8')
|
||||
== '{"status":"ok","conversation_id":"test_conversation_id"}'
|
||||
)
|
||||
|
||||
# Verify that _create_new_conversation was called with the correct arguments
|
||||
mock_create_conversation.assert_called_once()
|
||||
call_args = mock_create_conversation.call_args[1]
|
||||
assert call_args['user_id'] == 'test_user'
|
||||
assert call_args['selected_repository'] == test_repo
|
||||
assert call_args['selected_branch'] == 'main'
|
||||
assert (
|
||||
call_args['initial_user_msg']
|
||||
== 'Please fix the failing checks in PR #123'
|
||||
)
|
||||
assert (
|
||||
call_args['conversation_trigger']
|
||||
== ConversationTrigger.SUGGESTED_TASK
|
||||
)
|
||||
|
||||
# Verify that get_prompt_for_task was called
|
||||
mock_get_prompt.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_missing_settings():
|
||||
"""Test creating a new conversation when settings are missing."""
|
||||
with _patch_store():
|
||||
# Mock the _create_new_conversation function to raise MissingSettingsError
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations._create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to raise MissingSettingsError
|
||||
mock_create_conversation.side_effect = MissingSettingsError(
|
||||
'Settings not found'
|
||||
)
|
||||
|
||||
# Create test data
|
||||
test_repo = Repository(
|
||||
id=12345,
|
||||
full_name='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
selected_repository=test_repo,
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await new_conversation(
|
||||
data=test_request, user_id='test_user', provider_tokens={}
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 400
|
||||
assert 'Settings not found' in response.body.decode('utf-8')
|
||||
assert 'CONFIGURATION$SETTINGS_NOT_FOUND' in response.body.decode('utf-8')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_conversation_invalid_api_key():
|
||||
"""Test creating a new conversation with an invalid API key."""
|
||||
with _patch_store():
|
||||
# Mock the _create_new_conversation function to raise LLMAuthenticationError
|
||||
with patch(
|
||||
'openhands.server.routes.manage_conversations._create_new_conversation'
|
||||
) as mock_create_conversation:
|
||||
# Set up the mock to raise LLMAuthenticationError
|
||||
mock_create_conversation.side_effect = LLMAuthenticationError(
|
||||
'Error authenticating with the LLM provider. Please check your API key'
|
||||
)
|
||||
|
||||
# Create test data
|
||||
test_repo = Repository(
|
||||
id=12345,
|
||||
full_name='test/repo',
|
||||
git_provider=ProviderType.GITHUB,
|
||||
is_public=True,
|
||||
)
|
||||
|
||||
test_request = InitSessionRequest(
|
||||
conversation_trigger=ConversationTrigger.GUI,
|
||||
selected_repository=test_repo,
|
||||
selected_branch='main',
|
||||
initial_user_msg='Hello, agent!',
|
||||
)
|
||||
|
||||
# Call new_conversation
|
||||
response = await new_conversation(
|
||||
data=test_request, user_id='test_user', provider_tokens={}
|
||||
)
|
||||
|
||||
# Verify the response
|
||||
assert isinstance(response, JSONResponse)
|
||||
assert response.status_code == 400
|
||||
assert 'Error authenticating with the LLM provider' in response.body.decode(
|
||||
'utf-8'
|
||||
)
|
||||
assert 'STATUS$ERROR_LLM_AUTHENTICATION' in response.body.decode('utf-8')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_conversation():
|
||||
with _patch_store():
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.runtime.utils.git_handler import CommandResult, GitHandler
|
||||
|
||||
# Mark all test methods as asyncio tests
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestGitHandler(unittest.TestCase):
|
||||
def setUp(self):
|
||||
@@ -110,9 +104,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Push the feature branch to origin
|
||||
self._execute_command('git push -u origin feature-branch', self.local_dir)
|
||||
|
||||
async def test_is_git_repo(self):
|
||||
def test_is_git_repo(self):
|
||||
"""Test that _is_git_repo returns True for a git repository."""
|
||||
self.assertTrue(await self.git_handler._is_git_repo())
|
||||
self.assertTrue(self.git_handler._is_git_repo())
|
||||
|
||||
# Verify the command was executed
|
||||
self.assertTrue(
|
||||
@@ -122,9 +116,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_default_branch(self):
|
||||
def test_get_default_branch(self):
|
||||
"""Test that _get_default_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_default_branch()
|
||||
branch = self.git_handler._get_default_branch()
|
||||
self.assertEqual(branch, 'main')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -135,9 +129,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_current_branch(self):
|
||||
def test_get_current_branch(self):
|
||||
"""Test that _get_current_branch returns the correct branch name."""
|
||||
branch = await self.git_handler._get_current_branch()
|
||||
branch = self.git_handler._get_current_branch()
|
||||
self.assertEqual(branch, 'feature-branch')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -148,10 +142,10 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_valid_ref_with_origin_current_branch(self):
|
||||
def test_get_valid_ref_with_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref returns the current branch in origin when it exists."""
|
||||
# This test uses the setup from setUp where the current branch exists in origin
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
@@ -171,7 +165,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin_current_branch(self):
|
||||
def test_get_valid_ref_without_origin_current_branch(self):
|
||||
"""Test that _get_valid_ref falls back to default branch when current branch doesn't exist in origin."""
|
||||
# Create a new branch that doesn't exist in origin
|
||||
self._execute_command('git checkout -b new-local-branch', self.local_dir)
|
||||
@@ -179,7 +173,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await self.git_handler._get_valid_ref()
|
||||
ref = self.git_handler._get_valid_ref()
|
||||
self.assertIsNotNone(ref)
|
||||
|
||||
# Check that the refs were checked in the correct order
|
||||
@@ -202,7 +196,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
result = self._execute_command(f'git rev-parse --verify {ref}', self.local_dir)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_valid_ref_without_origin(self):
|
||||
def test_get_valid_ref_without_origin(self):
|
||||
"""Test that _get_valid_ref falls back to empty tree ref when there's no origin."""
|
||||
# Create a new directory with a git repo but no origin
|
||||
no_origin_dir = os.path.join(self.test_dir, 'no-origin')
|
||||
@@ -213,20 +207,18 @@ class TestGitHandler(unittest.TestCase):
|
||||
self._execute_command("git config user.email 'test@example.com'", no_origin_dir)
|
||||
self._execute_command("git config user.name 'Test User'", no_origin_dir)
|
||||
|
||||
# Create a file and commit it using subprocess
|
||||
file_path = os.path.join(no_origin_dir, 'file1.txt')
|
||||
self._execute_command(
|
||||
f'echo "Content in repo without origin" > {file_path}', no_origin_dir
|
||||
)
|
||||
# Create a file and commit it
|
||||
with open(os.path.join(no_origin_dir, 'file1.txt'), 'w') as f:
|
||||
f.write('Content in repo without origin')
|
||||
self._execute_command('git add file1.txt', no_origin_dir)
|
||||
self._execute_command("git commit -m 'Initial commit'", no_origin_dir)
|
||||
|
||||
# Create a custom GitHandler with a modified _get_default_branch method for this test
|
||||
class TestGitHandler(GitHandler):
|
||||
async def _get_default_branch(self) -> str:
|
||||
def _get_default_branch(self) -> str:
|
||||
# Override to handle repos without origin
|
||||
try:
|
||||
return await super()._get_default_branch()
|
||||
return super()._get_default_branch()
|
||||
except IndexError:
|
||||
return 'main' # Default fallback
|
||||
|
||||
@@ -237,7 +229,7 @@ class TestGitHandler(unittest.TestCase):
|
||||
# Clear the executed commands to start fresh
|
||||
self.executed_commands = []
|
||||
|
||||
ref = await no_origin_handler._get_valid_ref()
|
||||
ref = no_origin_handler._get_valid_ref()
|
||||
|
||||
# Verify that git commands were executed
|
||||
self.assertTrue(
|
||||
@@ -259,9 +251,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(result.exit_code, 0)
|
||||
|
||||
async def test_get_ref_content(self):
|
||||
def test_get_ref_content(self):
|
||||
"""Test that _get_ref_content returns the content from a valid ref."""
|
||||
content = await self.git_handler._get_ref_content('file1.txt')
|
||||
content = self.git_handler._get_ref_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content')
|
||||
|
||||
# Should have called _get_valid_ref and then git show
|
||||
@@ -270,9 +262,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(any('file1.txt' in cmd for cmd in show_commands))
|
||||
|
||||
async def test_get_current_file_content(self):
|
||||
def test_get_current_file_content(self):
|
||||
"""Test that _get_current_file_content returns the current content of a file."""
|
||||
content = await self.git_handler._get_current_file_content('file1.txt')
|
||||
content = self.git_handler._get_current_file_content('file1.txt')
|
||||
self.assertEqual(content.strip(), 'Modified content again')
|
||||
|
||||
# Verify the command was executed
|
||||
@@ -280,15 +272,14 @@ class TestGitHandler(unittest.TestCase):
|
||||
any(cmd == 'cat file1.txt' for cmd, _ in self.executed_commands)
|
||||
)
|
||||
|
||||
async def test_get_changed_files(self):
|
||||
def test_get_changed_files(self):
|
||||
"""Test that _get_changed_files returns the list of changed files."""
|
||||
# Let's create a new file to ensure it shows up in the diff
|
||||
# Use subprocess directly to create and add the file
|
||||
file_path = os.path.join(self.local_dir, 'new_file.txt')
|
||||
self._execute_command(f'echo "New file content" > {file_path}', self.local_dir)
|
||||
with open(os.path.join(self.local_dir, 'new_file.txt'), 'w') as f:
|
||||
f.write('New file content')
|
||||
self._execute_command('git add new_file.txt', self.local_dir)
|
||||
|
||||
files = await self.git_handler._get_changed_files()
|
||||
files = self.git_handler._get_changed_files()
|
||||
self.assertTrue(files)
|
||||
|
||||
# Should include file1.txt (modified) and file3.txt (deleted)
|
||||
@@ -304,15 +295,13 @@ class TestGitHandler(unittest.TestCase):
|
||||
]
|
||||
self.assertTrue(diff_commands)
|
||||
|
||||
async def test_get_untracked_files(self):
|
||||
def test_get_untracked_files(self):
|
||||
"""Test that _get_untracked_files returns the list of untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
files = await self.git_handler._get_untracked_files()
|
||||
files = self.git_handler._get_untracked_files()
|
||||
self.assertEqual(len(files), 1)
|
||||
self.assertEqual(files[0]['path'], 'untracked.txt')
|
||||
self.assertEqual(files[0]['status'], 'A')
|
||||
@@ -325,22 +314,18 @@ class TestGitHandler(unittest.TestCase):
|
||||
)
|
||||
)
|
||||
|
||||
async def test_get_git_changes(self):
|
||||
def test_get_git_changes(self):
|
||||
"""Test that get_git_changes returns the combined list of changed and untracked files."""
|
||||
# Create an untracked file using subprocess
|
||||
file_path = os.path.join(self.local_dir, 'untracked.txt')
|
||||
self._execute_command(
|
||||
f'echo "Untracked file content" > {file_path}', self.local_dir
|
||||
)
|
||||
# Create an untracked file
|
||||
with open(os.path.join(self.local_dir, 'untracked.txt'), 'w') as f:
|
||||
f.write('Untracked file content')
|
||||
|
||||
# Create a new file and stage it
|
||||
file_path2 = os.path.join(self.local_dir, 'new_file2.txt')
|
||||
self._execute_command(
|
||||
f'echo "New file 2 content" > {file_path2}', self.local_dir
|
||||
)
|
||||
with open(os.path.join(self.local_dir, 'new_file2.txt'), 'w') as f:
|
||||
f.write('New file 2 content')
|
||||
self._execute_command('git add new_file2.txt', self.local_dir)
|
||||
|
||||
changes = await self.git_handler.get_git_changes()
|
||||
changes = self.git_handler.get_git_changes()
|
||||
self.assertIsNotNone(changes)
|
||||
|
||||
# Should include file1.txt (modified), file3.txt (deleted), new_file2.txt (added), and untracked.txt (untracked)
|
||||
@@ -356,9 +341,9 @@ class TestGitHandler(unittest.TestCase):
|
||||
self.assertIn('A', statuses) # Added
|
||||
self.assertIn('D', statuses) # Deleted
|
||||
|
||||
async def test_get_git_diff(self):
|
||||
def test_get_git_diff(self):
|
||||
"""Test that get_git_diff returns the original and modified content of a file."""
|
||||
diff = await self.git_handler.get_git_diff('file1.txt')
|
||||
diff = self.git_handler.get_git_diff('file1.txt')
|
||||
self.assertEqual(diff['modified'].strip(), 'Modified content again')
|
||||
self.assertEqual(diff['original'].strip(), 'Modified content')
|
||||
|
||||
@@ -375,6 +360,4 @@ class TestGitHandler(unittest.TestCase):
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import asyncio
|
||||
|
||||
asyncio.run(unittest.main())
|
||||
unittest.main()
|
||||
|
||||
@@ -8,6 +8,8 @@ from pydantic import SecretStr
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.app import app
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.memory import InMemoryFileStore
|
||||
from openhands.storage.settings.file_settings_store import FileSettingsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
|
||||
@@ -40,16 +42,22 @@ class MockUserAuth(UserAuth):
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
# Create a test client
|
||||
with patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
):
|
||||
with patch(
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.user_auth.user_auth.UserAuth.get_instance',
|
||||
return_value=MockUserAuth(),
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.settings.validate_provider_token',
|
||||
return_value=ProviderType.GITHUB,
|
||||
):
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
),
|
||||
patch(
|
||||
'openhands.storage.settings.file_settings_store.FileSettingsStore.get_instance',
|
||||
AsyncMock(return_value=FileSettingsStore(InMemoryFileStore())),
|
||||
),
|
||||
):
|
||||
client = TestClient(app)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user