Compare commits

...

45 Commits

Author SHA1 Message Date
openhands
b13ba0cf2a feat: Enable Usage dashboard for Personal Workspaces
Remove isPersonalOrg restriction from usage page visibility check,
allowing users in Personal Workspaces to access the Usage dashboard
as long as they have view_billing permission (owner/admin).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-20 17:17:31 +00:00
openhands
93fe1613d7 feat: Add Usage dashboard for organization admins and owners
This PR adds a Usage page to the Settings menu that displays organization
usage statistics including:
- Total number of conversations
- Number of merged PRs
- Average cost per conversation
- A chart showing conversation counts over the last 90 days

The page is only visible to users with admin or owner roles in team organizations.

Backend changes:
- Add /api/organizations/{org_id}/usage endpoint in enterprise/server/routes/usage.py
- Register usage_router in enterprise/saas_server.py

Frontend changes:
- Add usage-service API client and types
- Add useUsageStats React Query hook
- Add Usage nav item to settings navigation (admin/owner only, team orgs only)
- Create usage-settings.tsx page with stat cards and conversation chart
- Add i18n translations for Usage page (all supported languages)
- Add route configuration for /settings/usage

Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 16:32:20 +00:00
Chris Bagwell
120fd7516a Fix: Prevent auto-logout on 401 errors in oss mode (#13466) 2026-03-19 16:33:01 +01:00
chuckbutkus
2224127ac3 Fix when budgets are None (#13482)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 10:14:48 -05:00
aivong-openhands
2d1e9fa35b Fix CVE-2026-33123: Update pypdf to 6.9.1 (#13473)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-19 11:05:30 -04:00
MkDev11
0ec962e96b feat: add /clear endpoint for V1 conversations (#12786)
Co-authored-by: mkdev11 <MkDev11@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: tofarr <tofarr@gmail.com>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 21:13:58 +07:00
Engel Nyst
3a9f00aa37 Keep VSCode accessible when agent errors (#13492)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-19 14:46:56 +01:00
Hiep Le
e02dbb8974 fix(backend): validate API key org_id during authorization to prevent cross-org access (org project) (#13468) 2026-03-19 16:09:37 +07:00
Hiep Le
8039807c3f fix(frontend): scope organization data queries by organization ID (org project) (#13459) 2026-03-19 14:18:29 +07:00
Saurya Velagapudi
a96760eea7 fix: ensure LiteLLM user exists before generating API keys (#12667)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:16:43 -07:00
Saurya Velagapudi
dcb2e21b87 feat: Auto-forward LLM_* env vars to agent-server and fix host network config (#13192)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:07:19 -07:00
Tim O'Farrell
7edebcbc0c fix: use atomic write in LocalFileStore to prevent race conditions (#13480)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-18 16:49:32 -06:00
HeyItsChloe
abd1f9948f fix: return empty skills list instead of 404 for stopped sandboxes (#13429)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:46:00 -06:00
aivong-openhands
2879e58781 Fix CVE-2026-30922: Update pyasn1 to 0.6.3 (#13452)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
2026-03-18 16:00:06 -04:00
Rohit Malhotra
1d1ffc2be0 feat(enterprise): Add service API for automation API key creation (#13467)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 19:07:36 +00:00
Hiep Le
db41148396 feat(backend): expose API key org_id via new GET /api/keys/current endpoint (org project) (#13469) 2026-03-19 01:46:23 +07:00
Robert Brennan
39a4ca422f fix: use sentence case for 'Waiting for sandbox' text (#12958)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 14:42:46 -04:00
Varun Chawla
6d86803f41 Add loading feedback to git changes refresh button (#12792)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-19 01:26:27 +07:00
Jordi Mas
8e0386c416 feat: add Catalan translation (#13299) 2026-03-18 13:17:43 -04:00
Nelson Spence
48cd85e47e fix(security): add sleep to container wait loop (#12869)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-18 13:04:36 -04:00
不做了睡大觉
c62b47dcb1 fix: handle empty body in GitHub issue resolver (#13039)
Co-authored-by: User <user@example.com>
2026-03-18 12:36:52 -04:00
Jamie Chicago
eb9a822d4c Update CONTRIBUTING.md (#13463) 2026-03-18 12:10:22 -04:00
Engel Nyst
fb7333aa62 fix: stop calling agent-server /generate_title (#13093)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 17:10:07 +01:00
aivong-openhands
fb23418803 clarify docstring for provider token reference (#13386)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 12:03:56 -04:00
Xingyao Wang
991585c05d docs: add cross-repo testing skill for SDK ↔ OH Cloud e2e workflow (#13446)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 16:00:23 +00:00
Chris Bagwell
35a40ddee8 fix: handle containers with tagless images in DockerSandboxService (#13238) 2026-03-18 11:55:48 -04:00
Hiep Le
5d1f9f815a fix(frontend): preserve settings page route on browser refresh (org project) (#13462) 2026-03-18 22:50:42 +07:00
Hiep Le
d3bf989e77 feat(frontend): improve conversation access error message with workspace hint (org project) (#13461) 2026-03-18 22:50:30 +07:00
Hiep Le
6589e592e3 feat(frontend): add contextual info messages on LLM settings page (org project) (#13460) 2026-03-18 22:50:16 +07:00
Chris Bagwell
fe4c0569f7 Remove unused WORK_HOSTS_SKILL_FOOTER (#12594) 2026-03-18 21:57:23 +07:00
Xingyao Wang
28ecf06404 Render V1 paired tool summaries (#13451)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 10:52:05 +00:00
dependabot[bot]
26fa1185a4 chore(deps): bump mcp from 1.25.0 to 1.26.0 in the mcp-packages group (#13314)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: aivong-openhands <ai.vong@openhands.dev>
2026-03-17 17:44:35 -05:00
HeyItsChloe
d3a8b037f2 feat(frontend): home page cta (#13339)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 03:44:36 +07:00
HeyItsChloe
af1fa8961a feat(frontend): login page cta (#13337)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 03:14:59 +07:00
HeyItsChloe
3b215c4ad1 feat(frontend): context menu cta (#13338)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-18 02:52:02 +07:00
HeyItsChloe
7516b53f5a feat(frontend): self hosted new user questions (#13367)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-18 02:51:40 +07:00
aivong-openhands
855ef7ba5f PLTF-309: disable budget enforcement when ENABLE_BILLING=false (#13440)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 14:26:13 -05:00
Rohit Malhotra
09ca1b882f (Hotfix): use direct attrib for file download result (#13448) 2026-03-17 14:48:46 -04:00
Jamie Chicago
79cfffce60 docs: Improve Development.md and CONTRIBUTING.md with OS-specific setup guides (#13432)
Co-authored-by: enyst <engel.nyst@gmail.com>
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 17:03:33 +01:00
Saurya Velagapudi
b68c75252d Add architecture diagrams explaining system components and WebSocket flow (#12542)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Saurya <saurya@openhands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-17 08:52:40 -07:00
aivong-openhands
d58e12ad74 Fix CVE-2026-27962: Update authlib to 1.6.9 (#13439)
Co-authored-by: OpenHands CVE Fix Bot <openhands@all-hands.dev>
Co-authored-by: OpenHands Bot <contact@all-hands.dev>
2026-03-17 10:13:08 -05:00
Engel Nyst
bd837039dd chore: update skills path comments (#12794) 2026-03-17 10:45:50 -04:00
Kooltek68
8a7779068a docs: fix typo in README.md (#13444) 2026-03-17 10:16:31 -04:00
Neha Prasad
38099934b6 fix : planner PLAN.md rendering and search labels (#13418)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-17 20:59:02 +07:00
Xingyao Wang
75c823c486 feat: expose_secrets param on /users/me + sandbox-scoped secrets API (#13383)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-17 12:54:57 +00:00
193 changed files with 13542 additions and 2519 deletions

View File

@@ -0,0 +1,202 @@
---
name: cross-repo-testing
description: This skill should be used when the user asks to "test a cross-repo feature", "deploy a feature branch to staging", "test SDK against OH Cloud", "e2e test a cloud workspace feature", "test provider tokens", "test secrets inheritance", or when changes span the SDK and OpenHands server repos and need end-to-end validation against a staging deployment.
triggers:
- cross-repo
- staging deployment
- feature branch deploy
- test against cloud
- e2e cloud
---
# Cross-Repo Testing: SDK ↔ OpenHands Cloud
How to end-to-end test features that span `OpenHands/software-agent-sdk` and `OpenHands/OpenHands` (the Cloud backend).
## Repository Map
| Repo | Role | What lives here |
|------|------|-----------------|
| [`software-agent-sdk`](https://github.com/OpenHands/software-agent-sdk) | Agent core | `openhands-sdk`, `openhands-workspace`, `openhands-tools` packages. `OpenHandsCloudWorkspace` lives here. |
| [`OpenHands`](https://github.com/OpenHands/OpenHands) | Cloud backend | FastAPI server (`openhands/app_server/`), sandbox management, auth, enterprise integrations. Deployed as OH Cloud. |
| [`deploy`](https://github.com/OpenHands/deploy) | Infrastructure | Helm charts + GitHub Actions that build the enterprise Docker image and deploy to staging/production. |
**Data flow:** SDK client → OH Cloud API (`/api/v1/...`) → sandbox agent-server (inside runtime container)
## When You Need This
There are **two flows** depending on which direction the dependency goes:
| Flow | When | Example |
|------|------|---------|
| **A — SDK client → new Cloud API** | The SDK calls an API that doesn't exist yet on production | `workspace.get_llm()` calling `GET /api/v1/users/me?expose_secrets=true` |
| **B — OH server → new SDK code** | The Cloud server needs unreleased SDK packages or a new agent-server image | Server consumes a new tool, agent behavior, or workspace method from the SDK |
Flow A only requires deploying the server PR. Flow B requires pinning the SDK to an unreleased commit in the server PR **and** using the SDK PR's agent-server image. Both flows may apply simultaneously.
---
## Flow A: SDK Client Tests Against New Cloud API
Use this when the SDK calls an endpoint that only exists on the server PR branch.
### A1. Write and test the server-side changes
In the `OpenHands` repo, implement the new API endpoint(s). Run unit tests:
```bash
cd OpenHands
poetry run pytest tests/unit/app_server/test_<relevant>.py -v
```
Push a PR. Wait for the **"Push Enterprise Image" (Docker) CI job** to succeed — this builds `ghcr.io/openhands/enterprise-server:sha-<COMMIT>`.
### A2. Write the SDK-side changes
In `software-agent-sdk`, implement the client code (e.g., new methods on `OpenHandsCloudWorkspace`). Run SDK unit tests:
```bash
cd software-agent-sdk
pip install -e openhands-sdk -e openhands-workspace
pytest tests/ -v
```
Push a PR. SDK CI is independent — it doesn't need the server changes to pass unit tests.
### A3. Deploy the server PR to staging
See [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) below.
### A4. Run the SDK e2e test against staging
See [Running E2E Tests Against Staging](#running-e2e-tests-against-staging) below.
---
## Flow B: OH Server Needs Unreleased SDK Code
Use this when the Cloud server depends on SDK changes that haven't been released to PyPI yet. The server's runtime containers run the `agent-server` image built from the SDK repo, so the server PR must be configured to use the SDK PR's image and packages.
### B1. Get the SDK PR merged (or identify the commit)
The SDK PR must have CI pass so its agent-server Docker image is built. The image is tagged with the **merge-commit SHA** from GitHub Actions — NOT the head-commit SHA shown in the PR.
Find the correct image tag:
- Check the SDK PR description for an `AGENT_SERVER_IMAGES` section
- Or check the "Consolidate Build Information" CI job for `"short_sha": "<tag>"`
### B2. Pin SDK packages to the commit in the OpenHands PR
In the `OpenHands` repo PR, pin all 3 SDK packages (`openhands-sdk`, `openhands-agent-server`, `openhands-tools`) to the unreleased commit and update the agent-server image tag. This involves editing 3 files and regenerating 3 lock files.
Follow the **`update-sdk` skill** → "Development: Pin SDK to an Unreleased Commit" section for the full procedure and file-by-file instructions.
### B3. Wait for the OpenHands enterprise image to build
Push the pinned changes. The OpenHands CI will build a new enterprise Docker image (`ghcr.io/openhands/enterprise-server:sha-<OH_COMMIT>`) that bundles the unreleased SDK. Wait for the "Push Enterprise Image" job to succeed.
### B4. Deploy and test
Follow [Deploying to a Staging Feature Environment](#deploying-to-a-staging-feature-environment) using the new OpenHands commit SHA.
### B5. Before merging: remove the pin
**CI guard:** `check-package-versions.yml` blocks merge to `main` if `[tool.poetry.dependencies]` contains `rev` fields. Before the OpenHands PR can merge, the SDK PR must be merged and released to PyPI, then the pin must be replaced with the released version number.
---
## Deploying to a Staging Feature Environment
The `deploy` repo creates preview environments from OpenHands PRs.
**Option A — GitHub Actions UI (preferred):**
Go to `OpenHands/deploy` → Actions → "Create OpenHands preview PR" → enter the OpenHands PR number. This creates a branch `ohpr-<PR>-<random>` and opens a deploy PR.
**Option B — Update an existing feature branch:**
```bash
cd deploy
git checkout ohpr-<PR>-<random>
# In .github/workflows/deploy.yaml, update BOTH:
# OPENHANDS_SHA: "<full-40-char-commit>"
# OPENHANDS_RUNTIME_IMAGE_TAG: "<same-commit>-nikolaik"
git commit -am "Update OPENHANDS_SHA to <commit>" && git push
```
**Before updating the SHA**, verify the enterprise Docker image exists:
```bash
gh api repos/OpenHands/OpenHands/actions/runs \
--jq '.workflow_runs[] | select(.head_sha=="<COMMIT>") | "\(.name): \(.conclusion)"' \
| grep Docker
# Must show: "Docker: success"
```
The deploy CI auto-triggers and creates the environment at:
```
https://ohpr-<PR>-<random>.staging.all-hands.dev
```
**Wait for it to be live:**
```bash
curl -s -o /dev/null -w "%{http_code}" https://ohpr-<PR>-<random>.staging.all-hands.dev/api/v1/health
# 401 = server is up (auth required). DNS may take 1-2 min on first deploy.
```
## Running E2E Tests Against Staging
**Critical: Feature deployments have their own Keycloak instance.** API keys from `app.all-hands.dev` or `$OPENHANDS_API_KEY` will NOT work. You need a test API key issued by the specific feature deployment's Keycloak.
**You (the agent) cannot obtain this key yourself** — the feature environment requires interactive browser login with credentials you do not have. You must **ask the user** to:
1. Log in to the feature deployment at `https://ohpr-<PR>-<random>.staging.all-hands.dev` in their browser
2. Generate a test API key from the UI
3. Provide the key to you so you can proceed with e2e testing
Do **not** attempt to log in via the browser or guess credentials. Wait for the user to supply the key before running any e2e tests.
```python
from openhands.workspace import OpenHandsCloudWorkspace
STAGING = "https://ohpr-<PR>-<random>.staging.all-hands.dev"
with OpenHandsCloudWorkspace(
cloud_api_url=STAGING,
cloud_api_key="<test-api-key-for-this-deployment>",
) as workspace:
# Test the new feature
llm = workspace.get_llm()
secrets = workspace.get_secrets()
print(f"LLM: {llm.model}, secrets: {list(secrets.keys())}")
```
Or run an example script:
```bash
OPENHANDS_CLOUD_API_KEY="<key>" \
OPENHANDS_CLOUD_API_URL="https://ohpr-<PR>-<random>.staging.all-hands.dev" \
python examples/02_remote_agent_server/10_cloud_workspace_saas_credentials.py
```
### Recording results
Both repos support a `.pr/` directory for temporary PR artifacts (design docs, test logs, scripts). These files are automatically removed when the PR is approved — see `.github/workflows/pr-artifacts.yml` and the "PR-Specific Artifacts" section in each repo's `AGENTS.md`.
Push test output to the `.pr/logs/` directory of whichever repo you're working in:
```bash
mkdir -p .pr/logs
python test_script.py 2>&1 | tee .pr/logs/<test_name>.log
git add -f .pr/logs/
git commit -m "docs: add e2e test results" && git push
```
Comment on **both PRs** with pass/fail summary and link to logs.
## Key Gotchas
| Gotcha | Details |
|--------|---------|
| **Feature env auth is isolated** | Each `ohpr-*` deployment has its own Keycloak. Production API keys don't work. Agents cannot log in — you must ask the user to provide a test API key from the feature deployment's UI. |
| **Two SHAs in deploy.yaml** | `OPENHANDS_SHA` and `OPENHANDS_RUNTIME_IMAGE_TAG` must both be updated. The runtime tag is `<sha>-nikolaik`. |
| **Enterprise image must exist** | The Docker CI job on the OpenHands PR must succeed before you can deploy. If it hasn't run, push an empty commit to trigger it. |
| **DNS propagation** | First deployment of a new branch takes 1-2 min for DNS. Subsequent deploys are instant. |
| **Merge-commit SHA ≠ head SHA** | SDK CI tags Docker images with GitHub Actions' merge-commit SHA, not the PR head SHA. Check the SDK PR description or CI logs for the correct tag. |
| **SDK pin blocks merge** | `check-package-versions.yml` prevents merging an OpenHands PR that has `rev` fields in `[tool.poetry.dependencies]`. The SDK must be released to PyPI first. |
| **Flow A: stock agent-server is fine** | When only the Cloud API changes, `OpenHandsCloudWorkspace` talks to the Cloud server, not the agent-server. No custom image needed. |
| **Flow B: agent-server image is required** | When the server needs new SDK code inside runtime containers, you must pin to the SDK PR's agent-server image. |

136
.github/workflows/pr-artifacts.yml vendored Normal file
View File

@@ -0,0 +1,136 @@
---
name: PR Artifacts
on:
workflow_dispatch: # Manual trigger for testing
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
pull_request_review:
types: [submitted]
jobs:
# Auto-remove .pr/ directory when a reviewer approves
cleanup-on-approval:
concurrency:
group: cleanup-pr-artifacts-${{ github.event.pull_request.number }}
cancel-in-progress: false
if: github.event_name == 'pull_request_review' && github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Check if fork PR
id: check-fork
run: |
if [ "${{ github.event.pull_request.head.repo.full_name }}" != "${{ github.event.pull_request.base.repo.full_name }}" ]; then
echo "is_fork=true" >> $GITHUB_OUTPUT
echo "::notice::Fork PR detected - skipping auto-cleanup (manual removal required)"
else
echo "is_fork=false" >> $GITHUB_OUTPUT
fi
- uses: actions/checkout@v5
if: steps.check-fork.outputs.is_fork == 'false'
with:
ref: ${{ github.event.pull_request.head.ref }}
token: ${{ secrets.ALLHANDS_BOT_GITHUB_PAT }}
- name: Remove .pr/ directory
id: remove
if: steps.check-fork.outputs.is_fork == 'false'
run: |
if [ -d ".pr" ]; then
git config user.name "allhands-bot"
git config user.email "allhands-bot@users.noreply.github.com"
git rm -rf .pr/
git commit -m "chore: Remove PR-only artifacts [automated]"
git push || {
echo "::error::Failed to push cleanup commit. Check branch protection rules."
exit 1
}
echo "removed=true" >> $GITHUB_OUTPUT
echo "::notice::Removed .pr/ directory"
else
echo "removed=false" >> $GITHUB_OUTPUT
echo "::notice::No .pr/ directory to remove"
fi
- name: Update PR comment after cleanup
if: steps.check-fork.outputs.is_fork == 'false' && steps.remove.outputs.removed == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
✅ **PR Artifacts Cleaned Up**
The \`.pr/\` directory has been automatically removed.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body: body,
});
}
# Warn if .pr/ directory exists (will be auto-removed on approval)
check-pr-artifacts:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v5
- name: Check for .pr/ directory
id: check
run: |
if [ -d ".pr" ]; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "::warning::.pr/ directory exists and will be automatically removed when the PR is approved. For fork PRs, manual removal is required before merging."
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Post or update PR comment
if: steps.check.outputs.exists == 'true'
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- pr-artifacts-notice -->';
const body = `${marker}
📁 **PR Artifacts Notice**
This PR contains a \`.pr/\` directory with PR-specific documents. This directory will be **automatically removed** when the PR is approved.
> For fork PRs: Manual removal is required before merging.
`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const existing = comments.find(c => c.body.includes(marker));
if (!existing) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: body,
});
}

View File

@@ -36,9 +36,45 @@ then re-run the command to ensure it passes. Common issues include:
- Be especially careful with `git reset --hard` after staging files, as it will remove accidentally staged files
- When remote has new changes, use `git fetch upstream && git rebase upstream/<branch>` on the same branch
## PR-Specific Artifacts (`.pr/` directory)
When working on a PR that requires design documents, scripts meant for development-only, or other temporary artifacts that should NOT be merged to main, store them in a `.pr/` directory at the repository root.
### Usage
```
.pr/
├── design.md # Design decisions and architecture notes
├── analysis.md # Investigation or debugging notes
├── logs/ # Test output or CI logs for reviewer reference
└── notes.md # Any other PR-specific content
```
### How It Works
1. **Notification**: When `.pr/` exists, a comment is posted to the PR conversation alerting reviewers
2. **Auto-cleanup**: When the PR is approved, the `.pr/` directory is automatically removed via `.github/workflows/pr-artifacts.yml`
3. **Fork PRs**: Auto-cleanup cannot push to forks, so manual removal is required before merging
### Important Notes
- Do NOT put anything in `.pr/` that needs to be preserved after merge
- The `.pr/` check passes (green ✅) during development — it only posts a notification, not a blocking error
- For fork PRs: You must manually remove `.pr/` before the PR can be merged
### When to Use
- Complex refactoring that benefits from written design rationale
- Debugging sessions where you want to document your investigation
- E2E test results or logs that demonstrate a cross-repo feature works
- Feature implementations that need temporary planning docs
- Any analysis that helps reviewers understand the PR but isn't needed long-term
## Repository Structure
Backend:
- Located in the `openhands` directory
- The current V1 application server lives in `openhands/app_server/`. `make start-backend` still launches `openhands.server.listen:app`, which includes the V1 routes by default unless `ENABLE_V1=0`.
- For V1 web-app docs, LLM setup should point users to the Settings UI.
- Testing:
- All tests are in `tests/unit/test_*.py`
- To test new code, run `poetry run pytest tests/unit/test_xxx.py` where `xxx` is the appropriate file for the current functionality
@@ -342,3 +378,30 @@ To add a new LLM model to OpenHands, you need to update multiple files across bo
- Models appear in CLI provider selection based on the verified arrays
- The `organize_models_and_providers` function groups models by provider
- Default model selection prioritizes verified models for each provider
### Sandbox Settings API (SDK Credential Inheritance)
The sandbox settings API allows SDK-created conversations to inherit the user's SaaS credentials
(LLM config, secrets) securely via `LookupSecret`. Raw secret values only flow SaaS→sandbox,
never through the SDK client.
#### User Credentials with Exposed Secrets (in `openhands/app_server/user/user_router.py`):
- `GET /api/v1/users/me?expose_secrets=true` → Full user settings with unmasked secrets (e.g., `llm_api_key`)
- `GET /api/v1/users/me` → Full user settings (secrets masked, Bearer only)
Auth requirements for `expose_secrets=true`:
- Bearer token (proves user identity via `OPENHANDS_API_KEY`)
- `X-Session-API-Key` header (proves caller has an active sandbox owned by the authenticated user)
Called by `workspace.get_llm()` in the SDK to retrieve LLM config with the API key.
#### Sandbox-Scoped Secrets Endpoints (in `openhands/app_server/sandbox/sandbox_router.py`):
- `GET /sandboxes/{id}/settings/secrets` → list secret names (no values)
- `GET /sandboxes/{id}/settings/secrets/{name}` → raw secret value (called FROM sandbox)
#### Auth: `X-Session-API-Key` header, validated via `SandboxService.get_sandbox_by_session_api_key()`
#### Related SDK code (in `software-agent-sdk` repo):
- `openhands/sdk/llm/llm.py`: `LLM.api_key` accepts `SecretSource` (including `LookupSecret`)
- `openhands/workspace/cloud/workspace.py`: `get_llm()` and `get_secrets()` return LookupSecret-backed objects
- Tests: `tests/sdk/llm/test_llm_secret_source_api_key.py`, `tests/workspace/test_cloud_workspace_sdk_settings.py`

View File

@@ -1,83 +1,105 @@
# Contributing
Thanks for your interest in contributing to OpenHands! We welcome and appreciate contributions.
Thanks for your interest in contributing to OpenHands! We're building the future of AI-powered software development, and we'd love for you to be part of this journey.
## Understanding OpenHands's CodeBase
## Our Vision
To understand the codebase, please refer to the README in each module:
- [frontend](./frontend/README.md)
- [openhands](./openhands/README.md)
- [agenthub](./openhands/agenthub/README.md)
- [server](./openhands/server/README.md)
The OpenHands community is built around the belief that AI and AI agents are going to fundamentally change the way we build software. If this is true, we should do everything we can to make sure that the benefits provided by such powerful technology are accessible to everyone.
For benchmarks and evaluation, see the [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks) repository.
We believe in the power of open source to democratize access to cutting-edge AI technology. Just as the internet transformed how we share information, we envision a world where AI-powered development tools are available to every developer, regardless of their background or resources.
## Setting up Your Development Environment
## Getting Started
We have a separate doc [Development.md](https://github.com/OpenHands/OpenHands/blob/main/Development.md) that tells
you how to set up a development workflow.
### Quick Ways to Contribute
## How Can I Contribute?
- **Use OpenHands** and [report issues](https://github.com/OpenHands/OpenHands/issues) you encounter
- **Give feedback** using the thumbs-up/thumbs-down buttons after each session
- **Star our repository** on [GitHub](https://github.com/OpenHands/OpenHands)
- **Share OpenHands** with other developers
There are many ways that you can contribute:
### Set Up Your Development Environment
1. **Download and use** OpenHands, and send [issues](https://github.com/OpenHands/OpenHands/issues) when you encounter something that isn't working or a feature that you'd like to see.
2. **Send feedback** after each session by [clicking the thumbs-up thumbs-down buttons](https://docs.openhands.dev/usage/feedback), so we can see where things are working and failing, and also build an open dataset for training code agents.
3. **Improve the Codebase** by sending [PRs](#sending-pull-requests-to-openhands) (see details below). In particular, we have some [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue) that may be ones to start on.
- **Requirements**: Linux/Mac/WSL, Docker, Python 3.12, Node.js 22+, Poetry 1.8+
- **Quick setup**: `make build`
- **Run locally**: `make run`
- **LLM setup (V1 web app)**: configure your model and API key in the Settings UI after the app starts
## What Can I Build?
Full details in our [Development Guide](./Development.md).
Here are a few ways you can help improve the codebase.
### Find Your First Issue
#### UI/UX
- Browse [good first issues](https://github.com/OpenHands/OpenHands/labels/good%20first%20issue)
- Check our [project boards](https://github.com/OpenHands/OpenHands/projects) for organized tasks
- Join our [Slack community](https://openhands.dev/joinslack) to ask what needs help
We're always looking to improve the look and feel of the application. If you've got a small fix
for something that's bugging you, feel free to open up a PR that changes the [`./frontend`](./frontend) directory.
## Understanding the Codebase
If you're looking to make a bigger change, add a new UI element, or significantly alter the style
of the application, please open an issue first, or better, join the #dev-ui-ux channel in our Slack
to gather consensus from our design team first.
- **[Frontend](./frontend/README.md)** - React application
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
- **[Agents](./openhands/agenthub/README.md)** - AI agent implementations
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
#### Improving the agent
## What Can You Build?
Our main agent is the CodeAct agent. You can [see its prompts here](https://github.com/OpenHands/OpenHands/tree/main/openhands/agenthub/codeact_agent).
### Frontend & UI/UX
- React & TypeScript development
- UI/UX improvements
- Mobile responsiveness
- Component libraries
Changes to these prompts, and to the underlying behavior in Python, can have a huge impact on user experience.
You can try modifying the prompts to see how they change the behavior of the agent as you use the app
locally, but we will need to do an end-to-end evaluation of any changes here to ensure that the agent
is getting better over time.
For bigger changes, join the #proj-gui channel in [Slack](https://openhands.dev/joinslack) first.
We use the [SWE-bench](https://www.swebench.com/) benchmark to test our agent. You can join the #evaluation
channel in Slack to learn more.
### Agent Development
- Prompt engineering
- New agent types
- Agent evaluation
- Multi-agent systems
#### Adding a new agent
We use [SWE-bench](https://www.swebench.com/) to evaluate agents.
You may want to experiment with building new types of agents. You can add an agent to [`openhands/agenthub`](./openhands/agenthub)
to help expand the capabilities of OpenHands.
### Backend & Infrastructure
- Python development
- Runtime systems (Docker containers, sandboxes)
- Cloud integrations
- Performance optimization
#### Adding a new runtime
### Testing & Quality Assurance
- Unit testing
- Integration testing
- Bug hunting
- Performance testing
The agent needs a place to run code and commands. When you run OpenHands on your laptop, it uses a Docker container
to do this by default. But there are other ways of creating a sandbox for the agent.
### Documentation & Education
- Technical documentation
- Translation
- Community support
If you work for a company that provides a cloud-based runtime, you could help us add support for that runtime
by implementing the [interface specified here](https://github.com/OpenHands/OpenHands/blob/main/openhands/runtime/base.py).
## Pull Request Process
#### Testing
### Small Improvements
- Quick review and approval
- Ensure CI tests pass
- Include clear description of changes
When you write code, it is also good to write tests. Please navigate to the [`./tests`](./tests) folder to see existing
test suites. At the moment, we have these kinds of tests: [`unit`](./tests/unit), [`runtime`](./tests/runtime), and [`end-to-end (e2e)`](./tests/e2e).
Please refer to the README for each test suite. These tests also run on GitHub's continuous integration to ensure
quality of the project.
### Core Agent Changes
These are evaluated based on:
- **Accuracy** - Does it make the agent better at solving problems?
- **Efficiency** - Does it improve speed or reduce resource usage?
- **Code Quality** - Is the code maintainable and well-tested?
Discuss major changes in [GitHub issues](https://github.com/OpenHands/OpenHands/issues) or [Slack](https://openhands.dev/joinslack) first.
## Sending Pull Requests to OpenHands
You'll need to fork our repository to send us a Pull Request. You can learn more
about how to fork a GitHub repo and open a PR with your changes in [this article](https://medium.com/swlh/forks-and-pull-requests-how-to-contribute-to-github-repos-8843fac34ce8).
### Pull Request title
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), ideally a valid PR title should begin with one of the following prefixes:
### Pull Request Title Format
As described [here](https://github.com/commitizen/conventional-commit-types/blob/master/index.json), a valid PR title should begin with one of the following prefixes:
- `feat`: A new feature
- `fix`: A bug fix
@@ -95,45 +117,27 @@ For example, a PR title could be:
- `refactor: modify package path`
- `feat(frontend): xxxx`, where `(frontend)` means that this PR mainly focuses on the frontend component.
You may also check out previous PRs in the [PR list](https://github.com/OpenHands/OpenHands/pulls).
### Pull Request Description
### Pull Request description
- Explain what the PR does and why
- Link to related issues
- Include screenshots for UI changes
- If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix),
please include a short message that we can add to our changelog
- If your PR is small (such as a typo fix), you can go brief.
- If it contains a lot of changes, it's better to write more details.
## Becoming a Maintainer
If your changes are user-facing (e.g. a new feature in the UI, a change in behavior, or a bugfix)
please include a short message that we can add to our changelog.
For contributors who have made significant and sustained contributions to the project, there is a possibility of joining the maintainer team.
The process for this is as follows:
## How to Make Effective Contributions
1. Any contributor who has made sustained and high-quality contributions to the codebase can be nominated by any maintainer. If you feel that you may qualify you can reach out to any of the maintainers that have reviewed your PRs and ask if you can be nominated.
2. Once a maintainer nominates a new maintainer, there will be a discussion period among the maintainers for at least 3 days.
3. If no concerns are raised the nomination will be accepted by acclamation, and if concerns are raised there will be a discussion and possible vote.
### Opening Issues
Note that just making many PRs does not immediately imply that you will become a maintainer. We will be looking at sustained high-quality contributions over a period of time, as well as good teamwork and adherence to our [Code of Conduct](./CODE_OF_CONDUCT.md).
If you notice any bugs or have any feature requests please open them via the [issues page](https://github.com/OpenHands/OpenHands/issues). We will triage
based on how critical the bug is or how potentially useful the improvement is, discuss, and implement the ones that
the community has interest/effort for.
## Need Help?
Further, if you see an issue you like, please leave a "thumbs-up" or a comment, which will help us prioritize.
### Making Pull Requests
We're generally happy to consider all pull requests with the evaluation process varying based on the type of change:
#### For Small Improvements
Small improvements with few downsides are typically reviewed and approved quickly.
One thing to check when making changes is to ensure that all continuous integration tests pass, which you can check
before getting a review.
#### For Core Agent Changes
We need to be more careful with changes to the core agent, as it is imperative to maintain high quality. These PRs are
evaluated based on three key metrics:
1. **Accuracy**
2. **Efficiency**
3. **Code Complexity**
If it improves accuracy, efficiency, or both with only a minimal change to code quality, that's great we're happy to merge it in!
If there are bigger tradeoffs (e.g. helping efficiency a lot and hurting accuracy a little) we might want to put it behind a feature flag.
Either way, please feel free to discuss on github issues or slack, and we will give guidance and preliminary feedback.
- **Slack**: [Join our community](https://openhands.dev/joinslack)
- **GitHub Issues**: [Open an issue](https://github.com/OpenHands/OpenHands/issues)
- **Email**: contact@openhands.dev

View File

@@ -6,22 +6,196 @@ If you wish to contribute your changes, check out the
on how to clone and setup the project initially before moving on. Otherwise,
you can clone the OpenHands project directly.
## Start the Server for Development
## Choose Your Setup
### 1. Requirements
Select your operating system to see the specific setup instructions:
- Linux, Mac OS, or [WSL on Windows](https://learn.microsoft.com/en-us/windows/wsl/install) [Ubuntu >= 22.04]
- [Docker](https://docs.docker.com/engine/install/) (For those on MacOS, make sure to allow the default Docker socket to be used from advanced settings!)
- [Python](https://www.python.org/downloads/) = 3.12
- [NodeJS](https://nodejs.org/en/download/package-manager) >= 22.x
- [Poetry](https://python-poetry.org/docs/#installing-with-the-official-installer) >= 1.8
- OS-specific dependencies:
- Ubuntu: build-essential => `sudo apt-get install build-essential python3.12-dev`
- WSL: netcat => `sudo apt-get install netcat`
- [macOS](#macos-setup)
- [Linux](#linux-setup)
- [Windows WSL](#windows-wsl-setup)
- [Dev Container](#dev-container)
- [Developing in Docker](#developing-in-docker)
- [No sudo access?](#develop-without-sudo-access)
Make sure you have all these dependencies installed before moving on to `make build`.
---
#### Dev container
## macOS Setup
### 1. Install Prerequisites
You'll need the following installed:
- **Python 3.12** — `brew install python@3.12` (see the [official Homebrew Python docs](https://docs.brew.sh/Homebrew-and-Python) for details). Make sure `python3.12` is available in your PATH (the `make build` step will verify this).
- **Node.js >= 22** — `brew install node`
- **Poetry >= 1.8** — `brew install poetry`
- **Docker Desktop** — `brew install --cask docker`
- After installing, open Docker Desktop → **Settings → Advanced** → Enable **"Allow the default Docker socket to be used"**
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For the V1 web app, start OpenHands and configure your model and API key in the Settings UI.
If you are running headless or CLI workflows, you can prepare local defaults with:
```bash
make setup-config
```
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
These targets serve the current OpenHands V1 API by default. In the codebase, `make start-backend` runs `openhands.server.listen:app`, and that app includes the `openhands/app_server` V1 routes unless `ENABLE_V1=0`.
---
## Linux Setup
This guide covers Ubuntu/Debian. For other distributions, adapt the package manager commands accordingly.
### 1. Install Prerequisites
```bash
# Update package list
sudo apt update
# Install system dependencies
sudo apt install -y build-essential curl netcat software-properties-common
# Install Python 3.12
# Ubuntu 24.04+ and Debian 13+ ship with Python 3.12 — skip the PPA step if
# python3.12 --version already works on your system.
# The deadsnakes PPA is Ubuntu-only and needed for Ubuntu 22.04 or older:
sudo add-apt-repository -y ppa:deadsnakes/ppa
sudo apt update
sudo apt install -y python3.12 python3.12-dev python3.12-venv
# Install Node.js 22.x
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt install -y nodejs
# Install Poetry
curl -sSL https://install.python-poetry.org | python3 -
# Add Poetry to your PATH
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Install Docker
# Follow the official guide: https://docs.docker.com/engine/install/ubuntu/
# Quick version:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker $USER
# Log out and back in for Docker group changes to take effect
```
### 2. Build and Setup the Environment
```bash
make build
```
### 3. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for guidance: configure your model and API key in the Settings UI.
### 4. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
---
## Windows WSL Setup
WSL2 with Ubuntu is recommended. The setup is similar to Linux, with a few WSL-specific considerations.
### 1. Install WSL2
**Option A: Windows 11 (Microsoft Store)**
The easiest way on Windows 11:
1. Open the **Microsoft Store** app
2. Search for **"Ubuntu 22.04 LTS"** or **"Ubuntu"**
3. Click **Install**
4. Launch Ubuntu from the Start menu
**Option B: PowerShell**
```powershell
# Run this in PowerShell as Administrator
wsl --install -d Ubuntu-22.04
```
After installation, restart your computer and open Ubuntu.
### 2. Install Prerequisites (in WSL Ubuntu)
Follow [Step 1 from the Linux setup](#1-install-prerequisites-1) to install system dependencies, Python 3.12, Node.js, and Poetry. Skip the Docker installation — Docker is provided through Docker Desktop below.
### 3. Configure Docker for WSL2
1. Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop)
2. Open Docker Desktop > Settings > General
3. Enable: "Use the WSL 2 based engine"
4. Go to Settings > Resources > WSL Integration
5. Enable integration with your Ubuntu distribution
**Important:** Keep your project files in the WSL filesystem (e.g., `~/workspace/openhands`), not in `/mnt/c`. Files accessed via `/mnt/c` will be significantly slower.
### 4. Build and Setup the Environment
```bash
make build
```
### 5. Configure the Language Model
See the [macOS section above](#3-configure-the-language-model) for the current V1 guidance: configure your model and API key in the Settings UI for the web app, and use `make setup-config` only for headless or CLI workflows.
### 6. Run the Application
```bash
# Run both backend and frontend
make run
# Or run separately:
make start-backend # Backend only on port 3000
make start-frontend # Frontend only on port 3001
```
Access the frontend at `http://localhost:3001` from your Windows browser.
---
## Dev Container
There is a [dev container](https://containers.dev/) available which provides a
pre-configured environment with all the necessary dependencies installed if you
@@ -32,7 +206,38 @@ extension installed, you can open the project in a dev container by using the
_Dev Container: Reopen in Container_ command from the Command Palette
(Ctrl+Shift+P).
#### Develop without sudo access
---
## Developing in Docker
If you don't want to install dependencies on your host machine, you can develop inside a Docker container.
### Quick Start
```bash
make docker-dev
```
For more details, see the [dev container documentation](./containers/dev/README.md).
### Alternative: Docker Run
If you just want to run OpenHands without setting up a dev environment:
```bash
make docker-run
```
If you don't have `make` installed, run:
```bash
cd ./containers/dev
./dev.sh
```
---
## Develop without sudo access
If you want to develop without system admin/sudo access to upgrade/install `Python` and/or `NodeJS`, you can use
`conda` or `mamba` to manage the packages for you:
@@ -48,159 +253,90 @@ mamba install conda-forge::nodejs
mamba install conda-forge::poetry
```
### 2. Build and Setup The Environment
---
Begin by building the project which includes setting up the environment and installing dependencies. This step ensures
that OpenHands is ready to run on your system:
## Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself!
### Quick Start
```bash
make build
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
### 3. Configuring the Language Model
Access the interface at:
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
OpenHands supports a diverse array of Language Models (LMs) through the powerful [litellm](https://docs.litellm.ai) library.
For external access:
```bash
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
To configure the LM of your choice, run:
---
## LLM Debugging
If you encounter issues with the Language Model, enable debug logging:
```bash
make setup-config
export DEBUG=1
# Restart the backend
make start-backend
```
This command will prompt you to enter the LLM API key, model name, and other variables ensuring that OpenHands is
tailored to your specific needs. Note that the model name will apply only when you run headless. If you use the UI,
please set the model in the UI.
Logs will be saved to `logs/llm/CURRENT_DATE/` for troubleshooting.
Note: If you have previously run OpenHands using the docker command, you may have already set some environment
variables in your terminal. The final configurations are set from highest to lowest priority:
Environment variables > config.toml variables > default variables
---
**Note on Alternative Models:**
See [our documentation](https://docs.openhands.dev/usage/llms) for recommended models.
## Testing
### 4. Running the application
#### Option A: Run the Full Application
Once the setup is complete, this command starts both the backend and frontend servers, allowing you to interact with OpenHands:
```bash
make run
```
#### Option B: Individual Server Startup
- **Start the Backend Server:** If you prefer, you can start the backend server independently to focus on
backend-related tasks or configurations.
```bash
make start-backend
```
- **Start the Frontend Server:** Similarly, you can start the frontend server on its own to work on frontend-related
components or interface enhancements.
```bash
make start-frontend
```
### 5. Running OpenHands with OpenHands
You can use OpenHands to develop and improve OpenHands itself! This is a powerful way to leverage AI assistance for contributing to the project.
#### Quick Start
1. **Build and run OpenHands:**
```bash
export INSTALL_DOCKER=0
export RUNTIME=local
make build && make run
```
2. **Access the interface:**
- Local development: http://localhost:3001
- Remote/cloud environments: Use the appropriate external URL
3. **Configure for external access (if needed):**
```bash
# For external access (e.g., cloud environments)
make run FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0
```
### 6. LLM Debugging
If you encounter any issues with the Language Model (LM) or you're simply curious, export DEBUG=1 in the environment and restart the backend.
OpenHands will log the prompts and responses in the logs/llm/CURRENT_DATE directory, allowing you to identify the causes.
### 7. Help
Need help or info on available targets and commands? Use the help command for all the guidance you need with OpenHands.
```bash
make help
```
### 8. Testing
To run tests, refer to the following:
#### Unit tests
### Unit Tests
```bash
poetry run pytest ./tests/unit/test_*.py
```
### 9. Add or update dependency
---
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`.
2. Update the poetry.lock file via `poetry lock --no-update`.
## Adding Dependencies
### 10. Use existing Docker image
1. Add your dependency in `pyproject.toml` or use `poetry add xxx`
2. Update the lock file: `poetry lock --no-update`
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
---
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik`
## Using Existing Docker Images
## Develop inside Docker container
TL;DR
To reduce build time, you can use an existing runtime image:
```bash
make docker-dev
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
```
See more details [here](./containers/dev/README.md).
---
If you are just interested in running `OpenHands` without installing all the required tools on your host.
## Help
```bash
make docker-run
make help
```
If you do not have `make` on your host, run:
```bash
cd ./containers/dev
./dev.sh
```
You do need [Docker](https://docs.docker.com/engine/install/) installed on your host though.
---
## Key Documentation Resources
Here's a guide to the important documentation files in the repository:
- [/README.md](./README.md): Main project overview, features, and basic setup instructions
- [/Development.md](./Development.md) (this file): Comprehensive guide for developers working on OpenHands
- [/CONTRIBUTING.md](./CONTRIBUTING.md): Guidelines for contributing to the project, including code style and PR process
- [DOC_STYLE_GUIDE.md](https://github.com/OpenHands/docs/blob/main/openhands/DOC_STYLE_GUIDE.md): Standards for writing and maintaining project documentation
- [/openhands/README.md](./openhands/README.md): Details about the backend Python implementation
- [/openhands/app_server/README.md](./openhands/app_server/README.md): Current V1 application server implementation and REST API modules
- [/frontend/README.md](./frontend/README.md): Frontend React application setup and development guide
- [/containers/README.md](./containers/README.md): Information about Docker containers and deployment
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
- [/openhands/server/README.md](./openhands/server/README.md): Server implementation details and API documentation
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model

View File

@@ -51,6 +51,6 @@ NOTE: in the future we will simply replace the `GithubTokenManager` with keycloa
## User ID vs User Token
- In OpenHands, the entire app revolves around the GitHub token the user sets. `openhands/server` uses `request.state.github_token` for the entire app
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completly ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
- On Enterprise, the entire APP resolves around the Github User ID. This is because the cookie sets it, so `openhands/server` AND `enterprise/server` depend on it and completely ignore `request.state.github_token` (token is fetched from `GithubTokenManager` instead)
Note that introducing GitHub User ID in OpenHands, for instance, will cause large breakages.

View File

@@ -0,0 +1,13 @@
# Enterprise Architecture Documentation
Architecture diagrams specific to the OpenHands SaaS/Enterprise deployment.
## Documentation
- [Authentication Flow](./authentication.md) - Keycloak-based authentication for SaaS deployment
- [External Integrations](./external-integrations.md) - GitHub, Slack, Jira, and other service integrations
## Related Documentation
For core OpenHands architecture (applicable to all deployments), see:
- [Core Architecture Documentation](../../../openhands/architecture/README.md)

View File

@@ -0,0 +1,58 @@
# Authentication Flow (SaaS Deployment)
OpenHands uses Keycloak for identity management in the SaaS deployment. The authentication flow involves multiple services:
```mermaid
sequenceDiagram
autonumber
participant User as User (Browser)
participant App as App Server
participant KC as Keycloak
participant IdP as Identity Provider<br/>(GitHub, Google, etc.)
participant DB as User Database
Note over User,DB: OAuth 2.0 / OIDC Authentication Flow
User->>App: Access OpenHands
App->>User: Redirect to Keycloak
User->>KC: Login request
KC->>User: Show login options
User->>KC: Select provider (e.g., GitHub)
KC->>IdP: OAuth redirect
User->>IdP: Authenticate
IdP-->>KC: OAuth callback + tokens
Note over KC: Create/update user session
KC-->>User: Redirect with auth code
User->>App: Auth code
App->>KC: Exchange code for tokens
KC-->>App: Access token + Refresh token
Note over App: Create signed JWT cookie
App->>DB: Store/update user record
App-->>User: Set keycloak_auth cookie
Note over User,DB: Subsequent Requests
User->>App: Request with cookie
Note over App: Verify JWT signature
App->>KC: Validate token (if needed)
KC-->>App: Token valid
Note over App: Extract user context
App-->>User: Authorized response
```
### Authentication Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Keycloak** | Identity provider, SSO, token management | External service |
| **UserAuth** | Abstract auth interface | `openhands/server/user_auth/user_auth.py` |
| **SaasUserAuth** | Keycloak implementation | `enterprise/server/auth/saas_user_auth.py` |
| **JWT Service** | Token signing/verification | `openhands/app_server/services/jwt_service.py` |
| **Auth Routes** | Login/logout endpoints | `enterprise/server/routes/auth.py` |
### Token Flow
1. **Keycloak Access Token**: Short-lived token for API access
2. **Keycloak Refresh Token**: Long-lived token to obtain new access tokens
3. **Signed JWT Cookie**: App Server's session cookie containing encrypted Keycloak tokens
4. **Provider Tokens**: OAuth tokens for GitHub, GitLab, etc. (stored separately for git operations)

View File

@@ -0,0 +1,88 @@
# External Integrations
OpenHands integrates with external services (GitHub, Slack, Jira, etc.) through webhook-based event handling:
```mermaid
sequenceDiagram
autonumber
participant Ext as External Service<br/>(GitHub/Slack/Jira)
participant App as App Server
participant IntRouter as Integration Router
participant Manager as Integration Manager
participant Conv as Conversation Service
participant Sandbox as Sandbox
Note over Ext,Sandbox: Webhook Event Flow (e.g., GitHub Issue Created)
Ext->>App: POST /api/integration/{service}/events
App->>IntRouter: Route to service handler
Note over IntRouter: Verify signature (HMAC)
IntRouter->>Manager: Parse event payload
Note over Manager: Extract context (repo, issue, user)
Note over Manager: Map external user → OpenHands user
Manager->>Conv: Create conversation (with issue context)
Conv->>Sandbox: Provision sandbox
Sandbox-->>Conv: Ready
Manager->>Sandbox: Start agent with task
Note over Ext,Sandbox: Agent Works on Task...
Sandbox-->>Manager: Task complete
Manager->>Ext: POST result<br/>(PR, comment, etc.)
Note over Ext,Sandbox: Callback Flow (Agent → External Service)
Sandbox->>App: Webhook callback<br/>/api/v1/webhooks
App->>Manager: Process callback
Manager->>Ext: Update external service
```
### Supported Integrations
| Integration | Trigger Events | Agent Actions |
|-------------|----------------|---------------|
| **GitHub** | Issue created, PR opened, @mention | Create PR, comment, push commits |
| **GitLab** | Issue created, MR opened | Create MR, comment, push commits |
| **Slack** | @mention in channel | Reply in thread, create tasks |
| **Jira** | Issue created/updated | Update ticket, add comments |
| **Linear** | Issue created | Update status, add comments |
### Integration Components
| Component | Purpose | Location |
|-----------|---------|----------|
| **Integration Routes** | Webhook endpoints per service | `enterprise/server/routes/integration/` |
| **Integration Managers** | Business logic per service | `enterprise/integrations/{service}/` |
| **Token Manager** | Store/retrieve OAuth tokens | `enterprise/server/auth/token_manager.py` |
| **Callback Processor** | Handle agent → service updates | `enterprise/integrations/{service}/*_callback_processor.py` |
### Integration Authentication
```
External Service (e.g., GitHub)
┌─────────────────────────────────┐
│ GitHub App Installation │
│ - Webhook secret for signature │
│ - App private key for API calls │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ User Account Linking │
│ - Keycloak user ID │
│ - GitHub user ID │
│ - Stored OAuth tokens │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ Agent Execution │
│ - Uses linked tokens for API │
│ - Can push, create PRs, comment │
└─────────────────────────────────┘
```

View File

@@ -60,7 +60,9 @@ class ResolverUserContext(UserContext):
return provider_token.token.get_secret_value()
return None
async def get_provider_tokens(self) -> PROVIDER_TOKEN_TYPE | None:
async def get_provider_tokens(
self, as_env_vars: bool = False
) -> PROVIDER_TOKEN_TYPE | dict[str, str] | None:
return await self.saas_user_auth.get_provider_tokens()
async def get_secrets(self) -> dict[str, SecretSource]:

24
enterprise/poetry.lock generated
View File

@@ -602,14 +602,14 @@ files = [
[[package]]
name = "authlib"
version = "1.6.7"
version = "1.6.9"
description = "The ultimate Python library in building OAuth and OpenID Connect servers and clients."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0"},
{file = "authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b"},
{file = "authlib-1.6.9-py2.py3-none-any.whl", hash = "sha256:f08b4c14e08f0861dc18a32357b33fbcfd2ea86cfe3fe149484b4d764c4a0ac3"},
{file = "authlib-1.6.9.tar.gz", hash = "sha256:d8f2421e7e5980cc1ddb4e32d3f5fa659cfaf60d8eaf3281ebed192e4ab74f04"},
]
[package.dependencies]
@@ -5443,14 +5443,14 @@ files = [
[[package]]
name = "mcp"
version = "1.25.0"
version = "1.26.0"
description = "Model Context Protocol SDK"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a"},
{file = "mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802"},
{file = "mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca"},
{file = "mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66"},
]
[package.dependencies]
@@ -7597,14 +7597,14 @@ wrappers-encryption = ["cryptography (>=45.0.0)"]
[[package]]
name = "pyasn1"
version = "0.6.2"
version = "0.6.3"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "pyasn1-0.6.2-py3-none-any.whl", hash = "sha256:1eb26d860996a18e9b6ed05e7aae0e9fc21619fcee6af91cca9bad4fbea224bf"},
{file = "pyasn1-0.6.2.tar.gz", hash = "sha256:9b59a2b25ba7e4f8197db7686c09fb33e658b98339fadb826e9512629017833b"},
{file = "pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde"},
{file = "pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf"},
]
[[package]]
@@ -11587,14 +11587,14 @@ diagrams = ["jinja2", "railroad-diagrams"]
[[package]]
name = "pypdf"
version = "6.8.0"
version = "6.9.1"
description = "A pure-python PDF library capable of splitting, merging, cropping, and transforming PDF files"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pypdf-6.8.0-py3-none-any.whl", hash = "sha256:2a025080a8dd73f48123c89c57174a5ff3806c71763ee4e49572dc90454943c7"},
{file = "pypdf-6.8.0.tar.gz", hash = "sha256:cb7eaeaa4133ce76f762184069a854e03f4d9a08568f0e0623f7ea810407833b"},
{file = "pypdf-6.9.1-py3-none-any.whl", hash = "sha256:f35a6a022348fae47e092a908339a8f3dc993510c026bb39a96718fc7185e89f"},
{file = "pypdf-6.9.1.tar.gz", hash = "sha256:ae052407d33d34de0c86c5c729be6d51010bf36e03035a8f23ab449bca52377d"},
]
[package.extras]

View File

@@ -46,6 +46,8 @@ from server.routes.org_invitations import ( # noqa: E402
)
from server.routes.orgs import org_router # noqa: E402
from server.routes.readiness import readiness_router # noqa: E402
from server.routes.usage import usage_router # noqa: E402
from server.routes.service import service_router # noqa: E402
from server.routes.user import saas_user_router # noqa: E402
from server.routes.user_app_settings import user_app_settings_router # noqa: E402
from server.sharing.shared_conversation_router import ( # noqa: E402
@@ -112,7 +114,9 @@ if GITLAB_APP_CLIENT_ID:
base_app.include_router(gitlab_integration_router)
base_app.include_router(api_keys_router) # Add routes for API key management
base_app.include_router(service_router) # Add routes for internal service API
base_app.include_router(org_router) # Add routes for organization management
base_app.include_router(usage_router) # Add routes for organization usage statistics
base_app.include_router(
verified_models_router
) # Add routes for verified models management

View File

@@ -35,7 +35,7 @@ Usage:
from enum import Enum
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi import Depends, HTTPException, Request, status
from storage.org_member_store import OrgMemberStore
from storage.role import Role
from storage.role_store import RoleStore
@@ -214,6 +214,19 @@ def has_permission(user_role: Role, permission: Permission) -> bool:
return permission in permissions
async def get_api_key_org_id_from_request(request: Request) -> UUID | None:
"""Get the org_id bound to the API key used for authentication.
Returns None if:
- Not authenticated via API key (cookie auth)
- API key is a legacy key without org binding
"""
user_auth = getattr(request.state, 'user_auth', None)
if user_auth and hasattr(user_auth, 'get_api_key_org_id'):
return user_auth.get_api_key_org_id()
return None
def require_permission(permission: Permission):
"""
Factory function that creates a dependency to require a specific permission.
@@ -221,8 +234,9 @@ def require_permission(permission: Permission):
This creates a FastAPI dependency that:
1. Extracts org_id from the path parameter
2. Gets the authenticated user_id
3. Checks if the user has the required permission in the organization
4. Returns the user_id if authorized, raises HTTPException otherwise
3. Validates API key org binding (if using API key auth)
4. Checks if the user has the required permission in the organization
5. Returns the user_id if authorized, raises HTTPException otherwise
Usage:
@router.get('/{org_id}/settings')
@@ -240,6 +254,7 @@ def require_permission(permission: Permission):
"""
async def permission_checker(
request: Request,
org_id: UUID | None = None,
user_id: str | None = Depends(get_user_id),
) -> str:
@@ -249,6 +264,23 @@ def require_permission(permission: Permission):
detail='User not authenticated',
)
# Validate API key organization binding
api_key_org_id = await get_api_key_org_id_from_request(request)
if api_key_org_id is not None and org_id is not None:
if api_key_org_id != org_id:
logger.warning(
'API key organization mismatch',
extra={
'user_id': user_id,
'api_key_org_id': str(api_key_org_id),
'target_org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail='API key is not authorized for this organization',
)
user_role = await get_user_org_role(user_id, org_id)
if not user_role:

View File

@@ -1,6 +1,7 @@
import time
from dataclasses import dataclass
from types import MappingProxyType
from uuid import UUID
import jwt
from fastapi import Request
@@ -59,6 +60,19 @@ class SaasUserAuth(UserAuth):
_secrets: Secrets | None = None
accepted_tos: bool | None = None
auth_type: AuthType = AuthType.COOKIE
# API key context fields - populated when authenticated via API key
api_key_org_id: UUID | None = None # Org bound to the API key used for auth
api_key_id: int | None = None
api_key_name: str | None = None
def get_api_key_org_id(self) -> UUID | None:
"""Get the organization ID bound to the API key used for authentication.
Returns:
The org_id if authenticated via API key with org binding, None otherwise
(cookie auth or legacy API keys without org binding).
"""
return self.api_key_org_id
async def get_user_id(self) -> str | None:
return self.user_id
@@ -283,14 +297,19 @@ async def saas_user_auth_from_bearer(request: Request) -> SaasUserAuth | None:
return None
api_key_store = ApiKeyStore.get_instance()
user_id = await api_key_store.validate_api_key(api_key)
if not user_id:
validation_result = await api_key_store.validate_api_key(api_key)
if not validation_result:
return None
offline_token = await token_manager.load_offline_token(user_id)
offline_token = await token_manager.load_offline_token(
validation_result.user_id
)
saas_user_auth = SaasUserAuth(
user_id=user_id,
user_id=validation_result.user_id,
refresh_token=SecretStr(offline_token),
auth_type=AuthType.BEARER,
api_key_org_id=validation_result.org_id,
api_key_id=validation_result.key_id,
api_key_name=validation_result.key_name,
)
await saas_user_auth.refresh()
return saas_user_auth

View File

@@ -182,6 +182,10 @@ class SetAuthCookieMiddleware:
if path.startswith('/api/v1/webhooks/'):
return False
# Service API uses its own authentication (X-Service-API-Key header)
if path.startswith('/api/service/'):
return False
is_mcp = path.startswith('/mcp')
is_api_route = path.startswith('/api')
return is_api_route or is_mcp

View File

@@ -1,7 +1,9 @@
from datetime import UTC, datetime
from typing import cast
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, field_validator
from server.auth.saas_user_auth import SaasUserAuth
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.lite_llm_manager import LiteLlmManager
@@ -11,7 +13,8 @@ from storage.org_service import OrgService
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
from openhands.server.user_auth import get_user_id
from openhands.server.user_auth import get_user_auth, get_user_id
from openhands.server.user_auth.user_auth import AuthType
# Helper functions for BYOR API key management
@@ -150,6 +153,16 @@ class MessageResponse(BaseModel):
message: str
class CurrentApiKeyResponse(BaseModel):
"""Response model for the current API key endpoint."""
id: int
name: str | None
org_id: str
user_id: str
auth_type: str
def api_key_to_response(key: ApiKey) -> ApiKeyResponse:
"""Convert an ApiKey model to an ApiKeyResponse."""
return ApiKeyResponse(
@@ -262,6 +275,46 @@ async def delete_api_key(
)
@api_router.get('/current', tags=['Keys'])
async def get_current_api_key(
request: Request,
user_id: str = Depends(get_user_id),
) -> CurrentApiKeyResponse:
"""Get information about the currently authenticated API key.
This endpoint returns metadata about the API key used for the current request,
including the org_id associated with the key. This is useful for API key
callers who need to know which organization context their key operates in.
Returns 400 if not authenticated via API key (e.g., using cookie auth).
"""
user_auth = await get_user_auth(request)
# Check if authenticated via API key
if user_auth.get_auth_type() != AuthType.BEARER:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This endpoint requires API key authentication. Not available for cookie-based auth.',
)
# In SaaS context, bearer auth always produces SaasUserAuth
saas_user_auth = cast(SaasUserAuth, user_auth)
if saas_user_auth.api_key_org_id is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='This API key was created before organization support. Please regenerate your API key to use this endpoint.',
)
return CurrentApiKeyResponse(
id=saas_user_auth.api_key_id,
name=saas_user_auth.api_key_name,
org_id=str(saas_user_auth.api_key_org_id),
user_id=user_id,
auth_type=saas_user_auth.auth_type.value,
)
@api_router.get('/llm/byor', tags=['Keys'])
async def get_llm_api_key_for_byor(
user_id: str = Depends(get_user_id),

View File

@@ -0,0 +1,270 @@
"""
Service API routes for internal service-to-service communication.
This module provides endpoints for trusted internal services (e.g., automations service)
to perform privileged operations like creating API keys on behalf of users.
Authentication is via a shared secret (X-Service-API-Key header) configured
through the AUTOMATIONS_SERVICE_API_KEY environment variable.
"""
import os
from uuid import UUID
from fastapi import APIRouter, Header, HTTPException, status
from pydantic import BaseModel, field_validator
from storage.api_key_store import ApiKeyStore
from storage.org_member_store import OrgMemberStore
from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
# Environment variable for the service API key
AUTOMATIONS_SERVICE_API_KEY = os.getenv('AUTOMATIONS_SERVICE_API_KEY', '').strip()
service_router = APIRouter(prefix='/api/service', tags=['Service'])
class CreateUserApiKeyRequest(BaseModel):
"""Request model for creating an API key on behalf of a user."""
name: str # Required - used to identify the key
@field_validator('name')
@classmethod
def validate_name(cls, v: str) -> str:
if not v or not v.strip():
raise ValueError('name is required and cannot be empty')
return v.strip()
class CreateUserApiKeyResponse(BaseModel):
"""Response model for created API key."""
key: str
user_id: str
org_id: str
name: str
class ServiceInfoResponse(BaseModel):
"""Response model for service info endpoint."""
service: str
authenticated: bool
async def validate_service_api_key(
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> str:
"""
Validate the service API key from the request header.
Args:
x_service_api_key: The service API key from the X-Service-API-Key header
Returns:
str: Service identifier for audit logging
Raises:
HTTPException: 401 if key is missing or invalid
HTTPException: 503 if service auth is not configured
"""
if not AUTOMATIONS_SERVICE_API_KEY:
logger.warning(
'Service authentication not configured (AUTOMATIONS_SERVICE_API_KEY not set)'
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail='Service authentication not configured',
)
if not x_service_api_key:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='X-Service-API-Key header is required',
)
if x_service_api_key != AUTOMATIONS_SERVICE_API_KEY:
logger.warning('Invalid service API key attempted')
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail='Invalid service API key',
)
return 'automations-service'
@service_router.get('/health')
async def service_health() -> dict:
"""Health check endpoint for the service API.
This endpoint does not require authentication and can be used
to verify the service routes are accessible.
"""
return {
'status': 'ok',
'service_auth_configured': bool(AUTOMATIONS_SERVICE_API_KEY),
}
@service_router.post('/users/{user_id}/orgs/{org_id}/api-keys')
async def get_or_create_api_key_for_user(
user_id: str,
org_id: UUID,
request: CreateUserApiKeyRequest,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> CreateUserApiKeyResponse:
"""
Get or create an API key for a user on behalf of the automations service.
If a key with the given name already exists for the user/org and is not expired,
returns the existing key. Otherwise, creates a new key.
The created/returned keys are system keys and are:
- Not visible to the user in their API keys list
- Not deletable by the user
- Never expire
Args:
user_id: The user ID
org_id: The organization ID
request: Request body containing name (required)
x_service_api_key: Service API key header for authentication
Returns:
CreateUserApiKeyResponse: The API key and metadata
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if user not found
HTTPException: 403 if user is not a member of the specified org
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
# Verify user exists
user = await UserStore.get_user_by_id(user_id)
if not user:
logger.warning(
'Service attempted to create key for non-existent user',
extra={'user_id': user_id},
)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'User {user_id} not found',
)
# Verify user is a member of the specified org
org_member = await OrgMemberStore.get_org_member(org_id, UUID(user_id))
if not org_member:
logger.warning(
'Service attempted to create key for user not in org',
extra={
'user_id': user_id,
'org_id': str(org_id),
},
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f'User {user_id} is not a member of org {org_id}',
)
# Get or create the system API key
api_key_store = ApiKeyStore.get_instance()
try:
api_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=request.name,
)
except Exception as e:
logger.exception(
'Failed to get or create system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'error': str(e),
},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='Failed to get or create API key',
)
logger.info(
'Service created API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': request.name,
},
)
return CreateUserApiKeyResponse(
key=api_key,
user_id=user_id,
org_id=str(org_id),
name=request.name,
)
@service_router.delete('/users/{user_id}/orgs/{org_id}/api-keys/{key_name}')
async def delete_user_api_key(
user_id: str,
org_id: UUID,
key_name: str,
x_service_api_key: str | None = Header(default=None, alias='X-Service-API-Key'),
) -> dict:
"""
Delete a system API key created by the service.
This endpoint allows the automations service to clean up API keys
it previously created for users.
Args:
user_id: The user ID
org_id: The organization ID
key_name: The name of the key to delete (without __SYSTEM__: prefix)
x_service_api_key: Service API key header for authentication
Returns:
dict: Success message
Raises:
HTTPException: 401 if service key is invalid
HTTPException: 404 if key not found
"""
# Validate service API key
service_id = await validate_service_api_key(x_service_api_key)
api_key_store = ApiKeyStore.get_instance()
# Delete the key by name (wrap with system key prefix since service creates system keys)
system_key_name = api_key_store.make_system_key_name(key_name)
success = await api_key_store.delete_api_key_by_name(
user_id=user_id,
org_id=org_id,
name=system_key_name,
allow_system=True,
)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f'API key with name "{key_name}" not found for user {user_id} in org {org_id}',
)
logger.info(
'Service deleted API key for user',
extra={
'service_id': service_id,
'user_id': user_id,
'org_id': str(org_id),
'key_name': key_name,
},
)
return {'message': 'API key deleted successfully'}

View File

@@ -0,0 +1,200 @@
"""Usage statistics API endpoint for organization dashboards.
Provides aggregated metrics for organization admins and owners including:
- Total conversation count
- Merged PR count
- Average conversation cost
- Daily conversation counts for the last 90 days
"""
from datetime import UTC, datetime, timedelta
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from server.auth.authorization import Permission, require_permission
from sqlalchemy import and_, func, select
from storage.database import a_session_maker
from storage.openhands_pr import OpenhandsPR
from storage.stored_conversation_metadata import StoredConversationMetadata
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
from openhands.core.logger import openhands_logger as logger
usage_router = APIRouter(prefix="/api/organizations", tags=["Usage"])
class DailyConversationCount(BaseModel):
"""Daily conversation count for a specific date."""
date: str # ISO date format YYYY-MM-DD
count: int
class UsageStatsResponse(BaseModel):
"""Response model for organization usage statistics."""
total_conversations: int
merged_prs: int
average_cost: float
daily_conversations: list[DailyConversationCount]
@usage_router.get(
"/{org_id}/usage",
response_model=UsageStatsResponse,
dependencies=[Depends(require_permission(Permission.VIEW_BILLING))],
)
async def get_org_usage_stats(
org_id: UUID,
) -> UsageStatsResponse:
"""Get usage statistics for an organization.
This endpoint returns aggregated usage metrics for the specified organization:
- Total number of conversations
- Number of merged PRs created by OpenHands
- Average cost per conversation
- Daily conversation counts for the last 90 days
Only users with VIEW_BILLING permission (admin or owner) can access this endpoint.
Args:
org_id: Organization ID (UUID)
Returns:
UsageStatsResponse: Aggregated usage statistics
Raises:
HTTPException: 403 if user doesn't have permission
HTTPException: 500 if retrieval fails
"""
logger.info(
"Fetching usage statistics for organization",
extra={"org_id": str(org_id)},
)
try:
async with a_session_maker() as session:
# Get total conversation count for this org
total_conversations_result = await session.execute(
select(
func.count(StoredConversationMetadataSaas.conversation_id)
).where(StoredConversationMetadataSaas.org_id == org_id)
)
total_conversations = total_conversations_result.scalar() or 0
# Get average cost - join with conversation metadata to get accumulated_cost
avg_cost_result = await session.execute(
select(func.avg(StoredConversationMetadata.accumulated_cost))
.select_from(StoredConversationMetadata)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(StoredConversationMetadataSaas.org_id == org_id)
)
average_cost = avg_cost_result.scalar() or 0.0
# Get merged PRs count
# Note: OpenhandsPR doesn't have org_id directly, so we need to join via conversations
# For now, we'll count all merged PRs that are associated with conversations in this org
merged_prs_result = await session.execute(
select(func.count(func.distinct(OpenhandsPR.id)))
.select_from(OpenhandsPR)
.join(
StoredConversationMetadata,
and_(
StoredConversationMetadata.selected_repository
== OpenhandsPR.repo_name,
StoredConversationMetadata.pr_number.contains(
[OpenhandsPR.pr_number]
),
),
)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(
and_(
StoredConversationMetadataSaas.org_id == org_id,
OpenhandsPR.merged.is_(True),
)
)
)
merged_prs = merged_prs_result.scalar() or 0
# Get daily conversation counts for the last 90 days
ninety_days_ago = datetime.now(UTC) - timedelta(days=90)
daily_counts_result = await session.execute(
select(
func.date(StoredConversationMetadata.created_at).label("date"),
func.count(StoredConversationMetadata.conversation_id).label(
"count"
),
)
.select_from(StoredConversationMetadata)
.join(
StoredConversationMetadataSaas,
StoredConversationMetadata.conversation_id
== StoredConversationMetadataSaas.conversation_id,
)
.where(
and_(
StoredConversationMetadataSaas.org_id == org_id,
StoredConversationMetadata.created_at >= ninety_days_ago,
)
)
.group_by(func.date(StoredConversationMetadata.created_at))
.order_by(func.date(StoredConversationMetadata.created_at))
)
daily_counts_rows = daily_counts_result.all()
# Convert to response format, filling in missing dates with 0
daily_conversations = []
date_counts = {
str(row.date): row.count
for row in daily_counts_rows
if row.date is not None
}
current_date = ninety_days_ago.date()
end_date = datetime.now(UTC).date()
while current_date <= end_date:
date_str = current_date.isoformat()
daily_conversations.append(
DailyConversationCount(
date=date_str,
count=date_counts.get(date_str, 0),
)
)
current_date += timedelta(days=1)
logger.info(
"Successfully retrieved usage statistics",
extra={
"org_id": str(org_id),
"total_conversations": total_conversations,
"merged_prs": merged_prs,
"average_cost": average_cost,
},
)
return UsageStatsResponse(
total_conversations=total_conversations,
merged_prs=merged_prs,
average_cost=round(average_cost, 4),
daily_conversations=daily_conversations,
)
except Exception as e:
logger.exception(
"Error fetching usage statistics",
extra={"org_id": str(org_id), "error": str(e)},
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve usage statistics",
)

View File

@@ -4,6 +4,7 @@ import secrets
import string
from dataclasses import dataclass
from datetime import UTC, datetime
from uuid import UUID
from sqlalchemy import select, update
from storage.api_key import ApiKey
@@ -13,9 +14,22 @@ from storage.user_store import UserStore
from openhands.core.logger import openhands_logger as logger
@dataclass
class ApiKeyValidationResult:
"""Result of API key validation containing user and organization info."""
user_id: str
org_id: UUID | None # None for legacy API keys without org binding
key_id: int
key_name: str | None
@dataclass
class ApiKeyStore:
API_KEY_PREFIX = 'sk-oh-'
# Prefix for system keys created by internal services (e.g., automations)
# Keys with this prefix are hidden from users and cannot be deleted by users
SYSTEM_KEY_NAME_PREFIX = '__SYSTEM__:'
def generate_api_key(self, length: int = 32) -> str:
"""Generate a random API key with the sk-oh- prefix."""
@@ -23,6 +37,19 @@ class ApiKeyStore:
random_part = ''.join(secrets.choice(alphabet) for _ in range(length))
return f'{self.API_KEY_PREFIX}{random_part}'
@classmethod
def is_system_key_name(cls, name: str | None) -> bool:
"""Check if a key name indicates a system key."""
return name is not None and name.startswith(cls.SYSTEM_KEY_NAME_PREFIX)
@classmethod
def make_system_key_name(cls, name: str) -> str:
"""Create a system key name with the appropriate prefix.
Format: __SYSTEM__:<name>
"""
return f'{cls.SYSTEM_KEY_NAME_PREFIX}{name}'
async def create_api_key(
self, user_id: str, name: str | None = None, expires_at: datetime | None = None
) -> str:
@@ -60,8 +87,120 @@ class ApiKeyStore:
return api_key
async def validate_api_key(self, api_key: str) -> str | None:
"""Validate an API key and return the associated user_id if valid."""
async def get_or_create_system_api_key(
self,
user_id: str,
org_id: UUID,
name: str,
) -> str:
"""Get or create a system API key for a user on behalf of an internal service.
If a key with the given name already exists for this user/org and is not expired,
returns the existing key. Otherwise, creates a new key (and deletes any expired one).
System keys are:
- Not visible to users in their API keys list (filtered by name prefix)
- Not deletable by users (protected by name prefix check)
- Associated with a specific org (not the user's current org)
- Never expire (no expiration date)
Args:
user_id: The ID of the user to create the key for
org_id: The organization ID to associate the key with
name: Required name for the key (will be prefixed with __SYSTEM__:)
Returns:
The API key (existing or newly created)
"""
# Create system key name with prefix
system_key_name = self.make_system_key_name(name)
async with a_session_maker() as session:
# Check if key already exists for this user/org/name
result = await session.execute(
select(ApiKey).filter(
ApiKey.user_id == user_id,
ApiKey.org_id == org_id,
ApiKey.name == system_key_name,
)
)
existing_key = result.scalars().first()
if existing_key:
# Check if expired
if existing_key.expires_at:
now = datetime.now(UTC)
expires_at = existing_key.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < now:
# Key is expired, delete it and create new one
logger.info(
'System API key expired, re-issuing',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
await session.delete(existing_key)
await session.commit()
else:
# Key exists and is not expired, return it
logger.debug(
'Returning existing system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return existing_key.key
else:
# Key exists and has no expiration, return it
logger.debug(
'Returning existing system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return existing_key.key
# Create new key (no expiration)
api_key = self.generate_api_key()
async with a_session_maker() as session:
key_record = ApiKey(
key=api_key,
user_id=user_id,
org_id=org_id,
name=system_key_name,
expires_at=None, # System keys never expire
)
session.add(key_record)
await session.commit()
logger.info(
'Created system API key',
extra={
'user_id': user_id,
'org_id': str(org_id),
'key_name': system_key_name,
},
)
return api_key
async def validate_api_key(self, api_key: str) -> ApiKeyValidationResult | None:
"""Validate an API key and return the associated user_id and org_id if valid.
Returns:
ApiKeyValidationResult if the key is valid, None otherwise.
The org_id may be None for legacy API keys that weren't bound to an organization.
"""
now = datetime.now(UTC)
async with a_session_maker() as session:
@@ -89,7 +228,12 @@ class ApiKeyStore:
)
await session.commit()
return key_record.user_id
return ApiKeyValidationResult(
user_id=key_record.user_id,
org_id=key_record.org_id,
key_id=key_record.id,
key_name=key_record.name,
)
async def delete_api_key(self, api_key: str) -> bool:
"""Delete an API key by the key value."""
@@ -105,8 +249,18 @@ class ApiKeyStore:
return True
async def delete_api_key_by_id(self, key_id: int) -> bool:
"""Delete an API key by its ID."""
async def delete_api_key_by_id(
self, key_id: int, allow_system: bool = False
) -> bool:
"""Delete an API key by its ID.
Args:
key_id: The ID of the key to delete
allow_system: If False (default), system keys cannot be deleted
Returns:
True if the key was deleted, False if not found or is a protected system key
"""
async with a_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
@@ -114,13 +268,26 @@ class ApiKeyStore:
if not key_record:
return False
# Protect system keys from deletion unless explicitly allowed
if self.is_system_key_name(key_record.name) and not allow_system:
logger.warning(
'Attempted to delete system API key',
extra={'key_id': key_id, 'user_id': key_record.user_id},
)
return False
await session.delete(key_record)
await session.commit()
return True
async def list_api_keys(self, user_id: str) -> list[ApiKey]:
"""List all API keys for a user."""
"""List all user-visible API keys for a user.
This excludes:
- System keys (name starts with __SYSTEM__:) - created by internal services
- MCP_API_KEY - internal MCP key
"""
user = await UserStore.get_user_by_id(user_id)
if user is None:
raise ValueError(f'User not found: {user_id}')
@@ -129,11 +296,17 @@ class ApiKeyStore:
async with a_session_maker() as session:
result = await session.execute(
select(ApiKey).filter(
ApiKey.user_id == user_id, ApiKey.org_id == org_id
ApiKey.user_id == user_id,
ApiKey.org_id == org_id,
)
)
keys = result.scalars().all()
return [key for key in keys if key.name != 'MCP_API_KEY']
# Filter out system keys and MCP_API_KEY
return [
key
for key in keys
if key.name != 'MCP_API_KEY' and not self.is_system_key_name(key.name)
]
async def retrieve_mcp_api_key(self, user_id: str) -> str | None:
user = await UserStore.get_user_by_id(user_id)
@@ -163,17 +336,44 @@ class ApiKeyStore:
key_record = result.scalars().first()
return key_record.key if key_record else None
async def delete_api_key_by_name(self, user_id: str, name: str) -> bool:
"""Delete an API key by name for a specific user."""
async def delete_api_key_by_name(
self,
user_id: str,
name: str,
org_id: UUID | None = None,
allow_system: bool = False,
) -> bool:
"""Delete an API key by name for a specific user.
Args:
user_id: The ID of the user whose key to delete
name: The name of the key to delete
org_id: Optional organization ID to filter by (required for system keys)
allow_system: If False (default), system keys cannot be deleted
Returns:
True if the key was deleted, False if not found or is a protected system key
"""
async with a_session_maker() as session:
result = await session.execute(
select(ApiKey).filter(ApiKey.user_id == user_id, ApiKey.name == name)
)
# Build the query filters
filters = [ApiKey.user_id == user_id, ApiKey.name == name]
if org_id is not None:
filters.append(ApiKey.org_id == org_id)
result = await session.execute(select(ApiKey).filter(*filters))
key_record = result.scalars().first()
if not key_record:
return False
# Protect system keys from deletion unless explicitly allowed
if self.is_system_key_name(key_record.name) and not allow_system:
logger.warning(
'Attempted to delete system API key',
extra={'user_id': user_id, 'key_name': name},
)
return False
await session.delete(key_record)
await session.commit()

View File

@@ -29,14 +29,37 @@ KEY_VERIFICATION_TIMEOUT = 5.0
# A very large number to represent "unlimited" until LiteLLM fixes their unlimited update bug.
UNLIMITED_BUDGET_SETTING = 1000000000.0
try:
DEFAULT_INITIAL_BUDGET = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if DEFAULT_INITIAL_BUDGET < 0:
# Check if billing is enabled (defaults to false for enterprise deployments)
ENABLE_BILLING = os.environ.get('ENABLE_BILLING', 'false').lower() == 'true'
def _get_default_initial_budget() -> float | None:
"""Get the default initial budget for new teams.
When billing is disabled (ENABLE_BILLING=false), returns None to disable
budget enforcement in LiteLLM. When billing is enabled, returns the
DEFAULT_INITIAL_BUDGET environment variable value (default 0.0).
Returns:
float | None: The default budget, or None to disable budget enforcement.
"""
if not ENABLE_BILLING:
return None
try:
budget = float(os.environ.get('DEFAULT_INITIAL_BUDGET', 0.0))
if budget < 0:
raise ValueError(
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {budget}'
)
return budget
except ValueError as e:
raise ValueError(
f'DEFAULT_INITIAL_BUDGET must be non-negative, got {DEFAULT_INITIAL_BUDGET}'
)
except ValueError as e:
raise ValueError(f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}') from e
f'Invalid DEFAULT_INITIAL_BUDGET environment variable: {e}'
) from e
DEFAULT_INITIAL_BUDGET: float | None = _get_default_initial_budget()
def get_openhands_cloud_key_alias(keycloak_user_id: str, org_id: str) -> str:
@@ -110,12 +133,15 @@ class LiteLlmManager:
) as client:
# Check if team already exists and get its budget
# New users joining existing orgs should inherit the team's budget
team_budget: float = DEFAULT_INITIAL_BUDGET
# When billing is disabled, DEFAULT_INITIAL_BUDGET is None
team_budget: float | None = DEFAULT_INITIAL_BUDGET
try:
existing_team = await LiteLlmManager._get_team(client, org_id)
if existing_team:
team_info = existing_team.get('team_info', {})
team_budget = team_info.get('max_budget', 0.0) or 0.0
# Preserve None from existing team (no budget enforcement)
existing_budget = team_info.get('max_budget')
team_budget = existing_budget
logger.info(
'LiteLlmManager:create_entries:existing_team_budget',
extra={
@@ -138,9 +164,33 @@ class LiteLlmManager:
)
if create_user:
await LiteLlmManager._create_user(
user_created = await LiteLlmManager._create_user(
client, keycloak_user_info.get('email'), keycloak_user_id
)
if not user_created:
logger.error(
'create_entries_failed_user_creation',
extra={
'org_id': org_id,
'user_id': keycloak_user_id,
},
)
return None
# Verify user exists before proceeding with key generation
user_exists = await LiteLlmManager._user_exists(
client, keycloak_user_id
)
if not user_exists:
logger.error(
'create_entries_user_not_found_before_key_generation',
extra={
'org_id': org_id,
'user_id': keycloak_user_id,
'create_user_flag': create_user,
},
)
return None
await LiteLlmManager._add_user_to_team(
client, keycloak_user_id, org_id, team_budget
@@ -525,25 +575,40 @@ class LiteLlmManager:
client: httpx.AsyncClient,
team_alias: str,
team_id: str,
max_budget: float,
max_budget: float | None,
):
"""Create a new team in LiteLLM.
Args:
client: The HTTP client to use.
team_alias: The alias for the team.
team_id: The ID for the team.
max_budget: The maximum budget for the team. When None, budget
enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
}
if max_budget is not None:
json_data['max_budget'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/new',
json={
'team_id': team_id,
'team_alias': team_alias,
'models': [],
'max_budget': max_budget,
'spend': 0,
'metadata': {
'version': ORG_SETTINGS_VERSION,
'model': get_default_litellm_model(),
},
},
json=json_data,
)
# Team failed to create in litellm - this is an unforseen error state...
if not response.is_success:
if (
@@ -620,15 +685,48 @@ class LiteLlmManager:
)
response.raise_for_status()
@staticmethod
async def _user_exists(
client: httpx.AsyncClient,
user_id: str,
) -> bool:
"""Check if a user exists in LiteLLM.
Returns True if the user exists, False otherwise.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
return False
try:
response = await client.get(
f'{LITE_LLM_API_URL}/user/info?user_id={user_id}',
)
if response.is_success:
user_data = response.json()
# Check that user_info exists and has the user_id
user_info = user_data.get('user_info', {})
return user_info.get('user_id') == user_id
return False
except Exception as e:
logger.warning(
'litellm_user_exists_check_failed',
extra={'user_id': user_id, 'error': str(e)},
)
return False
@staticmethod
async def _create_user(
client: httpx.AsyncClient,
email: str | None,
keycloak_user_id: str,
):
) -> bool:
"""Create a user in LiteLLM.
Returns True if the user was created or already exists and is verified,
False if creation failed and user does not exist.
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
return False
response = await client.post(
f'{LITE_LLM_API_URL}/user/new',
json={
@@ -681,17 +779,33 @@ class LiteLlmManager:
'user_id': keycloak_user_id,
},
)
return
# Verify the user actually exists before returning success
user_exists = await LiteLlmManager._user_exists(
client, keycloak_user_id
)
if not user_exists:
logger.error(
'litellm_user_claimed_exists_but_not_found',
extra={
'user_id': keycloak_user_id,
'status_code': response.status_code,
'text': response.text,
},
)
return False
return True
logger.error(
'error_creating_litellm_user',
extra={
'status_code': response.status_code,
'text': response.text,
'user_id': [keycloak_user_id],
'user_id': keycloak_user_id,
'email': None,
},
)
return False
response.raise_for_status()
return True
@staticmethod
async def _get_user(client: httpx.AsyncClient, user_id: str) -> dict | None:
@@ -918,19 +1032,34 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float,
max_budget: float | None,
):
"""Add a user to a team in LiteLLM.
Args:
client: The HTTP client to use.
keycloak_user_id: The user's Keycloak ID.
team_id: The team ID.
max_budget: The maximum budget for the user in the team. When None,
budget enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_add',
json={
'team_id': team_id,
'member': {'user_id': keycloak_user_id, 'role': 'user'},
'max_budget_in_team': max_budget,
},
json=json_data,
)
# Failed to add user to team - this is an unforseen error state...
if not response.is_success:
if (
@@ -998,19 +1127,34 @@ class LiteLlmManager:
client: httpx.AsyncClient,
keycloak_user_id: str,
team_id: str,
max_budget: float,
max_budget: float | None,
):
"""Update a user's budget in a team.
Args:
client: The HTTP client to use.
keycloak_user_id: The user's Keycloak ID.
team_id: The team ID.
max_budget: The maximum budget for the user in the team. When None,
budget enforcement is disabled (unlimited usage).
"""
if LITE_LLM_API_KEY is None or LITE_LLM_API_URL is None:
logger.warning('LiteLLM API configuration not found')
return
json_data: dict[str, Any] = {
'team_id': team_id,
'user_id': keycloak_user_id,
}
if max_budget is not None:
json_data['max_budget_in_team'] = max_budget
response = await client.post(
f'{LITE_LLM_API_URL}/team/member_update',
json={
'team_id': team_id,
'user_id': keycloak_user_id,
'max_budget_in_team': max_budget,
},
json=json_data,
)
# Failed to update user in team - this is an unforseen error state...
if not response.is_success:
logger.error(
@@ -1397,6 +1541,7 @@ class LiteLlmManager:
create_team = staticmethod(with_http_client(_create_team))
get_team = staticmethod(with_http_client(_get_team))
update_team = staticmethod(with_http_client(_update_team))
user_exists = staticmethod(with_http_client(_user_exists))
create_user = staticmethod(with_http_client(_create_user))
get_user = staticmethod(with_http_client(_get_user))
update_user = staticmethod(with_http_client(_update_user))

View File

@@ -15,25 +15,27 @@ class SaasConversationValidator(ConversationValidator):
async def _validate_api_key(self, api_key: str) -> str | None:
"""
Validate an API key and return the user_id and github_user_id if valid.
Validate an API key and return the user_id if valid.
Args:
api_key: The API key to validate
Returns:
A tuple of (user_id, github_user_id) if the API key is valid, None otherwise
The user_id if the API key is valid, None otherwise
"""
try:
token_manager = TokenManager()
# Validate the API key and get the user_id
api_key_store = ApiKeyStore.get_instance()
user_id = await api_key_store.validate_api_key(api_key)
validation_result = await api_key_store.validate_api_key(api_key)
if not user_id:
if not validation_result:
logger.warning('Invalid API key')
return None
user_id = validation_result.user_id
# Get the offline token for the user
offline_token = await token_manager.load_offline_token(user_id)
if not offline_token:

View File

View File

@@ -0,0 +1,331 @@
"""Unit tests for service API routes."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from server.routes.service import (
CreateUserApiKeyRequest,
delete_user_api_key,
get_or_create_api_key_for_user,
validate_service_api_key,
)
class TestValidateServiceApiKey:
"""Test cases for validate_service_api_key."""
@pytest.mark.asyncio
async def test_valid_service_key(self):
"""Test validation with valid service API key."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
result = await validate_service_api_key('test-service-key')
assert result == 'automations-service'
@pytest.mark.asyncio
async def test_missing_service_key(self):
"""Test validation with missing service API key header."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key(None)
assert exc_info.value.status_code == 401
assert 'X-Service-API-Key header is required' in exc_info.value.detail
@pytest.mark.asyncio
async def test_invalid_service_key(self):
"""Test validation with invalid service API key."""
with patch(
'server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-service-key'
):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key('wrong-key')
assert exc_info.value.status_code == 401
assert 'Invalid service API key' in exc_info.value.detail
@pytest.mark.asyncio
async def test_service_auth_not_configured(self):
"""Test validation when service auth is not configured."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', ''):
with pytest.raises(HTTPException) as exc_info:
await validate_service_api_key('any-key')
assert exc_info.value.status_code == 503
assert 'Service authentication not configured' in exc_info.value.detail
class TestCreateUserApiKeyRequest:
"""Test cases for CreateUserApiKeyRequest validation."""
def test_valid_request(self):
"""Test valid request with all fields."""
request = CreateUserApiKeyRequest(
name='automation',
)
assert request.name == 'automation'
def test_name_is_required(self):
"""Test that name field is required."""
with pytest.raises(ValueError):
CreateUserApiKeyRequest(
name='', # Empty name should fail
)
def test_name_is_stripped(self):
"""Test that name field is stripped of whitespace."""
request = CreateUserApiKeyRequest(
name=' automation ',
)
assert request.name == 'automation'
def test_whitespace_only_name_fails(self):
"""Test that whitespace-only name fails validation."""
with pytest.raises(ValueError):
CreateUserApiKeyRequest(
name=' ',
)
class TestGetOrCreateApiKeyForUser:
"""Test cases for get_or_create_api_key_for_user endpoint."""
@pytest.fixture
def valid_user_id(self):
"""Return a valid user ID."""
return '5594c7b6-f959-4b81-92e9-b09c206f5081'
@pytest.fixture
def valid_org_id(self):
"""Return a valid org ID."""
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
@pytest.fixture
def valid_request(self):
"""Create a valid request object."""
return CreateUserApiKeyRequest(
name='automation',
)
@pytest.mark.asyncio
async def test_user_not_found(self, valid_user_id, valid_org_id, valid_request):
"""Test error when user doesn't exist."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
mock_get_user.return_value = None
with pytest.raises(HTTPException) as exc_info:
await get_or_create_api_key_for_user(
user_id=valid_user_id,
org_id=valid_org_id,
request=valid_request,
x_service_api_key='test-key',
)
assert exc_info.value.status_code == 404
assert 'not found' in exc_info.value.detail
@pytest.mark.asyncio
async def test_user_not_in_org(self, valid_user_id, valid_org_id, valid_request):
"""Test error when user is not a member of the org."""
mock_user = MagicMock()
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
with patch(
'server.routes.service.OrgMemberStore.get_org_member',
new_callable=AsyncMock,
) as mock_get_member:
mock_get_user.return_value = mock_user
mock_get_member.return_value = None
with pytest.raises(HTTPException) as exc_info:
await get_or_create_api_key_for_user(
user_id=valid_user_id,
org_id=valid_org_id,
request=valid_request,
x_service_api_key='test-key',
)
assert exc_info.value.status_code == 403
assert 'not a member of org' in exc_info.value.detail
@pytest.mark.asyncio
async def test_successful_key_creation(
self, valid_user_id, valid_org_id, valid_request
):
"""Test successful API key creation."""
mock_user = MagicMock()
mock_org_member = MagicMock()
mock_api_key_store = MagicMock()
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
return_value='sk-oh-test-key-12345678901234567890'
)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
with patch(
'server.routes.service.OrgMemberStore.get_org_member',
new_callable=AsyncMock,
) as mock_get_member:
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
mock_get_user.return_value = mock_user
mock_get_member.return_value = mock_org_member
mock_get_store.return_value = mock_api_key_store
response = await get_or_create_api_key_for_user(
user_id=valid_user_id,
org_id=valid_org_id,
request=valid_request,
x_service_api_key='test-key',
)
assert response.key == 'sk-oh-test-key-12345678901234567890'
assert response.user_id == valid_user_id
assert response.org_id == str(valid_org_id)
assert response.name == 'automation'
# Verify the store was called with correct arguments
mock_api_key_store.get_or_create_system_api_key.assert_called_once_with(
user_id=valid_user_id,
org_id=valid_org_id,
name='automation',
)
@pytest.mark.asyncio
async def test_store_exception_handling(
self, valid_user_id, valid_org_id, valid_request
):
"""Test error handling when store raises exception."""
mock_user = MagicMock()
mock_org_member = MagicMock()
mock_api_key_store = MagicMock()
mock_api_key_store.get_or_create_system_api_key = AsyncMock(
side_effect=Exception('Database error')
)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
with patch(
'server.routes.service.OrgMemberStore.get_org_member',
new_callable=AsyncMock,
) as mock_get_member:
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
mock_get_user.return_value = mock_user
mock_get_member.return_value = mock_org_member
mock_get_store.return_value = mock_api_key_store
with pytest.raises(HTTPException) as exc_info:
await get_or_create_api_key_for_user(
user_id=valid_user_id,
org_id=valid_org_id,
request=valid_request,
x_service_api_key='test-key',
)
assert exc_info.value.status_code == 500
assert 'Failed to get or create API key' in exc_info.value.detail
class TestDeleteUserApiKey:
"""Test cases for delete_user_api_key endpoint."""
@pytest.fixture
def valid_org_id(self):
"""Return a valid org ID."""
return uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
@pytest.mark.asyncio
async def test_successful_delete(self, valid_org_id):
"""Test successful deletion of a system API key."""
mock_api_key_store = MagicMock()
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:automation'
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=True)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
mock_get_store.return_value = mock_api_key_store
response = await delete_user_api_key(
user_id='user-123',
org_id=valid_org_id,
key_name='automation',
x_service_api_key='test-key',
)
assert response == {'message': 'API key deleted successfully'}
# Verify the store was called with correct arguments
mock_api_key_store.make_system_key_name.assert_called_once_with('automation')
mock_api_key_store.delete_api_key_by_name.assert_called_once_with(
user_id='user-123',
org_id=valid_org_id,
name='__SYSTEM__:automation',
allow_system=True,
)
@pytest.mark.asyncio
async def test_delete_key_not_found(self, valid_org_id):
"""Test error when key to delete is not found."""
mock_api_key_store = MagicMock()
mock_api_key_store.make_system_key_name.return_value = '__SYSTEM__:nonexistent'
mock_api_key_store.delete_api_key_by_name = AsyncMock(return_value=False)
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with patch(
'server.routes.service.ApiKeyStore.get_instance'
) as mock_get_store:
mock_get_store.return_value = mock_api_key_store
with pytest.raises(HTTPException) as exc_info:
await delete_user_api_key(
user_id='user-123',
org_id=valid_org_id,
key_name='nonexistent',
x_service_api_key='test-key',
)
assert exc_info.value.status_code == 404
assert 'not found' in exc_info.value.detail
@pytest.mark.asyncio
async def test_delete_invalid_service_key(self, valid_org_id):
"""Test error when service API key is invalid."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with pytest.raises(HTTPException) as exc_info:
await delete_user_api_key(
user_id='user-123',
org_id=valid_org_id,
key_name='automation',
x_service_api_key='wrong-key',
)
assert exc_info.value.status_code == 401
assert 'Invalid service API key' in exc_info.value.detail
@pytest.mark.asyncio
async def test_delete_missing_service_key(self, valid_org_id):
"""Test error when service API key header is missing."""
with patch('server.routes.service.AUTOMATIONS_SERVICE_API_KEY', 'test-key'):
with pytest.raises(HTTPException) as exc_info:
await delete_user_api_key(
user_id='user-123',
org_id=valid_org_id,
key_name='automation',
x_service_api_key=None,
)
assert exc_info.value.status_code == 401
assert 'X-Service-API-Key header is required' in exc_info.value.detail

View File

@@ -1,19 +1,26 @@
"""Unit tests for API keys routes, focusing on BYOR key validation and retrieval."""
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from fastapi import HTTPException
from pydantic import SecretStr
from server.auth.saas_user_auth import SaasUserAuth
from server.routes.api_keys import (
ByorPermittedResponse,
CurrentApiKeyResponse,
LlmApiKeyResponse,
check_byor_permitted,
delete_byor_key_from_litellm,
get_current_api_key,
get_llm_api_key_for_byor,
)
from storage.lite_llm_manager import LiteLlmManager
from openhands.server.user_auth.user_auth import AuthType
class TestVerifyByorKeyInLitellm:
"""Test the verify_byor_key_in_litellm function."""
@@ -512,3 +519,81 @@ class TestCheckByorPermitted:
assert exc_info.value.status_code == 500
assert 'Failed to check BYOR export permission' in exc_info.value.detail
class TestGetCurrentApiKey:
"""Test the get_current_api_key endpoint."""
@pytest.mark.asyncio
@patch('server.routes.api_keys.get_user_auth')
async def test_returns_api_key_info_for_bearer_auth(self, mock_get_user_auth):
"""Test that API key metadata including org_id is returned for bearer token auth."""
# Arrange
user_id = 'user-123'
org_id = uuid.uuid4()
mock_request = MagicMock()
user_auth = SaasUserAuth(
refresh_token=SecretStr('mock-token'),
user_id=user_id,
auth_type=AuthType.BEARER,
api_key_org_id=org_id,
api_key_id=42,
api_key_name='My Production Key',
)
mock_get_user_auth.return_value = user_auth
# Act
result = await get_current_api_key(request=mock_request, user_id=user_id)
# Assert
assert isinstance(result, CurrentApiKeyResponse)
assert result.org_id == str(org_id)
assert result.id == 42
assert result.name == 'My Production Key'
assert result.user_id == user_id
assert result.auth_type == 'bearer'
@pytest.mark.asyncio
@patch('server.routes.api_keys.get_user_auth')
async def test_returns_400_for_cookie_auth(self, mock_get_user_auth):
"""Test that 400 Bad Request is returned when using cookie authentication."""
# Arrange
user_id = 'user-123'
mock_request = MagicMock()
mock_user_auth = MagicMock()
mock_user_auth.get_auth_type.return_value = AuthType.COOKIE
mock_get_user_auth.return_value = mock_user_auth
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_current_api_key(request=mock_request, user_id=user_id)
assert exc_info.value.status_code == 400
assert 'API key authentication' in exc_info.value.detail
@pytest.mark.asyncio
@patch('server.routes.api_keys.get_user_auth')
async def test_returns_400_when_api_key_org_id_is_none(self, mock_get_user_auth):
"""Test that 400 is returned when API key has no org_id (legacy key)."""
# Arrange
user_id = 'user-123'
mock_request = MagicMock()
user_auth = SaasUserAuth(
refresh_token=SecretStr('mock-token'),
user_id=user_id,
auth_type=AuthType.BEARER,
api_key_org_id=None, # No org_id - legacy key
api_key_id=42,
api_key_name='Legacy Key',
)
mock_get_user_auth.return_value = user_auth
# Act & Assert
with pytest.raises(HTTPException) as exc_info:
await get_current_api_key(request=mock_request, user_id=user_id)
assert exc_info.value.status_code == 400
assert 'created before organization support' in exc_info.value.detail

View File

@@ -0,0 +1,314 @@
"""Unit tests for ApiKeyStore system key functionality."""
import uuid
from datetime import UTC, datetime, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
@pytest.fixture
def api_key_store():
"""Create ApiKeyStore instance."""
return ApiKeyStore()
class TestApiKeyStoreSystemKeys:
"""Test cases for system API key functionality."""
def test_is_system_key_name_with_prefix(self, api_key_store):
"""Test that names with __SYSTEM__: prefix are identified as system keys."""
assert api_key_store.is_system_key_name('__SYSTEM__:automation') is True
assert api_key_store.is_system_key_name('__SYSTEM__:test-key') is True
assert api_key_store.is_system_key_name('__SYSTEM__:') is True
def test_is_system_key_name_without_prefix(self, api_key_store):
"""Test that names without __SYSTEM__: prefix are not system keys."""
assert api_key_store.is_system_key_name('my-key') is False
assert api_key_store.is_system_key_name('automation') is False
assert api_key_store.is_system_key_name('MCP_API_KEY') is False
assert api_key_store.is_system_key_name('') is False
def test_is_system_key_name_none(self, api_key_store):
"""Test that None is not a system key."""
assert api_key_store.is_system_key_name(None) is False
def test_make_system_key_name(self, api_key_store):
"""Test system key name generation."""
assert (
api_key_store.make_system_key_name('automation') == '__SYSTEM__:automation'
)
assert api_key_store.make_system_key_name('test-key') == '__SYSTEM__:test-key'
@pytest.mark.asyncio
async def test_get_or_create_system_api_key_creates_new(
self, api_key_store, async_session_maker
):
"""Test creating a new system API key when none exists."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
key_name = 'automation'
with patch('storage.api_key_store.a_session_maker', async_session_maker):
api_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=key_name,
)
assert api_key.startswith('sk-oh-')
assert len(api_key) == len('sk-oh-') + 32
# Verify the key was created in the database
async with async_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.key == api_key))
key_record = result.scalars().first()
assert key_record is not None
assert key_record.user_id == user_id
assert key_record.org_id == org_id
assert key_record.name == '__SYSTEM__:automation'
assert key_record.expires_at is None # System keys never expire
@pytest.mark.asyncio
async def test_get_or_create_system_api_key_returns_existing(
self, api_key_store, async_session_maker
):
"""Test that existing valid system key is returned."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
key_name = 'automation'
with patch('storage.api_key_store.a_session_maker', async_session_maker):
# Create the first key
first_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=key_name,
)
# Request again - should return the same key
second_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=key_name,
)
assert first_key == second_key
@pytest.mark.asyncio
async def test_get_or_create_system_api_key_different_names(
self, api_key_store, async_session_maker
):
"""Test that different names create different keys."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
with patch('storage.api_key_store.a_session_maker', async_session_maker):
key1 = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name='automation-1',
)
key2 = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name='automation-2',
)
assert key1 != key2
@pytest.mark.asyncio
async def test_get_or_create_system_api_key_reissues_expired(
self, api_key_store, async_session_maker
):
"""Test that expired system key is replaced with a new one."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
key_name = 'automation'
system_key_name = '__SYSTEM__:automation'
# First, manually create an expired key
expired_time = datetime.now(UTC) - timedelta(hours=1)
async with async_session_maker() as session:
expired_key = ApiKey(
key='sk-oh-expired-key-12345678901234567890',
user_id=user_id,
org_id=org_id,
name=system_key_name,
expires_at=expired_time.replace(tzinfo=None),
)
session.add(expired_key)
await session.commit()
with patch('storage.api_key_store.a_session_maker', async_session_maker):
# Request the key - should create a new one
new_key = await api_key_store.get_or_create_system_api_key(
user_id=user_id,
org_id=org_id,
name=key_name,
)
assert new_key != 'sk-oh-expired-key-12345678901234567890'
assert new_key.startswith('sk-oh-')
# Verify old key was deleted and new key exists
async with async_session_maker() as session:
result = await session.execute(
select(ApiKey).filter(ApiKey.name == system_key_name)
)
keys = result.scalars().all()
assert len(keys) == 1
assert keys[0].key == new_key
assert keys[0].expires_at is None
@pytest.mark.asyncio
async def test_list_api_keys_excludes_system_keys(
self, api_key_store, async_session_maker
):
"""Test that list_api_keys excludes system keys."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
# Create a user key and a system key
async with async_session_maker() as session:
user_key = ApiKey(
key='sk-oh-user-key-123456789012345678901',
user_id=user_id,
org_id=org_id,
name='my-user-key',
)
system_key = ApiKey(
key='sk-oh-system-key-12345678901234567890',
user_id=user_id,
org_id=org_id,
name='__SYSTEM__:automation',
)
mcp_key = ApiKey(
key='sk-oh-mcp-key-1234567890123456789012',
user_id=user_id,
org_id=org_id,
name='MCP_API_KEY',
)
session.add(user_key)
session.add(system_key)
session.add(mcp_key)
await session.commit()
# Mock UserStore.get_user_by_id to return a user with the correct org
mock_user = MagicMock()
mock_user.current_org_id = org_id
with patch('storage.api_key_store.a_session_maker', async_session_maker):
with patch(
'storage.api_key_store.UserStore.get_user_by_id', new_callable=AsyncMock
) as mock_get_user:
mock_get_user.return_value = mock_user
keys = await api_key_store.list_api_keys(user_id)
# Should only return the user key
assert len(keys) == 1
assert keys[0].name == 'my-user-key'
@pytest.mark.asyncio
async def test_delete_api_key_by_id_protects_system_keys(
self, api_key_store, async_session_maker
):
"""Test that system keys cannot be deleted by users."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
# Create a system key
async with async_session_maker() as session:
system_key = ApiKey(
key='sk-oh-system-key-12345678901234567890',
user_id=user_id,
org_id=org_id,
name='__SYSTEM__:automation',
)
session.add(system_key)
await session.commit()
key_id = system_key.id
with patch('storage.api_key_store.a_session_maker', async_session_maker):
# Attempt to delete without allow_system flag
result = await api_key_store.delete_api_key_by_id(
key_id, allow_system=False
)
assert result is False
# Verify the key still exists
async with async_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
assert key_record is not None
@pytest.mark.asyncio
async def test_delete_api_key_by_id_allows_system_with_flag(
self, api_key_store, async_session_maker
):
"""Test that system keys can be deleted with allow_system=True."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
# Create a system key
async with async_session_maker() as session:
system_key = ApiKey(
key='sk-oh-system-key-12345678901234567890',
user_id=user_id,
org_id=org_id,
name='__SYSTEM__:automation',
)
session.add(system_key)
await session.commit()
key_id = system_key.id
with patch('storage.api_key_store.a_session_maker', async_session_maker):
# Delete with allow_system=True
result = await api_key_store.delete_api_key_by_id(key_id, allow_system=True)
assert result is True
# Verify the key was deleted
async with async_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
assert key_record is None
@pytest.mark.asyncio
async def test_delete_api_key_by_id_allows_regular_keys(
self, api_key_store, async_session_maker
):
"""Test that regular keys can be deleted normally."""
user_id = '5594c7b6-f959-4b81-92e9-b09c206f5081'
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
# Create a regular key
async with async_session_maker() as session:
regular_key = ApiKey(
key='sk-oh-regular-key-1234567890123456789',
user_id=user_id,
org_id=org_id,
name='my-regular-key',
)
session.add(regular_key)
await session.commit()
key_id = regular_key.id
with patch('storage.api_key_store.a_session_maker', async_session_maker):
# Delete without allow_system flag - should work for regular keys
result = await api_key_store.delete_api_key_by_id(
key_id, allow_system=False
)
assert result is True
# Verify the key was deleted
async with async_session_maker() as session:
result = await session.execute(select(ApiKey).filter(ApiKey.id == key_id))
key_record = result.scalars().first()
assert key_record is None

View File

@@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from sqlalchemy import select
from storage.api_key import ApiKey
from storage.api_key_store import ApiKeyStore
from storage.api_key_store import ApiKeyStore, ApiKeyValidationResult
@pytest.fixture
@@ -110,8 +110,8 @@ async def test_create_api_key(
@pytest.mark.asyncio
async def test_validate_api_key_valid(api_key_store, async_session_maker):
"""Test validating a valid API key."""
# Setup - create an API key in the database
"""Test validating a valid API key returns user_id and org_id."""
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-api-key'
@@ -126,13 +126,19 @@ async def test_validate_api_key_valid(api_key_store, async_session_maker):
)
session.add(key_record)
await session.commit()
key_id = key_record.id
# Execute - patch a_session_maker to use test's async session maker
# Act
with patch('storage.api_key_store.a_session_maker', async_session_maker):
result = await api_key_store.validate_api_key(api_key_value)
# Verify
assert result == user_id
# Assert
assert isinstance(result, ApiKeyValidationResult)
assert result is not None
assert result.user_id == user_id
assert result.org_id == org_id
assert result.key_id == key_id
assert result.key_name == 'Test Key'
@pytest.mark.asyncio
@@ -197,7 +203,7 @@ async def test_validate_api_key_valid_timezone_naive(
api_key_store, async_session_maker
):
"""Test validating a valid API key with timezone-naive datetime from database."""
# Setup - create a valid API key with timezone-naive datetime (future date)
# Arrange
user_id = str(uuid.uuid4())
org_id = uuid.uuid4()
api_key_value = 'test-valid-naive-key'
@@ -214,12 +220,44 @@ async def test_validate_api_key_valid_timezone_naive(
session.add(key_record)
await session.commit()
# Execute - patch a_session_maker to use test's async session maker
# Act
with patch('storage.api_key_store.a_session_maker', async_session_maker):
result = await api_key_store.validate_api_key(api_key_value)
# Verify
assert result == user_id
# Assert
assert isinstance(result, ApiKeyValidationResult)
assert result.user_id == user_id
assert result.org_id == org_id
@pytest.mark.asyncio
async def test_validate_api_key_legacy_without_org_id(
api_key_store, async_session_maker
):
"""Test validating a legacy API key without org_id returns None for org_id."""
# Arrange
user_id = str(uuid.uuid4())
api_key_value = 'test-legacy-key-no-org'
async with async_session_maker() as session:
key_record = ApiKey(
key=api_key_value,
user_id=user_id,
org_id=None, # Legacy key without org binding
name='Legacy Key',
)
session.add(key_record)
await session.commit()
# Act
with patch('storage.api_key_store.a_session_maker', async_session_maker):
result = await api_key_store.validate_api_key(api_key_value)
# Assert
assert isinstance(result, ApiKeyValidationResult)
assert result is not None
assert result.user_id == user_id
assert result.org_id is None
@pytest.mark.asyncio

View File

@@ -13,6 +13,7 @@ from server.auth.authorization import (
ROLE_PERMISSIONS,
Permission,
RoleName,
get_api_key_org_id_from_request,
get_role_permissions,
get_user_org_role,
has_permission,
@@ -444,6 +445,15 @@ class TestGetUserOrgRole:
# =============================================================================
def _create_mock_request(api_key_org_id=None):
"""Helper to create a mock request with optional api_key_org_id."""
mock_request = MagicMock()
mock_user_auth = MagicMock()
mock_user_auth.get_api_key_org_id.return_value = api_key_org_id
mock_request.state.user_auth = mock_user_auth
return mock_request
class TestRequirePermission:
"""Tests for require_permission dependency factory."""
@@ -456,6 +466,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -465,7 +476,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -476,10 +489,11 @@ class TestRequirePermission:
THEN: 401 Unauthorized is raised
"""
org_id = uuid4()
mock_request = _create_mock_request()
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=None)
await permission_checker(request=mock_request, org_id=org_id, user_id=None)
assert exc_info.value.status_code == 401
assert 'not authenticated' in exc_info.value.detail.lower()
@@ -493,6 +507,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
with patch(
'server.auth.authorization.get_user_org_role',
@@ -500,7 +515,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail.lower()
@@ -514,6 +531,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -524,7 +542,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'delete_organization' in exc_info.value.detail.lower()
@@ -538,6 +558,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -547,7 +568,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -559,6 +582,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -569,7 +593,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -582,6 +608,7 @@ class TestRequirePermission:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -595,7 +622,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.DELETE_ORGANIZATION)
with pytest.raises(HTTPException):
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
@@ -611,6 +640,7 @@ class TestRequirePermission:
THEN: User ID is returned
"""
user_id = str(uuid4())
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -620,7 +650,9 @@ class TestRequirePermission:
AsyncMock(return_value=mock_role),
) as mock_get_role:
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(org_id=None, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
assert result == user_id
mock_get_role.assert_called_once_with(user_id, None)
@@ -632,6 +664,7 @@ class TestRequirePermission:
THEN: HTTPException with 403 status is raised
"""
user_id = str(uuid4())
mock_request = _create_mock_request()
with patch(
'server.auth.authorization.get_user_org_role',
@@ -639,7 +672,9 @@ class TestRequirePermission:
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=None, user_id=user_id)
await permission_checker(
request=mock_request, org_id=None, user_id=user_id
)
assert exc_info.value.status_code == 403
assert 'not a member' in exc_info.value.detail
@@ -662,6 +697,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -671,7 +707,9 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.MANAGE_SECRETS)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -683,6 +721,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'member'
@@ -695,7 +734,9 @@ class TestPermissionScenarios:
Permission.INVITE_USER_TO_ORGANIZATION
)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -708,6 +749,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -719,7 +761,9 @@ class TestPermissionScenarios:
permission_checker = require_permission(
Permission.INVITE_USER_TO_ORGANIZATION
)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
@@ -731,6 +775,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'admin'
@@ -741,7 +786,9 @@ class TestPermissionScenarios:
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(org_id=org_id, user_id=user_id)
await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
@@ -754,6 +801,7 @@ class TestPermissionScenarios:
"""
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request()
mock_role = MagicMock()
mock_role.name = 'owner'
@@ -763,5 +811,200 @@ class TestPermissionScenarios:
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.CHANGE_USER_ROLE_OWNER)
result = await permission_checker(org_id=org_id, user_id=user_id)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
# =============================================================================
# Tests for API key organization validation
# =============================================================================
class TestApiKeyOrgValidation:
"""Tests for API key organization binding validation in require_permission."""
@pytest.mark.asyncio
async def test_allows_access_when_api_key_org_matches_target_org(self):
"""
GIVEN: API key with org_id that matches the target org_id in the request
WHEN: Permission checker is called
THEN: User ID is returned (access allowed)
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=org_id)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_denies_access_when_api_key_org_mismatches_target_org(self):
"""
GIVEN: API key created for Org A, but user tries to access Org B
WHEN: Permission checker is called
THEN: 403 Forbidden is raised with org mismatch message
"""
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4() # Org A - where API key was created
target_org_id = uuid4() # Org B - where user is trying to access
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
# Act & Assert
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException) as exc_info:
await permission_checker(
request=mock_request, org_id=target_org_id, user_id=user_id
)
assert exc_info.value.status_code == 403
assert (
'API key is not authorized for this organization' in exc_info.value.detail
)
@pytest.mark.asyncio
async def test_allows_access_for_legacy_api_key_without_org_binding(self):
"""
GIVEN: Legacy API key without org_id binding (org_id is None)
WHEN: Permission checker is called
THEN: Falls through to normal permission check (backward compatible)
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=None)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_allows_access_for_cookie_auth_without_api_key_org_id(self):
"""
GIVEN: Cookie-based authentication (no api_key_org_id in user_auth)
WHEN: Permission checker is called
THEN: Falls through to normal permission check
"""
# Arrange
user_id = str(uuid4())
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=None)
mock_role = MagicMock()
mock_role.name = 'admin'
# Act & Assert
with patch(
'server.auth.authorization.get_user_org_role',
AsyncMock(return_value=mock_role),
):
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
result = await permission_checker(
request=mock_request, org_id=org_id, user_id=user_id
)
assert result == user_id
@pytest.mark.asyncio
async def test_logs_warning_on_api_key_org_mismatch(self):
"""
GIVEN: API key org_id doesn't match target org_id
WHEN: Permission checker is called
THEN: Warning is logged with org mismatch details
"""
# Arrange
user_id = str(uuid4())
api_key_org_id = uuid4()
target_org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=api_key_org_id)
# Act & Assert
with patch('server.auth.authorization.logger') as mock_logger:
permission_checker = require_permission(Permission.VIEW_LLM_SETTINGS)
with pytest.raises(HTTPException):
await permission_checker(
request=mock_request, org_id=target_org_id, user_id=user_id
)
mock_logger.warning.assert_called()
call_args = mock_logger.warning.call_args
assert call_args[1]['extra']['user_id'] == user_id
assert call_args[1]['extra']['api_key_org_id'] == str(api_key_org_id)
assert call_args[1]['extra']['target_org_id'] == str(target_org_id)
class TestGetApiKeyOrgIdFromRequest:
"""Tests for get_api_key_org_id_from_request helper function."""
@pytest.mark.asyncio
async def test_returns_org_id_when_user_auth_has_api_key_org_id(self):
"""
GIVEN: Request with user_auth that has api_key_org_id
WHEN: get_api_key_org_id_from_request is called
THEN: Returns the api_key_org_id
"""
# Arrange
org_id = uuid4()
mock_request = _create_mock_request(api_key_org_id=org_id)
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result == org_id
@pytest.mark.asyncio
async def test_returns_none_when_user_auth_has_no_api_key_org_id(self):
"""
GIVEN: Request with user_auth that has no api_key_org_id (cookie auth)
WHEN: get_api_key_org_id_from_request is called
THEN: Returns None
"""
# Arrange
mock_request = _create_mock_request(api_key_org_id=None)
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result is None
@pytest.mark.asyncio
async def test_returns_none_when_no_user_auth_in_request(self):
"""
GIVEN: Request without user_auth in state
WHEN: get_api_key_org_id_from_request is called
THEN: Returns None
"""
# Arrange
mock_request = MagicMock()
mock_request.state.user_auth = None
# Act
result = await get_api_key_org_id_from_request(mock_request)
# Assert
assert result is None

View File

@@ -38,8 +38,9 @@ class TestDefaultInitialBudget:
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Clear the env var
# Clear the env vars
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
os.environ.pop('ENABLE_BILLING', None)
# Restore original module or reimport fresh
if original_module is not None:
@@ -47,31 +48,56 @@ class TestDefaultInitialBudget:
else:
importlib.import_module('storage.lite_llm_manager')
def test_default_initial_budget_defaults_to_zero(self):
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when env var not set."""
def test_default_initial_budget_none_when_billing_disabled(self):
"""Test that DEFAULT_INITIAL_BUDGET is None when billing is disabled."""
# Temporarily remove the module so we can reimport with different env vars
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Clear the env var and reimport
# Ensure billing is disabled (default) and reimport
os.environ.pop('ENABLE_BILLING', None)
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET is None
def test_default_initial_budget_defaults_to_zero_when_billing_enabled(self):
"""Test that DEFAULT_INITIAL_BUDGET defaults to 0.0 when billing is enabled."""
# Temporarily remove the module so we can reimport with different env vars
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
# Enable billing and reimport
os.environ['ENABLE_BILLING'] = 'true'
os.environ.pop('DEFAULT_INITIAL_BUDGET', None)
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET == 0.0
def test_default_initial_budget_uses_env_var(self):
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable."""
def test_default_initial_budget_uses_env_var_when_billing_enabled(self):
"""Test that DEFAULT_INITIAL_BUDGET uses value from environment variable when billing enabled."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['ENABLE_BILLING'] = 'true'
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET == 100.0
def test_default_initial_budget_ignores_env_var_when_billing_disabled(self):
"""Test that DEFAULT_INITIAL_BUDGET returns None when billing disabled, ignoring env var."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ.pop('ENABLE_BILLING', None) # billing disabled by default
os.environ['DEFAULT_INITIAL_BUDGET'] = '100.0'
module = importlib.import_module('storage.lite_llm_manager')
assert module.DEFAULT_INITIAL_BUDGET is None
def test_default_initial_budget_rejects_invalid_value(self):
"""Test that DEFAULT_INITIAL_BUDGET raises ValueError for invalid values."""
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['ENABLE_BILLING'] = 'true'
os.environ['DEFAULT_INITIAL_BUDGET'] = 'abc'
with pytest.raises(ValueError) as exc_info:
importlib.import_module('storage.lite_llm_manager')
@@ -82,6 +108,7 @@ class TestDefaultInitialBudget:
if 'storage.lite_llm_manager' in sys.modules:
del sys.modules['storage.lite_llm_manager']
os.environ['ENABLE_BILLING'] = 'true'
os.environ['DEFAULT_INITIAL_BUDGET'] = '-10.0'
with pytest.raises(ValueError) as exc_info:
importlib.import_module('storage.lite_llm_manager')
@@ -212,6 +239,16 @@ class TestLiteLlmManager:
mock_404_response = MagicMock()
mock_404_response.status_code = 404
mock_404_response.is_success = False
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
# Mock user exists check response
mock_user_exists_response = MagicMock()
mock_user_exists_response.is_success = True
mock_user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_token_manager = MagicMock()
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
@@ -219,12 +256,8 @@ class TestLiteLlmManager:
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_404_response
mock_client.get.return_value.raise_for_status.side_effect = (
httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
)
# First GET is for _get_team (404), second GET is for _user_exists (success)
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -247,8 +280,8 @@ class TestLiteLlmManager:
assert result.llm_api_key.get_secret_value() == 'test-api-key'
assert result.llm_base_url == 'http://test.com'
# Verify API calls were made (get_team + 4 posts)
assert mock_client.get.call_count == 1 # get_team
# Verify API calls were made (get_team + user_exists + 4 posts)
assert mock_client.get.call_count == 2 # get_team + user_exists
assert (
mock_client.post.call_count == 4
) # create_team, add_user_to_team, delete_key_by_alias, generate_key
@@ -267,13 +300,21 @@ class TestLiteLlmManager:
}
mock_team_response.raise_for_status = MagicMock()
# Mock user exists check response
mock_user_exists_response = MagicMock()
mock_user_exists_response.is_success = True
mock_user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_token_manager = MagicMock()
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
return_value={'email': 'test@example.com'}
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_team_response
# First GET is for _get_team (success), second GET is for _user_exists (success)
mock_client.get.side_effect = [mock_team_response, mock_user_exists_response]
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -293,8 +334,8 @@ class TestLiteLlmManager:
assert result is not None
# Verify _get_team was called first
mock_client.get.assert_called_once()
get_call_url = mock_client.get.call_args[0][0]
assert mock_client.get.call_count == 2 # get_team + user_exists
get_call_url = mock_client.get.call_args_list[0][0][0]
assert 'team/info' in get_call_url
assert 'test-org-id' in get_call_url
@@ -316,19 +357,25 @@ class TestLiteLlmManager:
mock_404_response = MagicMock()
mock_404_response.status_code = 404
mock_404_response.is_success = False
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
mock_token_manager = MagicMock()
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
return_value={'email': 'test@example.com'}
)
# Mock user exists check response
mock_user_exists_response = MagicMock()
mock_user_exists_response.is_success = True
mock_user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_404_response
mock_client.get.return_value.raise_for_status.side_effect = (
httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
)
# First GET is for _get_team (404), second GET is for _user_exists (success)
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -366,6 +413,16 @@ class TestLiteLlmManager:
mock_404_response = MagicMock()
mock_404_response.status_code = 404
mock_404_response.is_success = False
mock_404_response.raise_for_status.side_effect = httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
# Mock user exists check response
mock_user_exists_response = MagicMock()
mock_user_exists_response.is_success = True
mock_user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_token_manager = MagicMock()
mock_token_manager.return_value.get_user_info_from_user_id = AsyncMock(
@@ -373,12 +430,8 @@ class TestLiteLlmManager:
)
mock_client = AsyncMock()
mock_client.get.return_value = mock_404_response
mock_client.get.return_value.raise_for_status.side_effect = (
httpx.HTTPStatusError(
message='Not Found', request=MagicMock(), response=mock_404_response
)
)
# First GET is for _get_team (404), second GET is for _user_exists (success)
mock_client.get.side_effect = [mock_404_response, mock_user_exists_response]
mock_client.post.return_value = mock_response
mock_client_class = MagicMock()
@@ -806,15 +859,16 @@ class TestLiteLlmManager:
@pytest.mark.asyncio
async def test_create_user_success(self, mock_http_client, mock_response):
"""Test successful _create_user operation."""
"""Test successful _create_user operation returns True."""
mock_http_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_user(
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
assert result is True
mock_http_client.post.assert_called_once()
call_args = mock_http_client.post.call_args
assert 'http://test.com/user/new' in call_args[0]
@@ -823,7 +877,7 @@ class TestLiteLlmManager:
@pytest.mark.asyncio
async def test_create_user_duplicate_email(self, mock_http_client, mock_response):
"""Test _create_user with duplicate email handling."""
"""Test _create_user with duplicate email handling returns True."""
# First call fails with duplicate email
error_response = MagicMock()
error_response.is_success = False
@@ -835,23 +889,81 @@ class TestLiteLlmManager:
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_user(
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
assert result is True
assert mock_http_client.post.call_count == 2
# Second call should have None email
second_call_args = mock_http_client.post.call_args_list[1]
assert second_call_args[1]['json']['user_email'] is None
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_user_exists_returns_true(self, mock_http_client):
"""Test _user_exists returns True when user exists in LiteLLM."""
# Arrange
user_response = MagicMock()
user_response.is_success = True
user_response.json.return_value = {
'user_info': {'user_id': 'test-user-id', 'email': 'test@example.com'}
}
mock_http_client.get.return_value = user_response
# Act
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
# Assert
assert result is True
mock_http_client.get.assert_called_once()
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_user_exists_returns_false_when_not_found(self, mock_http_client):
"""Test _user_exists returns False when user not found."""
# Arrange
user_response = MagicMock()
user_response.is_success = False
mock_http_client.get.return_value = user_response
# Act
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
# Assert
assert result is False
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_user_exists_returns_false_on_mismatched_user_id(
self, mock_http_client
):
"""Test _user_exists returns False when returned user_id doesn't match."""
# Arrange
user_response = MagicMock()
user_response.is_success = True
user_response.json.return_value = {
'user_info': {'user_id': 'different-user-id'}
}
mock_http_client.get.return_value = user_response
# Act
result = await LiteLlmManager._user_exists(mock_http_client, 'test-user-id')
# Assert
assert result is False
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.logger')
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_create_user_already_exists_with_409_status_code(
async def test_create_user_already_exists_and_verified(
self, mock_logger, mock_http_client
):
"""Test _create_user handles 409 Conflict when user already exists."""
"""Test _create_user returns True when user already exists and is verified."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
@@ -863,14 +975,141 @@ class TestLiteLlmManager:
second_response.status_code = 409
second_response.text = 'User with id test-user-id already exists'
user_exists_response = MagicMock()
user_exists_response.is_success = True
user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_http_client.post.side_effect = [first_response, second_response]
mock_http_client.get.return_value = user_exists_response
# Act
await LiteLlmManager._create_user(
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
# Assert
assert result is True
mock_logger.warning.assert_any_call(
'litellm_user_already_exists',
extra={'user_id': 'test-user-id'},
)
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.logger')
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_create_user_already_exists_but_not_found_returns_false(
self, mock_logger, mock_http_client
):
"""Test _create_user returns False when LiteLLM claims user exists but verification fails."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
first_response.status_code = 400
first_response.text = 'duplicate email'
second_response = MagicMock()
second_response.is_success = False
second_response.status_code = 409
second_response.text = 'User with id test-user-id already exists'
user_not_exists_response = MagicMock()
user_not_exists_response.is_success = False
mock_http_client.post.side_effect = [first_response, second_response]
mock_http_client.get.return_value = user_not_exists_response
# Act
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
# Assert
assert result is False
mock_logger.error.assert_any_call(
'litellm_user_claimed_exists_but_not_found',
extra={
'user_id': 'test-user-id',
'status_code': 409,
'text': 'User with id test-user-id already exists',
},
)
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.logger')
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_create_user_failure_returns_false(
self, mock_logger, mock_http_client
):
"""Test _create_user returns False when creation fails with non-'already exists' error."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
first_response.status_code = 400
first_response.text = 'duplicate email'
second_response = MagicMock()
second_response.is_success = False
second_response.status_code = 500
second_response.text = 'Internal server error'
mock_http_client.post.side_effect = [first_response, second_response]
# Act
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
# Assert
assert result is False
mock_logger.error.assert_any_call(
'error_creating_litellm_user',
extra={
'status_code': 500,
'text': 'Internal server error',
'user_id': 'test-user-id',
'email': None,
},
)
@pytest.mark.asyncio
@patch('storage.lite_llm_manager.logger')
@patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com')
@patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-key')
async def test_create_user_already_exists_with_409_status_code(
self, mock_logger, mock_http_client
):
"""Test _create_user handles 409 Conflict when user already exists and verifies."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
first_response.status_code = 400
first_response.text = 'duplicate email'
second_response = MagicMock()
second_response.is_success = False
second_response.status_code = 409
second_response.text = 'User with id test-user-id already exists'
user_exists_response = MagicMock()
user_exists_response.is_success = True
user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_http_client.post.side_effect = [first_response, second_response]
mock_http_client.get.return_value = user_exists_response
# Act
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
# Assert
assert result is True
mock_logger.warning.assert_any_call(
'litellm_user_already_exists',
extra={'user_id': 'test-user-id'},
@@ -883,7 +1122,7 @@ class TestLiteLlmManager:
async def test_create_user_already_exists_with_400_status_code(
self, mock_logger, mock_http_client
):
"""Test _create_user handles 400 Bad Request when user already exists."""
"""Test _create_user handles 400 Bad Request when user already exists and verifies."""
# Arrange
first_response = MagicMock()
first_response.is_success = False
@@ -895,14 +1134,22 @@ class TestLiteLlmManager:
second_response.status_code = 400
second_response.text = 'User already exists'
user_exists_response = MagicMock()
user_exists_response.is_success = True
user_exists_response.json.return_value = {
'user_info': {'user_id': 'test-user-id'}
}
mock_http_client.post.side_effect = [first_response, second_response]
mock_http_client.get.return_value = user_exists_response
# Act
await LiteLlmManager._create_user(
result = await LiteLlmManager._create_user(
mock_http_client, 'test@example.com', 'test-user-id'
)
# Assert
assert result is True
mock_logger.warning.assert_any_call(
'litellm_user_already_exists',
extra={'user_id': 'test-user-id'},
@@ -2137,3 +2384,195 @@ class TestVerifyExistingKey:
openhands_type=True,
)
assert result is False
class TestBudgetPayloadHandling:
"""Test cases for budget field handling in API payloads.
These tests verify that when max_budget is None, the budget field is NOT
included in the JSON payload (which tells LiteLLM to disable budget
enforcement), and when max_budget has a value, it IS included.
"""
@pytest.mark.asyncio
async def test_create_team_excludes_max_budget_when_none(self):
"""Test that _create_team does NOT include max_budget when it is None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_team(
mock_client,
team_alias='test-team',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/new'
# Verify that max_budget is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget' not in json_payload, (
'max_budget should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_create_team_includes_max_budget_when_set(self):
"""Test that _create_team includes max_budget when it has a value."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._create_team(
mock_client,
team_alias='test-team',
team_id='test-team-id',
max_budget=100.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget IS in the JSON payload with the correct value
json_payload = call_args[1]['json']
assert (
'max_budget' in json_payload
), 'max_budget should be in payload when set to a value'
assert json_payload['max_budget'] == 100.0
@pytest.mark.asyncio
async def test_add_user_to_team_excludes_max_budget_when_none(self):
"""Test that _add_user_to_team does NOT include max_budget_in_team when None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._add_user_to_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/member_add'
# Verify that max_budget_in_team is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget_in_team' not in json_payload, (
'max_budget_in_team should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_add_user_to_team_includes_max_budget_when_set(self):
"""Test that _add_user_to_team includes max_budget_in_team when set."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._add_user_to_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=50.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget_in_team IS in the JSON payload
json_payload = call_args[1]['json']
assert (
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 50.0
@pytest.mark.asyncio
async def test_update_user_in_team_excludes_max_budget_when_none(self):
"""Test that _update_user_in_team does NOT include max_budget_in_team when None."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._update_user_in_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=None, # None = no budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify URL
assert call_args[0][0] == 'http://test.com/team/member_update'
# Verify that max_budget_in_team is NOT in the JSON payload
json_payload = call_args[1]['json']
assert 'max_budget_in_team' not in json_payload, (
'max_budget_in_team should NOT be in payload when None '
'(omitting it tells LiteLLM to disable budget enforcement)'
)
@pytest.mark.asyncio
async def test_update_user_in_team_includes_max_budget_when_set(self):
"""Test that _update_user_in_team includes max_budget_in_team when set."""
mock_client = AsyncMock(spec=httpx.AsyncClient)
mock_response = MagicMock()
mock_response.is_success = True
mock_response.status_code = 200
mock_client.post.return_value = mock_response
with patch('storage.lite_llm_manager.LITE_LLM_API_KEY', 'test-api-key'):
with patch('storage.lite_llm_manager.LITE_LLM_API_URL', 'http://test.com'):
await LiteLlmManager._update_user_in_team(
mock_client,
keycloak_user_id='test-user-id',
team_id='test-team-id',
max_budget=75.0, # Explicit budget limit
)
# Verify the call was made
mock_client.post.assert_called_once()
call_args = mock_client.post.call_args
# Verify that max_budget_in_team IS in the JSON payload
json_payload = call_args[1]['json']
assert (
'max_budget_in_team' in json_payload
), 'max_budget_in_team should be in payload when set to a value'
assert json_payload['max_budget_in_team'] == 75.0

View File

@@ -1,4 +1,5 @@
import time
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import jwt
@@ -18,6 +19,7 @@ from server.auth.saas_user_auth import (
saas_user_auth_from_cookie,
saas_user_auth_from_signed_token,
)
from storage.api_key_store import ApiKeyValidationResult
from storage.user_authorization import UserAuthorizationType
from openhands.integrations.provider import ProviderToken, ProviderType
@@ -457,7 +459,8 @@ async def test_get_instance_no_auth(mock_request):
@pytest.mark.asyncio
async def test_saas_user_auth_from_bearer_success():
"""Test successful authentication from bearer token."""
"""Test successful authentication from bearer token sets user_id and api_key_org_id."""
# Arrange
mock_request = MagicMock()
mock_request.headers = {'Authorization': 'Bearer test_api_key'}
@@ -468,12 +471,22 @@ async def test_saas_user_auth_from_bearer_success():
algorithm='HS256',
)
mock_org_id = uuid.uuid4()
mock_validation_result = ApiKeyValidationResult(
user_id='test_user_id',
org_id=mock_org_id,
key_id=42,
key_name='Test Key',
)
with (
patch('server.auth.saas_user_auth.ApiKeyStore') as mock_api_key_store_cls,
patch('server.auth.saas_user_auth.token_manager') as mock_token_manager,
):
mock_api_key_store = MagicMock()
mock_api_key_store.validate_api_key = AsyncMock(return_value='test_user_id')
mock_api_key_store.validate_api_key = AsyncMock(
return_value=mock_validation_result
)
mock_api_key_store_cls.get_instance.return_value = mock_api_key_store
mock_token_manager.load_offline_token = AsyncMock(return_value=offline_token)
@@ -485,6 +498,9 @@ async def test_saas_user_auth_from_bearer_success():
assert isinstance(result, SaasUserAuth)
assert result.user_id == 'test_user_id'
assert result.api_key_org_id == mock_org_id
assert result.api_key_id == 42
assert result.api_key_name == 'Test Key'
mock_api_key_store.validate_api_key.assert_called_once_with('test_api_key')
mock_token_manager.load_offline_token.assert_called_once_with('test_user_id')
mock_token_manager.refresh.assert_called_once_with(offline_token)

View File

@@ -0,0 +1,78 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { ContextMenuContainer } from "#/components/features/context-menu/context-menu-container";
describe("ContextMenuContainer", () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();
it("should render children", () => {
render(
<ContextMenuContainer onClose={onCloseMock}>
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
</ContextMenuContainer>,
);
expect(screen.getByTestId("child-1")).toBeInTheDocument();
expect(screen.getByTestId("child-2")).toBeInTheDocument();
});
it("should apply consistent base styling", () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div>Content</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
expect(container).toHaveClass("bg-[#050505]");
expect(container).toHaveClass("border");
expect(container).toHaveClass("border-[#242424]");
expect(container).toHaveClass("rounded-[12px]");
expect(container).toHaveClass("p-[25px]");
expect(container).toHaveClass("context-menu-box-shadow");
});
it("should call onClose when clicking outside", async () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div>Content</div>
</ContextMenuContainer>,
);
await user.click(document.body);
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should render children in a flex row layout", () => {
render(
<ContextMenuContainer onClose={onCloseMock} testId="test-container">
<div data-testid="child-1">Child 1</div>
<div data-testid="child-2">Child 2</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
const innerDiv = container.firstChild as HTMLElement;
expect(innerDiv).toHaveClass("flex");
expect(innerDiv).toHaveClass("flex-row");
expect(innerDiv).toHaveClass("gap-4");
});
it("should apply additional className when provided", () => {
render(
<ContextMenuContainer
onClose={onCloseMock}
testId="test-container"
className="custom-class"
>
<div>Content</div>
</ContextMenuContainer>,
);
const container = screen.getByTestId("test-container");
expect(container).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,60 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { ContextMenuCTA } from "#/components/features/context-menu/context-menu-cta";
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
describe("ContextMenuCTA", () => {
it("should render the CTA component", () => {
render(<ContextMenuCTA />);
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE_DESCRIPTION")).toBeInTheDocument();
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
});
it("should call trackSaasSelfhostedInquiry with location 'context_menu' when Learn More is clicked", async () => {
const user = userEvent.setup();
render(<ContextMenuCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "context_menu",
});
});
it("should render Learn More as a link with correct href and target", () => {
render(<ContextMenuCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should render the stacked icon", () => {
render(<ContextMenuCTA />);
const contentContainer = screen.getByTestId("context-menu-cta-content");
const icon = contentContainer.querySelector("svg");
expect(icon).toBeInTheDocument();
expect(icon).toHaveAttribute("width", "40");
expect(icon).toHaveAttribute("height", "40");
});
});

View File

@@ -1,11 +1,16 @@
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AnalyticsConsentFormModal } from "#/components/features/analytics/analytics-consent-form-modal";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("AnalyticsConsentFormModal", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should call saveUserSettings with consent", async () => {
const user = userEvent.setup();
const onCloseMock = vi.fn();

View File

@@ -49,9 +49,17 @@ vi.mock("#/utils/custom-toast-handlers", () => ({
displayErrorToast: vi.fn(),
}));
// Mock feature flags - we'll control the return value in each test
const mockEnableProjUserJourney = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
}));
describe("LoginContent", () => {
beforeEach(() => {
vi.stubGlobal("location", { href: "" });
// Reset mock to return true by default
mockEnableProjUserJourney.mockReturnValue(true);
});
afterEach(() => {
@@ -274,6 +282,65 @@ describe("LoginContent", () => {
expect(screen.getByTestId("terms-and-privacy-notice")).toBeInTheDocument();
});
it("should display the enterprise LoginCTA component when appMode is saas and feature flag enabled", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when appMode is oss even with feature flag enabled", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="oss"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when appMode is null", () => {
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode={null}
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should not display the enterprise LoginCTA component when feature flag is disabled", () => {
// Disable the feature flag
mockEnableProjUserJourney.mockReturnValue(false);
render(
<MemoryRouter>
<LoginContent
githubAuthUrl="https://github.com/oauth/authorize"
appMode="saas"
providersConfigured={["github"]}
/>
</MemoryRouter>,
);
expect(screen.queryByTestId("login-cta")).not.toBeInTheDocument();
});
it("should display invitation pending message when hasInvitation is true", () => {
render(
<MemoryRouter>

View File

@@ -0,0 +1,63 @@
import { render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { LoginCTA } from "#/components/features/auth/login-cta";
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
describe("LoginCTA", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("should render enterprise CTA with title and description", () => {
render(<LoginCTA />);
expect(screen.getByTestId("login-cta")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE")).toBeInTheDocument();
expect(screen.getByText("CTA$ENTERPRISE_DEPLOY")).toBeInTheDocument();
});
it("should render all enterprise feature list items", () => {
render(<LoginCTA />);
expect(screen.getByText("CTA$FEATURE_ON_PREMISES")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_DATA_CONTROL")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_COMPLIANCE")).toBeInTheDocument();
expect(screen.getByText("CTA$FEATURE_SUPPORT")).toBeInTheDocument();
});
it("should render Learn More as a link with correct href and target", () => {
render(<LoginCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
it("should call trackSaasSelfhostedInquiry with location 'login_page' when Learn More is clicked", async () => {
const user = userEvent.setup();
render(<LoginCTA />);
const learnMoreLink = screen.getByRole("link", {
name: "CTA$LEARN_MORE",
});
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "login_page",
});
});
});

View File

@@ -10,9 +10,12 @@ import {
import { OpenHandsObservation } from "#/types/core/observations";
import ConversationService from "#/api/conversation-service/conversation-service.api";
import { Conversation } from "#/api/open-hands.types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("react-router", () => ({
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useParams: () => ({ conversationId: "123" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
let queryClient: QueryClient;
@@ -47,6 +50,7 @@ const renderMessages = ({
describe("Messages", () => {
beforeEach(() => {
queryClient = new QueryClient();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const assistantMessage: AssistantMessageAction = {

View File

@@ -11,23 +11,23 @@ vi.mock("posthog-js/react", () => ({
}),
}));
const { PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
const { ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/utils/feature-flags", () => ({
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
}));
describe("EnterpriseBanner", () => {
beforeEach(() => {
vi.clearAllMocks();
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
describe("Feature Flag", () => {
it("should not render when proj_user_journey feature flag is disabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
const { container } = renderWithProviders(<EnterpriseBanner />);
@@ -36,7 +36,7 @@ describe("EnterpriseBanner", () => {
});
it("should render when proj_user_journey feature flag is enabled", () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
renderWithProviders(<EnterpriseBanner />);

View File

@@ -0,0 +1,159 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { HomepageCTA } from "#/components/features/home/homepage-cta";
// Mock the translation function
vi.mock("react-i18next", async () => {
const actual = await vi.importActual("react-i18next");
return {
...actual,
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
"CTA$ENTERPRISE_TITLE": "Get OpenHands for Enterprise",
"CTA$ENTERPRISE_DESCRIPTION":
"Cloud allows you to access OpenHands anywhere and coordinate with your team like never before",
"CTA$LEARN_MORE": "Learn More",
};
return translations[key] || key;
},
i18n: { language: "en" },
}),
};
});
// Mock local storage
vi.mock("#/utils/local-storage", () => ({
setCTADismissed: vi.fn(),
}));
// Mock useTracking hook
const mockTrackSaasSelfhostedInquiry = vi.fn();
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: mockTrackSaasSelfhostedInquiry,
}),
}));
import { setCTADismissed } from "#/utils/local-storage";
describe("HomepageCTA", () => {
const mockSetShouldShowCTA = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
const renderHomepageCTA = () => {
return render(<HomepageCTA setShouldShowCTA={mockSetShouldShowCTA} />);
};
describe("rendering", () => {
it("renders the enterprise title", () => {
renderHomepageCTA();
expect(
screen.getByText("Get OpenHands for Enterprise"),
).toBeInTheDocument();
});
it("renders the enterprise description", () => {
renderHomepageCTA();
expect(
screen.getByText(/Cloud allows you to access OpenHands anywhere/),
).toBeInTheDocument();
});
it("renders the Learn More link", () => {
renderHomepageCTA();
const link = screen.getByRole("link", { name: "Learn More" });
expect(link).toBeInTheDocument();
});
it("renders the close button with correct aria-label", () => {
renderHomepageCTA();
expect(screen.getByRole("button", { name: "Close" })).toBeInTheDocument();
});
});
describe("close button behavior", () => {
it("calls setCTADismissed with 'homepage' when close button is clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(setCTADismissed).toHaveBeenCalledWith("homepage");
});
it("calls setShouldShowCTA with false when close button is clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(mockSetShouldShowCTA).toHaveBeenCalledWith(false);
});
it("calls both setCTADismissed and setShouldShowCTA in order", async () => {
const user = userEvent.setup();
const callOrder: string[] = [];
vi.mocked(setCTADismissed).mockImplementation(() => {
callOrder.push("setCTADismissed");
});
mockSetShouldShowCTA.mockImplementation(() => {
callOrder.push("setShouldShowCTA");
});
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
await user.click(closeButton);
expect(callOrder).toEqual(["setCTADismissed", "setShouldShowCTA"]);
});
});
describe("Learn More link behavior", () => {
it("calls trackSaasSelfhostedInquiry with location 'home_page' when clicked", async () => {
const user = userEvent.setup();
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
await user.click(learnMoreLink);
expect(mockTrackSaasSelfhostedInquiry).toHaveBeenCalledWith({
location: "home_page",
});
});
it("has correct href and target attributes", () => {
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
expect(learnMoreLink).toHaveAttribute(
"href",
"https://openhands.dev/enterprise/",
);
expect(learnMoreLink).toHaveAttribute("target", "_blank");
expect(learnMoreLink).toHaveAttribute("rel", "noopener noreferrer");
});
});
describe("accessibility", () => {
it("close button is focusable", () => {
renderHomepageCTA();
const closeButton = screen.getByRole("button", { name: "Close" });
expect(closeButton).not.toHaveAttribute("tabindex", "-1");
});
it("Learn More link is focusable", () => {
renderHomepageCTA();
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
expect(learnMoreLink).not.toHaveAttribute("tabindex", "-1");
});
});
});

View File

@@ -10,6 +10,7 @@ import OptionService from "#/api/option-service/option-service.api";
import { GitRepository } from "#/types/git";
import { RepoConnector } from "#/components/features/home/repo-connector";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
const renderRepoConnector = () => {
const mockRepoSelection = vi.fn();
@@ -65,6 +66,7 @@ const MOCK_RESPOSITORIES: GitRepository[] = [
];
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,

View File

@@ -7,6 +7,8 @@ import OnboardingForm from "#/routes/onboarding-form";
const mockMutate = vi.fn();
const mockNavigate = vi.fn();
const mockUseConfig = vi.fn();
const mockTrackOnboardingCompleted = vi.fn();
vi.mock("react-router", async (importOriginal) => {
const original = await importOriginal<typeof import("react-router")>();
@@ -22,6 +24,16 @@ vi.mock("#/hooks/mutation/use-submit-onboarding", () => ({
}),
}));
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => mockUseConfig(),
}));
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackOnboardingCompleted: mockTrackOnboardingCompleted,
}),
}));
const renderOnboardingForm = () => {
return renderWithProviders(
<MemoryRouter>
@@ -30,10 +42,15 @@ const renderOnboardingForm = () => {
);
};
describe("OnboardingForm", () => {
describe("OnboardingForm - SaaS Mode", () => {
beforeEach(() => {
mockMutate.mockClear();
mockNavigate.mockClear();
mockTrackOnboardingCompleted.mockClear();
mockUseConfig.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
});
it("should render with the correct test id", () => {
@@ -50,7 +67,7 @@ describe("OnboardingForm", () => {
expect(screen.getByTestId("step-actions")).toBeInTheDocument();
});
it("should display step progress indicator with 3 bars", () => {
it("should display step progress indicator with 3 bars for saas mode", () => {
renderOnboardingForm();
const stepHeader = screen.getByTestId("step-header");
@@ -69,7 +86,7 @@ describe("OnboardingForm", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
const nextButton = screen.getByRole("button", { name: /next/i });
expect(nextButton).not.toBeDisabled();
@@ -84,7 +101,7 @@ describe("OnboardingForm", () => {
let progressBars = stepHeader.querySelectorAll(".bg-white");
expect(progressBars).toHaveLength(1);
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 2, first two progress bars should be filled
@@ -96,7 +113,7 @@ describe("OnboardingForm", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
const nextButton = screen.getByRole("button", { name: /next/i });
@@ -107,29 +124,51 @@ describe("OnboardingForm", () => {
const user = userEvent.setup();
renderOnboardingForm();
// Step 1 - select role
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select org size
// Step 1 - select org size (first step in saas mode - single select)
await user.click(screen.getByTestId("step-option-org_2_10"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select use case
// Step 2 - select use case (multi-select)
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select role (last step in saas mode - single select)
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledTimes(1);
expect(mockMutate).toHaveBeenCalledWith({
selections: {
step1: "software_engineer",
step2: "org_2_10",
step3: "new_features",
org_size: "org_2_10",
use_case: ["new_features"],
role: "software_engineer",
},
});
});
it("should render 6 options on step 1", () => {
it("should track onboarding completion to PostHog in SaaS mode", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Complete the full SaaS onboarding flow
await user.click(screen.getByTestId("step-option-org_2_10"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockTrackOnboardingCompleted).toHaveBeenCalledTimes(1);
expect(mockTrackOnboardingCompleted).toHaveBeenCalledWith({
role: "software_engineer",
orgSize: "org_2_10",
useCase: ["new_features"],
});
});
it("should render 5 options on step 1 (org size question)", () => {
renderOnboardingForm();
const options = screen
@@ -137,31 +176,86 @@ describe("OnboardingForm", () => {
.filter((btn) =>
btn.getAttribute("data-testid")?.startsWith("step-option-"),
);
expect(options).toHaveLength(6);
expect(options).toHaveLength(5);
});
it("should preserve selections when navigating through steps", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Select role on step 1
await user.click(screen.getByTestId("step-option-cto_founder"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select org size on step 2
// Select org size on step 1 (single select)
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select use case on step 3
// Select use case on step 2 (multi-select)
await user.click(screen.getByTestId("step-option-fixing_bugs"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Select role on step 3 (single select)
await user.click(screen.getByTestId("step-option-cto_founder"));
await user.click(screen.getByRole("button", { name: /finish/i }));
// Verify all selections were preserved
expect(mockMutate).toHaveBeenCalledWith({
selections: {
step1: "cto_founder",
step2: "solo",
step3: "fixing_bugs",
org_size: "solo",
use_case: ["fixing_bugs"],
role: "cto_founder",
},
});
});
it("should allow selecting multiple options on multi-select steps", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Step 1 - select org size (single select)
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select multiple use cases (multi-select)
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByTestId("step-option-fixing_bugs"));
await user.click(screen.getByTestId("step-option-refactoring"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select role (single select)
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "solo",
use_case: ["new_features", "fixing_bugs", "refactoring"],
role: "software_engineer",
},
});
});
it("should allow deselecting options on multi-select steps", async () => {
const user = userEvent.setup();
renderOnboardingForm();
// Step 1 - select org size
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 2 - select and deselect use cases
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByTestId("step-option-fixing_bugs"));
await user.click(screen.getByTestId("step-option-new_features")); // Deselect
await user.click(screen.getByRole("button", { name: /next/i }));
// Step 3 - select role
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByRole("button", { name: /finish/i }));
expect(mockMutate).toHaveBeenCalledWith({
selections: {
org_size: "solo",
use_case: ["fixing_bugs"],
role: "software_engineer",
},
});
});
@@ -171,10 +265,10 @@ describe("OnboardingForm", () => {
renderOnboardingForm();
// Navigate to step 3
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByTestId("step-option-new_features"));
await user.click(screen.getByRole("button", { name: /next/i }));
// On step 3, all three progress bars should be filled
@@ -194,7 +288,7 @@ describe("OnboardingForm", () => {
const user = userEvent.setup();
renderOnboardingForm();
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
const backButton = screen.getByRole("button", { name: /back/i });
@@ -206,7 +300,7 @@ describe("OnboardingForm", () => {
renderOnboardingForm();
// Navigate to step 2
await user.click(screen.getByTestId("step-option-software_engineer"));
await user.click(screen.getByTestId("step-option-solo"));
await user.click(screen.getByRole("button", { name: /next/i }));
// Verify we're on step 2 (2 progress bars filled)

View File

@@ -12,7 +12,7 @@ describe("StepContent", () => {
const defaultProps = {
options: mockOptions,
selectedOptionId: null,
selectedOptionIds: [],
onSelectOption: vi.fn(),
};
@@ -44,7 +44,7 @@ describe("StepContent", () => {
});
it("should mark the selected option as selected", () => {
render(<StepContent {...defaultProps} selectedOptionId="option1" />);
render(<StepContent {...defaultProps} selectedOptionIds={["option1"]} />);
const selectedOption = screen.getByTestId("step-option-option1");
const unselectedOption = screen.getByTestId("step-option-option2");

View File

@@ -0,0 +1,72 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { StepInput } from "#/components/features/onboarding/step-input";
describe("StepInput", () => {
const defaultProps = {
id: "test-input",
label: "Test Label",
value: "",
onChange: vi.fn(),
};
it("should render with correct test id", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByTestId("step-input-test-input")).toBeInTheDocument();
});
it("should render the label", () => {
render(<StepInput {...defaultProps} />);
expect(screen.getByText("Test Label")).toBeInTheDocument();
});
it("should display the provided value", () => {
render(<StepInput {...defaultProps} value="Hello World" />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveValue("Hello World");
});
it("should call onChange when user types", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "a");
expect(mockOnChange).toHaveBeenCalledWith("a");
});
it("should call onChange with the full input value on each keystroke", async () => {
const mockOnChange = vi.fn();
const user = userEvent.setup();
render(<StepInput {...defaultProps} onChange={mockOnChange} />);
const input = screen.getByTestId("step-input-test-input");
await user.type(input, "abc");
expect(mockOnChange).toHaveBeenCalledTimes(3);
expect(mockOnChange).toHaveBeenNthCalledWith(1, "a");
expect(mockOnChange).toHaveBeenNthCalledWith(2, "b");
expect(mockOnChange).toHaveBeenNthCalledWith(3, "c");
});
it("should use the id prop for data-testid", () => {
render(<StepInput {...defaultProps} id="org_name" />);
expect(screen.getByTestId("step-input-org_name")).toBeInTheDocument();
});
it("should render as a text input", () => {
render(<StepInput {...defaultProps} />);
const input = screen.getByTestId("step-input-test-input");
expect(input).toHaveAttribute("type", "text");
});
});

View File

@@ -1,7 +1,8 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysManager } from "#/components/features/settings/api-keys-manager";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the react-i18next
vi.mock("react-i18next", async () => {
@@ -37,6 +38,10 @@ vi.mock("#/hooks/query/use-api-keys", () => ({
}));
describe("ApiKeysManager", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const renderComponent = () => {
const queryClient = new QueryClient();
return render(

View File

@@ -1,4 +1,4 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
renderWithProviders,
createAxiosNotFoundErrorObject,
@@ -10,6 +10,7 @@ import SettingsService from "#/api/settings-service/settings-service.api";
import OptionService from "#/api/option-service/option-service.api";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { WebClientConfig } from "#/api/option-service/option.types";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Helper to create mock config with sensible defaults
const createMockConfig = (
@@ -76,6 +77,10 @@ describe("Sidebar", () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
vi.clearAllMocks();
});

View File

@@ -18,6 +18,27 @@ import { OrganizationMember } from "#/types/org";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import { createMockWebClientConfig } from "#/mocks/settings-handlers";
// Mock useBreakpoint hook
vi.mock("#/hooks/use-breakpoint", () => ({
useBreakpoint: vi.fn(() => false), // Default to desktop (not mobile)
}));
// Mock feature flags
const mockEnableProjUserJourney = vi.fn(() => true);
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => mockEnableProjUserJourney(),
}));
// Mock useTracking hook for CTA
vi.mock("#/hooks/use-tracking", () => ({
useTracking: () => ({
trackSaasSelfhostedInquiry: vi.fn(),
}),
}));
// Import the mocked modules
import * as breakpoint from "#/hooks/use-breakpoint";
type UserContextMenuProps = GetComponentPropTypes<typeof UserContextMenu>;
function UserContextMenuWithRootOutlet({
@@ -123,6 +144,9 @@ describe("UserContextMenu", () => {
// Ensure clean state at the start of each test
vi.restoreAllMocks();
useSelectedOrganizationStore.setState({ organizationId: null });
// Reset feature flag and breakpoint mocks to defaults
mockEnableProjUserJourney.mockReturnValue(true);
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(false); // Desktop by default
});
afterEach(() => {
@@ -132,11 +156,19 @@ describe("UserContextMenu", () => {
useSelectedOrganizationStore.setState({ organizationId: null });
});
it("should render the default context items for a user", () => {
it("should render the default context items for a user", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
screen.getByTestId("org-selector");
screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
// Wait for config to load so logout button appears
await waitFor(() => {
expect(screen.getByText("ACCOUNT_SETTINGS$LOGOUT")).toBeInTheDocument();
});
expect(
screen.queryByText("ORG$INVITE_ORG_MEMBERS"),
@@ -280,6 +312,20 @@ describe("UserContextMenu", () => {
screen.queryByText("Organization Members"),
).not.toBeInTheDocument();
});
it("should not display logout button in OSS mode", async () => {
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for the config to load
await waitFor(() => {
expect(screen.getByText("SETTINGS$NAV_LLM")).toBeInTheDocument();
});
// Verify logout button is NOT rendered in OSS mode
expect(
screen.queryByText("ACCOUNT_SETTINGS$LOGOUT"),
).not.toBeInTheDocument();
});
});
describe("HIDE_LLM_SETTINGS feature flag", () => {
@@ -358,10 +404,15 @@ describe("UserContextMenu", () => {
});
it("should call the logout handler when Logout is clicked", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
const logoutSpy = vi.spyOn(AuthService, "logout");
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
// Wait for config to load so logout button appears
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(logoutSpy).toHaveBeenCalledOnce();
@@ -464,6 +515,10 @@ describe("UserContextMenu", () => {
});
it("should call the onClose handler after each action", async () => {
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Mock a team org so org management buttons are visible
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [MOCK_TEAM_ORG_ACME],
@@ -473,7 +528,8 @@ describe("UserContextMenu", () => {
const onCloseMock = vi.fn();
renderUserContextMenu({ type: "owner", onClose: onCloseMock, onOpenInviteModal: vi.fn });
const logoutButton = screen.getByText("ACCOUNT_SETTINGS$LOGOUT");
// Wait for config to load so logout button appears
const logoutButton = await screen.findByText("ACCOUNT_SETTINGS$LOGOUT");
await userEvent.click(logoutButton);
expect(onCloseMock).toHaveBeenCalledTimes(1);
@@ -630,4 +686,77 @@ describe("UserContextMenu", () => {
// Verify that the dropdown shows the selected organization
expect(screen.getByRole("combobox")).toHaveValue(INITIAL_MOCK_ORGS[1].name);
});
describe("Context Menu CTA", () => {
it("should render the CTA component in SaaS mode on desktop with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("context-menu-cta")).toBeInTheDocument();
});
expect(screen.getByText("CTA$ENTERPRISE_TITLE")).toBeInTheDocument();
expect(screen.getByText("CTA$LEARN_MORE")).toBeInTheDocument();
});
it("should not render the CTA component in OSS mode even with feature flag enabled", async () => {
// Set OSS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "oss" }),
);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component on mobile even in SaaS mode with feature flag enabled", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Set mobile mode
vi.mocked(breakpoint.useBreakpoint).mockReturnValue(true);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not render the CTA component when feature flag is disabled in SaaS mode", async () => {
// Set SaaS mode
vi.spyOn(OptionService, "getConfig").mockResolvedValue(
createMockWebClientConfig({ app_mode: "saas" }),
);
// Disable the feature flag
mockEnableProjUserJourney.mockReturnValue(false);
renderUserContextMenu({ type: "member", onClose: vi.fn, onOpenInviteModal: vi.fn });
// Wait for config to load
await waitFor(() => {
expect(screen.getByTestId("user-context-menu")).toBeInTheDocument();
});
expect(screen.queryByTestId("context-menu-cta")).not.toBeInTheDocument();
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
});
});

View File

@@ -1,26 +1,25 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { AgentState } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useConversationStore } from "#/stores/conversation-store";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/use-agent-state", () => ({
useAgentState: vi.fn(),
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock the useActiveConversation hook
vi.mock("#/hooks/query/use-active-conversation", () => ({
@@ -52,6 +51,10 @@ vi.mock("#/hooks/use-conversation-name-context-menu", () => ({
describe("InteractiveChatBox", () => {
const onSubmitMock = vi.fn();
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const mockStores = (agentState: AgentState = AgentState.INIT) => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: agentState,
@@ -213,6 +216,36 @@ describe("InteractiveChatBox", () => {
expect(onSubmitMock).not.toHaveBeenCalled();
});
it("should lock the text input field when disabled prop is true (isNewConversationPending)", () => {
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
disabled: true,
});
const chatInput = screen.getByTestId("chat-input");
// When disabled=true, the text field should not be editable
expect(chatInput).toHaveAttribute("contenteditable", "false");
// Should show visual disabled state
expect(chatInput.className).toContain("cursor-not-allowed");
expect(chatInput.className).toContain("opacity-50");
});
it("should keep the text input field editable when disabled prop is false", () => {
mockStores(AgentState.INIT);
renderInteractiveChatBox({
onSubmit: onSubmitMock,
disabled: false,
});
const chatInput = screen.getByTestId("chat-input");
expect(chatInput).toHaveAttribute("contenteditable", "true");
expect(chatInput.className).not.toContain("cursor-not-allowed");
expect(chatInput.className).not.toContain("opacity-50");
});
it("should handle image upload and message submission correctly", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();

View File

@@ -28,6 +28,7 @@ const createUserMessageEvent = (id: string): MessageEvent => ({
const createPlanningObservationEvent = (
id: string,
actionId: string = "action-1",
path: string = "/workspace/PLAN.md",
): ObservationEvent<PlanningFileEditorObservation> => ({
id,
timestamp: new Date().toISOString(),
@@ -40,7 +41,7 @@ const createPlanningObservationEvent = (
content: [{ type: "text", text: "Plan content" }],
is_error: false,
command: "create",
path: "/workspace/PLAN.md",
path,
prev_exist: false,
old_content: null,
new_content: "Plan content",
@@ -172,6 +173,31 @@ describe("usePlanPreviewEvents", () => {
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-1")).toBe(true);
});
it("should exclude PlanningFileEditorObservation for non-Plan.md paths", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "use-add-mcp.ts"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(0);
});
it("should include only Plan.md observations when mixed with other file edits", () => {
const events: OpenHandsEvent[] = [
createUserMessageEvent("user-1"),
createPlanningObservationEvent("plan-obs-1", "action-1", "settings.py"),
createPlanningObservationEvent("plan-obs-2", "action-2", "/workspace/PLAN.md"),
];
const { result } = renderHook(() => usePlanPreviewEvents(events));
expect(result.current.size).toBe(1);
expect(result.current.has("plan-obs-2")).toBe(true);
});
});
describe("shouldShowPlanPreview", () => {

View File

@@ -0,0 +1,95 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { getEventContent } from "#/components/v1/chat";
import { ActionEvent, ObservationEvent, SecurityRisk } from "#/types/v1/core";
const terminalActionEvent: ActionEvent = {
id: "action-1",
timestamp: new Date().toISOString(),
source: "agent",
thought: [{ type: "text", text: "Checking repository status." }],
thinking_blocks: [],
action: {
kind: "TerminalAction",
command: "git status",
is_input: false,
timeout: null,
reset: false,
},
tool_name: "terminal",
tool_call_id: "tool-1",
tool_call: {
id: "tool-1",
type: "function",
function: {
name: "terminal",
arguments: '{"command":"git status"}',
},
},
llm_response_id: "response-1",
security_risk: SecurityRisk.LOW,
summary: "Check repository status",
};
const terminalObservationEvent: ObservationEvent = {
id: "obs-1",
timestamp: new Date().toISOString(),
source: "environment",
tool_name: "terminal",
tool_call_id: "tool-1",
action_id: "action-1",
observation: {
kind: "TerminalObservation",
content: [{ type: "text", text: "On branch main" }],
command: "git status",
exit_code: 0,
is_error: false,
timeout: false,
metadata: {
exit_code: 0,
pid: 1,
username: "openhands",
hostname: "sandbox",
prefix: "",
suffix: "",
working_dir: "/workspace/project/OpenHands",
py_interpreter_path: null,
},
},
};
describe("getEventContent", () => {
it("uses the action summary as the full action title", () => {
const { title } = getEventContent(terminalActionEvent);
render(<>{title}</>);
expect(screen.getByText("Check repository status")).toBeInTheDocument();
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
});
it("falls back to command-based title when summary is missing", () => {
const actionWithoutSummary = { ...terminalActionEvent, summary: undefined };
const { title } = getEventContent(actionWithoutSummary);
render(<>{title}</>);
// Without i18n loaded, the translation key renders as the raw key
expect(screen.getByText("ACTION_MESSAGE$RUN")).toBeInTheDocument();
expect(
screen.queryByText("Check repository status"),
).not.toBeInTheDocument();
});
it("reuses the action summary as the full paired observation title", () => {
const { title } = getEventContent(
terminalObservationEvent,
terminalActionEvent,
);
render(<>{title}</>);
expect(screen.getByText("Check repository status")).toBeInTheDocument();
expect(screen.queryByText("$ git status")).not.toBeInTheDocument();
});
});

View File

@@ -7,6 +7,7 @@ import {
WsClientProvider,
useWsClient,
} from "#/context/ws-client-provider";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("Propagate error message", () => {
it("should do nothing when no message was passed from server", () => {
@@ -56,6 +57,7 @@ function TestComponent() {
describe("WsClientProvider", () => {
beforeEach(() => {
vi.clearAllMocks();
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => {
return { data: {

View File

@@ -40,6 +40,7 @@ import {
import { conversationWebSocketTestSetup } from "./helpers/msw-websocket-setup";
import { useEventStore } from "#/stores/use-event-store";
import { isV1Event } from "#/types/v1/type-guards";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock useUserConversation to return V1 conversation data
vi.mock("#/hooks/query/use-user-conversation", () => ({
@@ -62,6 +63,10 @@ beforeAll(() => {
mswServer.listen({ onUnhandledRequest: "bypass" });
});
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
afterEach(() => {
mswServer.resetHandlers();
// Clean up any React components

View File

@@ -0,0 +1,299 @@
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi, beforeEach } from "vitest";
import V1ConversationService from "#/api/conversation-service/v1-conversation-service.api";
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
const mockNavigate = vi.fn();
vi.mock("react-router", () => ({
useNavigate: () => mockNavigate,
useParams: () => ({ conversationId: "conv-123" }),
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const { mockToast } = vi.hoisted(() => {
const mockToast = Object.assign(vi.fn(), {
loading: vi.fn(),
dismiss: vi.fn(),
});
return { mockToast };
});
vi.mock("react-hot-toast", () => ({
default: mockToast,
}));
vi.mock("#/utils/custom-toast-handlers", () => ({
displaySuccessToast: vi.fn(),
displayErrorToast: vi.fn(),
TOAST_OPTIONS: { position: "top-right" },
}));
const mockConversation = {
conversation_id: "conv-123",
sandbox_id: "sandbox-456",
title: "Test Conversation",
selected_repository: null,
selected_branch: null,
git_provider: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING" as const,
runtime_status: null,
url: null,
session_api_key: null,
conversation_version: "V1" as const,
};
vi.mock("#/hooks/query/use-active-conversation", () => ({
useActiveConversation: () => ({
data: mockConversation,
}),
}));
function makeStartTask(overrides: Record<string, unknown> = {}) {
return {
id: "task-789",
created_by_user_id: null,
status: "READY",
detail: null,
app_conversation_id: "new-conv-999",
sandbox_id: "sandbox-456",
agent_server_url: "http://agent-server.local",
request: {
sandbox_id: null,
initial_message: null,
processors: [],
llm_model: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
suggested_task: null,
title: null,
trigger: null,
pr_number: [],
parent_conversation_id: null,
agent_type: "default",
},
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides,
};
}
describe("useNewConversationCommand", () => {
let queryClient: QueryClient;
beforeEach(() => {
vi.clearAllMocks();
queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
// Mock batchGetAppConversations to return V1 data with llm_model
vi.spyOn(
V1ConversationService,
"batchGetAppConversations",
).mockResolvedValue([
{
id: "conv-123",
title: "Test Conversation",
sandbox_id: "sandbox-456",
sandbox_status: "RUNNING",
execution_status: "IDLE",
conversation_url: null,
session_api_key: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
trigger: null,
pr_number: [],
llm_model: "gpt-4o",
metrics: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
sub_conversation_ids: [],
public: false,
} as never,
]);
});
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
it("calls createConversation with sandbox_id and navigates on success", async () => {
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
const getStartTaskSpy = vi
.spyOn(V1ConversationService, "getStartTask")
.mockResolvedValue(readyTask as never);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(createSpy).toHaveBeenCalledWith(
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"sandbox-456",
"gpt-4o",
);
expect(getStartTaskSpy).toHaveBeenCalledWith("task-789");
expect(mockNavigate).toHaveBeenCalledWith(
"/conversations/new-conv-999",
);
});
});
it("polls getStartTask until status is READY", async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const workingTask = makeStartTask({
status: "WORKING",
app_conversation_id: null,
});
const readyTask = makeStartTask({ status: "READY" });
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
workingTask as never,
);
const getStartTaskSpy = vi
.spyOn(V1ConversationService, "getStartTask")
.mockResolvedValueOnce(workingTask as never)
.mockResolvedValueOnce(readyTask as never);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
const mutatePromise = result.current.mutateAsync();
await vi.advanceTimersByTimeAsync(2000);
await mutatePromise;
await waitFor(() => {
expect(getStartTaskSpy).toHaveBeenCalledTimes(2);
expect(mockNavigate).toHaveBeenCalledWith(
"/conversations/new-conv-999",
);
});
vi.useRealTimers();
});
it("throws when task status is ERROR", async () => {
const errorTask = makeStartTask({
status: "ERROR",
detail: "Sandbox crashed",
app_conversation_id: null,
});
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
errorTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
errorTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await expect(result.current.mutateAsync()).rejects.toThrow(
"Sandbox crashed",
);
});
it("invalidates conversation list queries on success", async () => {
const readyTask = makeStartTask();
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
readyTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["user", "conversations"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["v1-batch-get-app-conversations"],
});
});
});
it("creates a standalone conversation (not a sub-conversation) so it appears in the list", async () => {
const readyTask = makeStartTask();
const createSpy = vi
.spyOn(V1ConversationService, "createConversation")
.mockResolvedValue(readyTask as never);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
// parent_conversation_id should be undefined so the new conversation
// is NOT a sub-conversation and will appear in the conversation list.
expect(createSpy).toHaveBeenCalledWith(
undefined, // selectedRepository (null from mock)
undefined, // git_provider (null from mock)
undefined, // initialUserMsg
undefined, // selected_branch (null from mock)
undefined, // conversationInstructions
undefined, // suggestedTask
undefined, // trigger
undefined, // parent_conversation_id is NOT set
undefined, // agent_type
"sandbox-456", // sandbox_id IS set to reuse the sandbox
"gpt-4o", // llm_model IS inherited from the original conversation
);
});
});
it("shows a loading toast immediately and dismisses it on success", async () => {
const readyTask = makeStartTask();
vi.spyOn(V1ConversationService, "createConversation").mockResolvedValue(
readyTask as never,
);
vi.spyOn(V1ConversationService, "getStartTask").mockResolvedValue(
readyTask as never,
);
const { result } = renderHook(() => useNewConversationCommand(), { wrapper });
await result.current.mutateAsync();
await waitFor(() => {
expect(mockToast.loading).toHaveBeenCalledWith(
"CONVERSATION$CLEARING",
expect.objectContaining({ id: "clear-conversation" }),
);
expect(mockToast.dismiss).toHaveBeenCalledWith("clear-conversation");
});
});
});

View File

@@ -1,10 +1,15 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import SettingsService from "#/api/settings-service/settings-service.api";
import { useSaveSettings } from "#/hooks/mutation/use-save-settings";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
describe("useSaveSettings", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
it("should send an empty string for llm_api_key if an empty string is passed, otherwise undefined", async () => {
const saveSettingsSpy = vi.spyOn(SettingsService, "saveSettings");
const { result } = renderHook(() => useSaveSettings(), {

View File

@@ -0,0 +1,225 @@
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React from "react";
import { useSettings } from "#/hooks/query/use-settings";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { useApiKeys } from "#/hooks/query/use-api-keys";
import SettingsService from "#/api/settings-service/settings-service.api";
import { SecretsService } from "#/api/secrets-service";
import ApiKeysClient from "#/api/api-keys";
import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
vi.mock("#/hooks/query/use-config", () => ({
useConfig: () => ({
data: { app_mode: "saas" },
}),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
useIsAuthed: () => ({
data: true,
}),
}));
vi.mock("#/hooks/use-is-on-intermediate-page", () => ({
useIsOnIntermediatePage: () => false,
}));
describe("Organization-scoped query hooks", () => {
let queryClient: QueryClient;
const createWrapper = () => {
return ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
vi.clearAllMocks();
});
describe("useSettings", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
const { result } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["settings", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["settings"]);
expect(oldKeyData).toBeUndefined();
});
it("should refetch when organization changes", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
// First render with org-1
const { result, rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(getSettingsSpy).toHaveBeenCalledTimes(1);
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSettingsSpy.mockResolvedValue({
...MOCK_DEFAULT_USER_SETTINGS,
language: "es",
});
// Rerender to pick up the new org ID
rerender();
await waitFor(() => {
// Should have fetched again for the new org
expect(getSettingsSpy).toHaveBeenCalledTimes(2);
});
// Verify both org caches exist independently
const org1Data = queryClient.getQueryData(["settings", "org-1"]);
const org2Data = queryClient.getQueryData(["settings", "org-2"]);
expect(org1Data).toBeDefined();
expect(org2Data).toBeDefined();
});
});
describe("useGetSecrets", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
getSecretsSpy.mockResolvedValue([]);
const { result } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["secrets", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["secrets"]);
expect(oldKeyData).toBeUndefined();
});
it("should fetch different data when organization changes", async () => {
const getSecretsSpy = vi.spyOn(SecretsService, "getSecrets");
// Mock different secrets for different orgs
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_1", description: "Org 1 secret" },
]);
const { result, rerender } = renderHook(() => useGetSecrets(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
expect(result.current.data).toHaveLength(1);
expect(result.current.data?.[0].name).toBe("SECRET_ORG_1");
// Change organization
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
getSecretsSpy.mockResolvedValueOnce([
{ name: "SECRET_ORG_2", description: "Org 2 secret" },
]);
rerender();
await waitFor(() => {
expect(result.current.data?.[0]?.name).toBe("SECRET_ORG_2");
});
});
});
describe("useApiKeys", () => {
it("should include organizationId in query key for proper cache isolation", async () => {
const getApiKeysSpy = vi.spyOn(ApiKeysClient, "getApiKeys");
getApiKeysSpy.mockResolvedValue([]);
const { result } = renderHook(() => useApiKeys(), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current.isFetched).toBe(true));
// Verify the query was cached with the org-specific key
const cachedData = queryClient.getQueryData(["api-keys", "org-1"]);
expect(cachedData).toBeDefined();
// Verify no data is cached under the old key without org ID
const oldKeyData = queryClient.getQueryData(["api-keys"]);
expect(oldKeyData).toBeUndefined();
});
});
describe("Cache isolation between organizations", () => {
it("should maintain separate caches for each organization", async () => {
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
// Simulate fetching for org-1
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "en",
});
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
const { rerender } = renderHook(() => useSettings(), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-1"])).toBeDefined();
});
// Switch to org-2
getSettingsSpy.mockResolvedValueOnce({
...MOCK_DEFAULT_USER_SETTINGS,
language: "fr",
});
useSelectedOrganizationStore.setState({ organizationId: "org-2" });
rerender();
await waitFor(() => {
expect(queryClient.getQueryData(["settings", "org-2"])).toBeDefined();
});
// Switch back to org-1 - should use cached data, not refetch
useSelectedOrganizationStore.setState({ organizationId: "org-1" });
rerender();
// org-1 data should still be in cache
const org1Cache = queryClient.getQueryData(["settings", "org-1"]) as any;
expect(org1Cache?.language).toBe("en");
// org-2 data should also still be in cache
const org2Cache = queryClient.getQueryData(["settings", "org-2"]) as any;
expect(org2Cache?.language).toBe("fr");
});
});
});

View File

@@ -0,0 +1,64 @@
import { renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Conversation } from "#/api/open-hands.types";
import { useRuntimeIsReady } from "#/hooks/use-runtime-is-ready";
import { useAgentState } from "#/hooks/use-agent-state";
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
import { AgentState } from "#/types/agent-state";
vi.mock("#/hooks/use-agent-state");
vi.mock("#/hooks/query/use-active-conversation");
function asMockReturnValue<T>(value: Partial<T>): T {
return value as T;
}
function makeConversation(): Conversation {
return {
conversation_id: "conv-123",
title: "Test Conversation",
selected_repository: null,
selected_branch: null,
git_provider: null,
last_updated_at: new Date().toISOString(),
created_at: new Date().toISOString(),
status: "RUNNING",
runtime_status: null,
url: null,
session_api_key: null,
};
}
describe("useRuntimeIsReady", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useActiveConversation).mockReturnValue(
asMockReturnValue<ReturnType<typeof useActiveConversation>>({
data: makeConversation(),
}),
);
});
it("treats agent errors as not ready by default", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() => useRuntimeIsReady());
expect(result.current).toBe(false);
});
it("allows runtime-backed tabs to stay ready when the agent errors", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
const { result } = renderHook(() =>
useRuntimeIsReady({ allowAgentError: true }),
);
expect(result.current).toBe(true);
});
});

View File

@@ -205,7 +205,9 @@ describe("useWebSocket", () => {
expect(result.current.isConnected).toBe(true);
});
expect(onCloseSpy).not.toHaveBeenCalled();
// Reset spy after connection is established to ignore any spurious
// close events fired by the MSW mock during the handshake.
onCloseSpy.mockClear();
// Unmount to trigger close
unmount();

View File

@@ -5,9 +5,11 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import AcceptTOS from "#/routes/accept-tos";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import { openHands } from "#/api/open-hands-axios";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
// Mock the react-router hooks
vi.mock("react-router", () => ({
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useSearchParams: () => [
{
@@ -19,6 +21,7 @@ vi.mock("react-router", () => ({
},
},
],
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock the axios instance
@@ -54,6 +57,7 @@ const createWrapper = () => {
describe("AcceptTOS", () => {
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
vi.stubGlobal("location", { href: "" });
});

View File

@@ -1,5 +1,5 @@
import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import AppSettingsScreen, { clientLoader } from "#/routes/app-settings";
@@ -8,6 +8,11 @@ import { MOCK_DEFAULT_USER_SETTINGS } from "#/mocks/handlers";
import { AvailableLanguages } from "#/i18n";
import * as CaptureConsent from "#/utils/handle-capture-consent";
import * as ToastHandlers from "#/utils/custom-toast-handlers";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
const renderAppSettingsScreen = () =>
render(<AppSettingsScreen />, {

View File

@@ -32,6 +32,7 @@ describe("Changes Tab", () => {
vi.mocked(useUnifiedGetGitChanges).mockReturnValue({
data: [],
isLoading: false,
isFetching: false,
isSuccess: true,
isError: false,
error: null,
@@ -50,6 +51,7 @@ describe("Changes Tab", () => {
vi.mocked(useUnifiedGetGitChanges).mockReturnValue({
data: [{ path: "src/file.ts", status: "M" }],
isLoading: false,
isFetching: false,
isSuccess: true,
isError: false,
error: null,

View File

@@ -5,12 +5,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import DeviceVerify from "#/routes/device-verify";
const { useIsAuthedMock, PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
const { useIsAuthedMock, ENABLE_PROJ_USER_JOURNEY_MOCK } = vi.hoisted(() => ({
useIsAuthedMock: vi.fn(() => ({
data: false as boolean | undefined,
isLoading: false,
})),
PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
ENABLE_PROJ_USER_JOURNEY_MOCK: vi.fn(() => true),
}));
vi.mock("#/hooks/query/use-is-authed", () => ({
@@ -24,7 +24,7 @@ vi.mock("posthog-js/react", () => ({
}));
vi.mock("#/utils/feature-flags", () => ({
PROJ_USER_JOURNEY: () => PROJ_USER_JOURNEY_MOCK(),
ENABLE_PROJ_USER_JOURNEY: () => ENABLE_PROJ_USER_JOURNEY_MOCK(),
}));
const RouterStub = createRoutesStub([
@@ -67,7 +67,7 @@ describe("DeviceVerify", () => {
),
);
// Enable feature flag by default
PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(true);
});
afterEach(() => {
@@ -254,7 +254,7 @@ describe("DeviceVerify", () => {
});
it("should not include the EnterpriseBanner and be center-aligned when feature flag is disabled", async () => {
PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
ENABLE_PROJ_USER_JOURNEY_MOCK.mockReturnValue(false);
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,

View File

@@ -609,3 +609,193 @@ describe("New user welcome toast", () => {
).not.toBeInTheDocument();
});
});
describe("HomepageCTA visibility", () => {
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
// Mock localStorage to enable the PROJ_USER_JOURNEY feature flag (CTA dismissal also uses localStorage)
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => {
if (key === "FEATURE_PROJ_USER_JOURNEY") {
return "true";
}
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should show HomepageCTA in SaaS mode when not dismissed and feature flag enabled", async () => {
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
renderHomeScreen();
await screen.findByTestId("home-screen");
const ctaLink = await screen.findByRole("link", {
name: "CTA$LEARN_MORE",
});
expect(ctaLink).toBeInTheDocument();
});
it("should not show HomepageCTA in OSS mode even with feature flag enabled", async () => {
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { app_mode: "oss", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
app_mode: "oss",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
renderHomeScreen();
await screen.findByTestId("home-screen");
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not show HomepageCTA when feature flag is disabled", async () => {
// Override localStorage to disable the feature flag
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null), // No feature flags set
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
renderHomeScreen();
await screen.findByTestId("home-screen");
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
it("should not show HomepageCTA when dismissed in local storage", async () => {
// Override localStorage to mark CTA as dismissed while keeping the feature flag enabled
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => {
if (key === "FEATURE_PROJ_USER_JOURNEY") {
return "true";
}
if (key === "homepage-cta-dismissed") {
return "true";
}
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
useIsAuthedMock.mockReturnValue({
data: true,
isLoading: false,
isFetching: false,
isError: false,
});
useConfigMock.mockReturnValue({
data: { app_mode: "saas", feature_flags: DEFAULT_FEATURE_FLAGS },
isLoading: false,
});
getConfigSpy.mockResolvedValue({
app_mode: "saas",
posthog_client_key: "test-posthog-key",
providers_configured: ["github"],
auth_url: "https://auth.example.com",
feature_flags: DEFAULT_FEATURE_FLAGS,
maintenance_start_time: null,
recaptcha_site_key: null,
faulty_models: [],
error_message: null,
updated_at: "2024-01-14T10:00:00Z",
github_app_slug: null,
});
renderHomeScreen();
await screen.findByTestId("home-screen");
expect(screen.queryByText("CTA$ENTERPRISE_TITLE")).not.toBeInTheDocument();
});
});

View File

@@ -13,7 +13,39 @@ import * as ToastHandlers from "#/utils/custom-toast-handlers";
import OptionService from "#/api/option-service/option-service.api";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
import type { OrganizationMember } from "#/types/org";
import type { Organization, OrganizationMember } from "#/types/org";
/** Creates a mock Organization with default values for testing */
const createMockOrganization = (
overrides: Partial<Organization> & Pick<Organization, "id" | "name">,
): Organization => ({
contact_name: "",
contact_email: "",
conversation_expiration: 0,
agent: "CodeActAgent",
default_max_iterations: 20,
security_analyzer: "",
confirmation_mode: false,
default_llm_model: "",
default_llm_api_key_for_byor: "",
default_llm_base_url: "",
remote_runtime_resource_factor: 1,
enable_default_condenser: true,
billing_margin: 0,
enable_proactive_conversation_starters: false,
sandbox_base_container_image: "",
sandbox_runtime_container_image: "",
org_version: 1,
mcp_config: { tools: [], settings: {} },
search_api_key: null,
sandbox_api_key: null,
max_budget_per_task: 0,
enable_solvability_analysis: false,
v1_enabled: true,
credits: 0,
is_personal: false,
...overrides,
});
// Mock react-router hooks
const mockUseSearchParams = vi.fn();
@@ -1767,3 +1799,163 @@ describe("clientLoader permission checks", () => {
expect(typeof clientLoader).toBe("function");
});
});
describe("Contextual info messages", () => {
it("should show admin message when user is an admin in a team organization", async () => {
// Arrange
const orgId = "team-org-1";
const adminMeData: OrganizationMember = {
org_id: orgId,
user_id: "1",
email: "admin@example.com",
role: "admin",
status: "active",
llm_api_key: "",
max_iterations: 20,
llm_model: "",
llm_api_key_for_byor: null,
llm_base_url: "",
};
mockUseConfig.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(adminMeData);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [
createMockOrganization({
id: orgId,
name: "Team Org",
is_personal: false,
}),
],
currentOrgId: orgId,
});
// Act
renderLlmSettingsScreen(orgId, adminMeData);
// Assert
await waitFor(() => {
expect(
screen.getByTestId("llm-settings-info-message"),
).toBeInTheDocument();
});
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
"SETTINGS$LLM_ADMIN_INFO",
);
});
it("should show member message when user is a member in a team organization", async () => {
// Arrange
const orgId = "team-org-2";
const memberMeData: OrganizationMember = {
org_id: orgId,
user_id: "2",
email: "member@example.com",
role: "member",
status: "active",
llm_api_key: "",
max_iterations: 20,
llm_model: "",
llm_api_key_for_byor: null,
llm_base_url: "",
};
mockUseConfig.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(memberMeData);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [
createMockOrganization({
id: orgId,
name: "Team Org",
is_personal: false,
}),
],
currentOrgId: orgId,
});
// Act
renderLlmSettingsScreen(orgId, memberMeData);
// Assert
await waitFor(() => {
expect(
screen.getByTestId("llm-settings-info-message"),
).toBeInTheDocument();
});
expect(screen.getByTestId("llm-settings-info-message")).toHaveTextContent(
"SETTINGS$LLM_MEMBER_INFO",
);
});
it("should not show info message in personal workspace", async () => {
// Arrange
const orgId = "personal-org-1";
const ownerMeData: OrganizationMember = {
org_id: orgId,
user_id: "3",
email: "user@example.com",
role: "owner",
status: "active",
llm_api_key: "",
max_iterations: 20,
llm_model: "",
llm_api_key_for_byor: null,
llm_base_url: "",
};
mockUseConfig.mockReturnValue({
data: { app_mode: "saas" },
isLoading: false,
});
vi.spyOn(organizationService, "getMe").mockResolvedValue(ownerMeData);
vi.spyOn(organizationService, "getOrganizations").mockResolvedValue({
items: [
createMockOrganization({ id: orgId, name: "Personal", is_personal: true }),
],
currentOrgId: orgId,
});
// Act
renderLlmSettingsScreen(orgId, ownerMeData);
// Assert
await waitFor(() => {
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
});
expect(
screen.queryByTestId("llm-settings-info-message"),
).not.toBeInTheDocument();
});
it("should not show info message in OSS mode", async () => {
// Arrange
mockUseConfig.mockReturnValue({
data: { app_mode: "oss" },
isLoading: false,
});
// Act
renderLlmSettingsScreen();
// Assert
await waitFor(() => {
expect(screen.getByTestId("llm-settings-screen")).toBeInTheDocument();
});
expect(
screen.queryByTestId("llm-settings-info-message"),
).not.toBeInTheDocument();
});
});

View File

@@ -73,6 +73,11 @@ vi.mock("#/hooks/use-invitation", () => ({
useInvitation: () => useInvitationMock(),
}));
// Mock feature flags - enable by default for tests
vi.mock("#/utils/feature-flags", () => ({
ENABLE_PROJ_USER_JOURNEY: () => true,
}));
const RouterStub = createRoutesStub([
{
Component: LoginPage,

View File

@@ -0,0 +1,65 @@
import { screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "test-utils";
import VSCodeTab from "#/routes/vscode-tab";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { useAgentState } from "#/hooks/use-agent-state";
import { AgentState } from "#/types/agent-state";
vi.mock("#/hooks/query/use-unified-vscode-url");
vi.mock("#/hooks/use-agent-state");
vi.mock("#/utils/feature-flags", () => ({
VSCODE_IN_NEW_TAB: () => false,
}));
function mockVSCodeUrlHook(
value: Partial<ReturnType<typeof useUnifiedVSCodeUrl>>,
) {
vi.mocked(useUnifiedVSCodeUrl).mockReturnValue({
data: { url: "http://localhost:3000/vscode", error: null },
error: null,
isLoading: false,
isError: false,
isSuccess: true,
status: "success",
refetch: vi.fn(),
...value,
} as ReturnType<typeof useUnifiedVSCodeUrl>);
}
describe("VSCodeTab", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("keeps VSCode accessible when the agent is in an error state", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.ERROR,
});
mockVSCodeUrlHook({});
renderWithProviders(<VSCodeTab />);
expect(
screen.queryByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
).not.toBeInTheDocument();
expect(screen.getByTitle("VSCODE$TITLE")).toHaveAttribute(
"src",
"http://localhost:3000/vscode",
);
});
it("still waits while the runtime is starting", () => {
vi.mocked(useAgentState).mockReturnValue({
curAgentState: AgentState.LOADING,
});
mockVSCodeUrlHook({});
renderWithProviders(<VSCodeTab />);
expect(
screen.getByText("DIFF_VIEWER$WAITING_FOR_RUNTIME"),
).toBeInTheDocument();
expect(screen.queryByTitle("VSCODE$TITLE")).not.toBeInTheDocument();
});
});

View File

@@ -1,8 +1,13 @@
import { render, screen } from "@testing-library/react";
import { test, expect, describe, vi } from "vitest";
import { test, expect, describe, vi, beforeEach } from "vitest";
import { MemoryRouter } from "react-router";
import { InteractiveChatBox } from "#/components/features/chat/interactive-chat-box";
import { renderWithProviders } from "../../test-utils";
import { useSelectedOrganizationStore } from "#/stores/selected-organization-store";
beforeEach(() => {
useSelectedOrganizationStore.setState({ organizationId: "test-org-id" });
});
// Mock the translation function
vi.mock("react-i18next", async () => {
@@ -29,14 +34,12 @@ vi.mock("#/hooks/query/use-active-conversation", () => ({
}));
// Mock React Router hooks
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
};
});
vi.mock("react-router", async (importOriginal) => ({
...(await importOriginal<typeof import("react-router")>()),
useNavigate: () => vi.fn(),
useParams: () => ({ conversationId: "test-conversation-id" }),
useRevalidator: () => ({ revalidate: vi.fn() }),
}));
// Mock other hooks that might be used by the component
vi.mock("#/hooks/use-user-providers", () => ({

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
LOCAL_STORAGE_KEYS,
LoginMethod,
setLoginMethod,
getLoginMethod,
clearLoginData,
setCTADismissed,
isCTADismissed,
} from "#/utils/local-storage";
describe("local-storage utilities", () => {
beforeEach(() => {
localStorage.clear();
});
describe("Login method utilities", () => {
describe("setLoginMethod", () => {
it("stores the login method in local storage", () => {
setLoginMethod(LoginMethod.GITHUB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("github");
});
it("stores different login methods correctly", () => {
setLoginMethod(LoginMethod.GITLAB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
setLoginMethod(LoginMethod.BITBUCKET);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket");
setLoginMethod(LoginMethod.AZURE_DEVOPS);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("azure_devops");
setLoginMethod(LoginMethod.ENTERPRISE_SSO);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("enterprise_sso");
setLoginMethod(LoginMethod.BITBUCKET_DATA_CENTER);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("bitbucket_data_center");
});
it("overwrites previous login method", () => {
setLoginMethod(LoginMethod.GITHUB);
setLoginMethod(LoginMethod.GITLAB);
expect(localStorage.getItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD)).toBe("gitlab");
});
});
describe("getLoginMethod", () => {
it("returns null when no login method is set", () => {
expect(getLoginMethod()).toBeNull();
});
it("returns the stored login method", () => {
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "github");
expect(getLoginMethod()).toBe(LoginMethod.GITHUB);
});
it("returns correct login method for all types", () => {
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "gitlab");
expect(getLoginMethod()).toBe(LoginMethod.GITLAB);
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "bitbucket");
expect(getLoginMethod()).toBe(LoginMethod.BITBUCKET);
localStorage.setItem(LOCAL_STORAGE_KEYS.LOGIN_METHOD, "azure_devops");
expect(getLoginMethod()).toBe(LoginMethod.AZURE_DEVOPS);
});
});
describe("clearLoginData", () => {
it("removes the login method from local storage", () => {
setLoginMethod(LoginMethod.GITHUB);
expect(getLoginMethod()).toBe(LoginMethod.GITHUB);
clearLoginData();
expect(getLoginMethod()).toBeNull();
});
it("does not throw when no login method is set", () => {
expect(() => clearLoginData()).not.toThrow();
});
});
});
describe("CTA utilities", () => {
describe("isCTADismissed", () => {
it("returns false when CTA has not been dismissed", () => {
expect(isCTADismissed("homepage")).toBe(false);
});
it("returns true when CTA has been dismissed", () => {
localStorage.setItem("homepage-cta-dismissed", "true");
expect(isCTADismissed("homepage")).toBe(true);
});
it("returns false when storage value is not 'true'", () => {
localStorage.setItem("homepage-cta-dismissed", "false");
expect(isCTADismissed("homepage")).toBe(false);
localStorage.setItem("homepage-cta-dismissed", "invalid");
expect(isCTADismissed("homepage")).toBe(false);
});
});
describe("setCTADismissed", () => {
it("sets the CTA as dismissed in local storage", () => {
setCTADismissed("homepage");
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
});
it("generates correct key for homepage location", () => {
setCTADismissed("homepage");
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
});
});
describe("storage key format", () => {
it("uses the correct key format: {location}-cta-dismissed", () => {
setCTADismissed("homepage");
// Verify key exists with correct format
expect(localStorage.getItem("homepage-cta-dismissed")).toBe("true");
// Verify other keys don't exist
expect(localStorage.getItem("cta-dismissed")).toBeNull();
expect(localStorage.getItem("homepage")).toBeNull();
});
});
describe("persistence", () => {
it("dismissed state persists across multiple reads", () => {
setCTADismissed("homepage");
expect(isCTADismissed("homepage")).toBe(true);
expect(isCTADismissed("homepage")).toBe(true);
expect(isCTADismissed("homepage")).toBe(true);
});
});
});
});

View File

@@ -1,10 +1,18 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import { PermissionKey } from "#/utils/org/permissions";
import { OrganizationMember, OrganizationsQueryData } from "#/types/org";
import {
getAvailableRolesAUserCanAssign,
getActiveOrganizationUser,
} from "#/utils/org/permission-checks";
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
import { queryClient } from "#/query-client-config";
// Mock dependencies for getActiveOrganizationUser tests
// Mock dependencies
vi.mock("#/api/organization-service/organization-service.api", () => ({
organizationService: {
getMe: vi.fn(),
getOrganizations: vi.fn(),
},
}));
@@ -12,49 +20,60 @@ vi.mock("#/stores/selected-organization-store", () => ({
getSelectedOrganizationIdFromStore: vi.fn(),
}));
vi.mock("#/utils/query-client-getters", () => ({
getMeFromQueryClient: vi.fn(),
}));
vi.mock("#/query-client-config", () => ({
queryClient: {
getQueryData: vi.fn(),
fetchQuery: vi.fn(),
setQueryData: vi.fn(),
},
}));
// Import after mocks are set up
import {
getAvailableRolesAUserCanAssign,
getActiveOrganizationUser,
} from "#/utils/org/permission-checks";
import { organizationService } from "#/api/organization-service/organization-service.api";
import { getSelectedOrganizationIdFromStore } from "#/stores/selected-organization-store";
import { getMeFromQueryClient } from "#/utils/query-client-getters";
// Test fixtures
const mockUser: OrganizationMember = {
org_id: "org-1",
user_id: "user-1",
email: "test@example.com",
role: "admin",
llm_api_key: "",
max_iterations: 100,
llm_model: "gpt-4",
llm_api_key_for_byor: null,
llm_base_url: "",
status: "active",
};
const mockOrganizationsData: OrganizationsQueryData = {
items: [
{ id: "org-1", name: "Org 1" },
{ id: "org-2", name: "Org 2" },
] as OrganizationsQueryData["items"],
currentOrgId: "org-1",
};
describe("getAvailableRolesAUserCanAssign", () => {
it("returns empty array if user has no permissions", () => {
const result = getAvailableRolesAUserCanAssign([]);
expect(result).toEqual([]);
});
it("returns empty array if user has no permissions", () => {
const result = getAvailableRolesAUserCanAssign([]);
expect(result).toEqual([]);
});
it("returns only roles the user has permission for", () => {
const userPermissions: PermissionKey[] = [
"change_user_role:member",
"change_user_role:admin",
];
const result = getAvailableRolesAUserCanAssign(userPermissions);
expect(result.sort()).toEqual(["admin", "member"].sort());
});
it("returns only roles the user has permission for", () => {
const userPermissions: PermissionKey[] = [
"change_user_role:member",
"change_user_role:admin",
];
const result = getAvailableRolesAUserCanAssign(userPermissions);
expect(result.sort()).toEqual(["admin", "member"].sort());
});
it("returns all roles if user has all permissions", () => {
const allPermissions: PermissionKey[] = [
"change_user_role:member",
"change_user_role:admin",
"change_user_role:owner",
];
const result = getAvailableRolesAUserCanAssign(allPermissions);
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
});
it("returns all roles if user has all permissions", () => {
const allPermissions: PermissionKey[] = [
"change_user_role:member",
"change_user_role:admin",
"change_user_role:owner",
];
const result = getAvailableRolesAUserCanAssign(allPermissions);
expect(result.sort()).toEqual(["member", "admin", "owner"].sort());
});
});
describe("getActiveOrganizationUser", () => {
@@ -62,18 +81,147 @@ describe("getActiveOrganizationUser", () => {
vi.clearAllMocks();
});
it("should return undefined when API call throws an error", async () => {
// Arrange: orgId exists, cache is empty, API call fails
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
vi.mocked(getMeFromQueryClient).mockReturnValue(undefined);
vi.mocked(organizationService.getMe).mockRejectedValue(
new Error("Network error"),
);
describe("when orgId exists in store", () => {
it("should fetch user directly using stored orgId", async () => {
// Arrange
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
// Act
const result = await getActiveOrganizationUser();
// Act
const result = await getActiveOrganizationUser();
// Assert: should return undefined instead of propagating the error
expect(result).toBeUndefined();
// Assert
expect(result).toEqual(mockUser);
expect(queryClient.getQueryData).not.toHaveBeenCalled();
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ["organizations", "org-1", "me"],
}),
);
});
it("should return undefined when user fetch fails", async () => {
// Arrange
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue("org-1");
vi.mocked(queryClient.fetchQuery).mockRejectedValue(
new Error("Network error"),
);
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toBeUndefined();
});
});
describe("when orgId is null in store (page refresh scenario)", () => {
beforeEach(() => {
vi.mocked(getSelectedOrganizationIdFromStore).mockReturnValue(null);
});
it("should use currentOrgId from cached organizations data", async () => {
// Arrange
vi.mocked(queryClient.getQueryData).mockReturnValue(
mockOrganizationsData,
);
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toEqual(mockUser);
expect(queryClient.getQueryData).toHaveBeenCalledWith(["organizations"]);
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ["organizations", "org-1", "me"],
}),
);
});
it("should fallback to first org when currentOrgId is null", async () => {
// Arrange
const dataWithoutCurrentOrg: OrganizationsQueryData = {
items: [
{ id: "first-org" },
{ id: "second-org" },
] as OrganizationsQueryData["items"],
currentOrgId: null,
};
vi.mocked(queryClient.getQueryData).mockReturnValue(
dataWithoutCurrentOrg,
);
vi.mocked(queryClient.fetchQuery).mockResolvedValue(mockUser);
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toEqual(mockUser);
expect(queryClient.fetchQuery).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: ["organizations", "first-org", "me"],
}),
);
});
it("should fetch organizations when not in cache", async () => {
// Arrange
vi.mocked(queryClient.getQueryData).mockReturnValue(undefined);
vi.mocked(queryClient.fetchQuery)
.mockResolvedValueOnce(mockOrganizationsData) // First call: fetch organizations
.mockResolvedValueOnce(mockUser); // Second call: fetch user
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toEqual(mockUser);
expect(queryClient.fetchQuery).toHaveBeenCalledTimes(2);
expect(queryClient.fetchQuery).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
queryKey: ["organizations"],
}),
);
expect(queryClient.fetchQuery).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
queryKey: ["organizations", "org-1", "me"],
}),
);
});
it("should return undefined when fetching organizations fails", async () => {
// Arrange
vi.mocked(queryClient.getQueryData).mockReturnValue(undefined);
vi.mocked(queryClient.fetchQuery).mockRejectedValue(
new Error("Failed to fetch organizations"),
);
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toBeUndefined();
});
it("should return undefined when no organizations exist", async () => {
// Arrange
const emptyData: OrganizationsQueryData = {
items: [],
currentOrgId: null,
};
vi.mocked(queryClient.getQueryData).mockReturnValue(emptyData);
// Act
const result = await getActiveOrganizationUser();
// Assert
expect(result).toBeUndefined();
// Should not attempt to fetch user since there's no orgId
expect(queryClient.fetchQuery).not.toHaveBeenCalled();
});
});
});

View File

@@ -11,7 +11,7 @@ import { I18nKey } from "#/i18n/declaration";
// Mock translations
const t = (key: string) => {
const translations: { [key: string]: string } = {
COMMON$WAITING_FOR_SANDBOX: "Waiting For Sandbox",
COMMON$WAITING_FOR_SANDBOX: "Waiting for sandbox",
COMMON$STOPPING: "Stopping",
COMMON$STARTING: "Starting",
COMMON$SERVER_STOPPED: "Server stopped",
@@ -69,7 +69,7 @@ describe("getStatusText", () => {
t,
});
expect(result).toBe(t(I18nKey.COMMON$WAITING_FOR_SANDBOX));
expect(result).toBe("Waiting for sandbox");
});
it("returns task detail when task status is ERROR and detail exists", () => {

View File

@@ -68,6 +68,8 @@ class V1ConversationService {
trigger?: ConversationTrigger,
parent_conversation_id?: string,
agent_type?: "default" | "plan",
sandbox_id?: string,
llm_model?: string,
): Promise<V1AppConversationStartTask> {
const body: V1AppConversationStartRequest = {
selected_repository: selectedRepository,
@@ -78,6 +80,8 @@ class V1ConversationService {
trigger,
parent_conversation_id: parent_conversation_id || null,
agent_type,
sandbox_id: sandbox_id || null,
llm_model: llm_model || null,
};
// suggested_task implies the backend will construct the initial_message

View File

@@ -0,0 +1,11 @@
import { openHands } from "../open-hands-axios";
import { UsageStats } from "./usage.types";
export const usageService = {
getUsageStats: async ({ orgId }: { orgId: string }): Promise<UsageStats> => {
const { data } = await openHands.get<UsageStats>(
`/api/organizations/${orgId}/usage`,
);
return data;
},
};

View File

@@ -0,0 +1,11 @@
export interface DailyConversationCount {
date: string; // ISO date format YYYY-MM-DD
count: number;
}
export interface UsageStats {
total_conversations: number;
merged_prs: number;
average_cost: number;
daily_conversations: DailyConversationCount[];
}

View File

@@ -13,6 +13,9 @@ import { TermsAndPrivacyNotice } from "#/components/shared/terms-and-privacy-not
import { useRecaptcha } from "#/hooks/use-recaptcha";
import { useConfig } from "#/hooks/query/use-config";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { cn } from "#/utils/utils";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
import { LoginCTA } from "./login-cta";
export interface LoginContentProps {
githubAuthUrl: string | null;
@@ -177,125 +180,133 @@ export function LoginContent({
return (
<div
className="flex flex-col items-center w-full gap-12.5"
data-testid="login-content"
className={cn(
"flex flex-col md:flex-row items-center md:items-stretch gap-6 h-full",
)}
>
<div>
<OpenHandsLogoWhite width={106} height={72} />
</div>
<div
className={cn("flex flex-col items-center w-full gap-12.5")}
data-testid="login-content"
>
<div>
<OpenHandsLogoWhite width={106} height={72} />
</div>
<h1 className="text-[39px] leading-5 font-medium text-white text-center">
{t(I18nKey.AUTH$LETS_GET_STARTED)}
</h1>
<h1 className="text-[39px] leading-5 font-medium text-white text-center">
{t(I18nKey.AUTH$LETS_GET_STARTED)}
</h1>
{shouldShownHelperText && (
<div className="flex flex-col items-center gap-3">
{emailVerified && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
</p>
)}
{hasDuplicatedEmail && (
<p className="text-sm text-danger text-center">
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
</p>
)}
{recaptchaBlocked && (
<p className="text-sm text-danger text-center max-w-125">
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
</p>
)}
{hasInvitation && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$INVITATION_PENDING)}
</p>
)}
{showBitbucket && (
<p className="text-sm text-white text-center max-w-125">
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
</p>
)}
</div>
)}
{shouldShownHelperText && (
<div className="flex flex-col items-center gap-3">
{emailVerified && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$EMAIL_VERIFIED_PLEASE_LOGIN)}
</p>
)}
{hasDuplicatedEmail && (
<p className="text-sm text-danger text-center">
{t(I18nKey.AUTH$DUPLICATE_EMAIL_ERROR)}
</p>
)}
{recaptchaBlocked && (
<p className="text-sm text-danger text-center max-w-125">
{t(I18nKey.AUTH$RECAPTCHA_BLOCKED)}
</p>
)}
{hasInvitation && (
<p className="text-sm text-muted-foreground text-center">
{t(I18nKey.AUTH$INVITATION_PENDING)}
</p>
)}
{showBitbucket && (
<p className="text-sm text-white text-center max-w-125">
{t(I18nKey.AUTH$BITBUCKET_SIGNUP_DISABLED)}
</p>
{noProvidersConfigured ? (
<div className="text-center p-4 text-muted-foreground">
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
</div>
) : (
<>
{showGithub && (
<button
type="button"
onClick={handleGitHubAuth}
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
>
<GitHubLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</span>
</button>
)}
{showGitlab && (
<button
type="button"
onClick={handleGitLabAuth}
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
>
<GitLabLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</span>
</button>
)}
{showBitbucket && (
<button
type="button"
onClick={handleBitbucketAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</span>
</button>
)}
{showBitbucketDataCenter && (
<button
type="button"
onClick={handleBitbucketDataCenterAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
)}
</span>
</button>
)}
{showEnterpriseSso && (
<button
type="button"
onClick={handleEnterpriseSsoAuth}
className={`${buttonBaseClasses} bg-[#374151] text-white`}
>
<FaUserShield size={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</span>
</button>
)}
</>
)}
</div>
)}
<div className="flex flex-col items-center gap-3">
{noProvidersConfigured ? (
<div className="text-center p-4 text-muted-foreground">
{t(I18nKey.AUTH$NO_PROVIDERS_CONFIGURED)}
</div>
) : (
<>
{showGithub && (
<button
type="button"
onClick={handleGitHubAuth}
className={`${buttonBaseClasses} bg-[#9E28B0] text-white`}
>
<GitHubLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITHUB$CONNECT_TO_GITHUB)}
</span>
</button>
)}
{showGitlab && (
<button
type="button"
onClick={handleGitLabAuth}
className={`${buttonBaseClasses} bg-[#FC6B0E] text-white`}
>
<GitLabLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.GITLAB$CONNECT_TO_GITLAB)}
</span>
</button>
)}
{showBitbucket && (
<button
type="button"
onClick={handleBitbucketAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.BITBUCKET$CONNECT_TO_BITBUCKET)}
</span>
</button>
)}
{showBitbucketDataCenter && (
<button
type="button"
onClick={handleBitbucketDataCenterAuth}
className={`${buttonBaseClasses} bg-[#2684FF] text-white`}
>
<BitbucketLogo width={14} height={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(
I18nKey.BITBUCKET_DATA_CENTER$CONNECT_TO_BITBUCKET_DATA_CENTER,
)}
</span>
</button>
)}
{showEnterpriseSso && (
<button
type="button"
onClick={handleEnterpriseSsoAuth}
className={`${buttonBaseClasses} bg-[#374151] text-white`}
>
<FaUserShield size={14} className="shrink-0" />
<span className={buttonLabelClasses}>
{t(I18nKey.ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO)}
</span>
</button>
)}
</>
)}
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
</div>
<TermsAndPrivacyNotice className="max-w-[320px] text-[#A3A3A3]" />
{appMode === "saas" && ENABLE_PROJ_USER_JOURNEY() && <LoginCTA />}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { useTranslation } from "react-i18next";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import { cn } from "#/utils/utils";
import StackedIcon from "#/icons/stacked.svg?react";
import { useTracking } from "#/hooks/use-tracking";
export function LoginCTA() {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "login_page" });
};
return (
<Card
testId="login-cta"
theme="dark"
className={cn("w-full max-w-80 h-auto flex-col", "cta-card-gradient")}
>
<div className={cn("flex flex-col gap-[11px] p-6")}>
<div className={cn("size-10")}>
<StackedIcon width={40} height={40} />
</div>
<CardTitle>{t(I18nKey.CTA$ENTERPRISE)}</CardTitle>
<Typography.Text className="text-[#8C8C8C] font-inter font-normal text-sm leading-5">
{t(I18nKey.CTA$ENTERPRISE_DEPLOY)}
</Typography.Text>
<ul
className={cn(
"text-[#8C8C8C] font-inter font-normal text-sm leading-5 list-disc list-inside flex flex-col gap-1",
)}
>
<li>{t(I18nKey.CTA$FEATURE_ON_PREMISES)}</li>
<li>{t(I18nKey.CTA$FEATURE_DATA_CONTROL)}</li>
<li>{t(I18nKey.CTA$FEATURE_COMPLIANCE)}</li>
<li>{t(I18nKey.CTA$FEATURE_SUPPORT)}</li>
</ul>
<div className={cn("h-10 flex justify-start")}>
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#0a0a0a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</div>
</Card>
);
}

View File

@@ -38,6 +38,8 @@ import { useTaskPolling } from "#/hooks/query/use-task-polling";
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
import ChatStatusIndicator from "./chat-status-indicator";
import { getStatusColor, getStatusText } from "#/utils/utils";
import { useNewConversationCommand } from "#/hooks/mutation/use-new-conversation-command";
import { I18nKey } from "#/i18n/declaration";
function getEntryPoint(
hasRepository: boolean | null,
@@ -80,6 +82,10 @@ export function ChatInterface() {
setHitBottom,
} = useScrollToBottom(scrollRef);
const { data: config } = useConfig();
const {
mutate: newConversationCommand,
isPending: isNewConversationPending,
} = useNewConversationCommand();
const { curAgentState } = useAgentState();
const { handleBuildPlanClick } = useHandleBuildPlanClick();
@@ -146,6 +152,27 @@ export function ChatInterface() {
originalImages: File[],
originalFiles: File[],
) => {
// Handle /new command for V1 conversations
if (content.trim() === "/new") {
if (!isV1Conversation) {
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_V1_ONLY));
return;
}
if (!params.conversationId) {
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_NO_ID));
return;
}
if (totalEvents === 0) {
displayErrorToast(t(I18nKey.CONVERSATION$CLEAR_EMPTY));
return;
}
if (isNewConversationPending) {
return;
}
newConversationCommand();
return;
}
// Create mutable copies of the arrays
const images = [...originalImages];
const files = [...originalFiles];
@@ -338,7 +365,10 @@ export function ChatInterface() {
/>
)}
<InteractiveChatBox onSubmit={handleSendMessage} />
<InteractiveChatBox
onSubmit={handleSendMessage}
disabled={isNewConversationPending}
/>
</div>
{config?.app_mode !== "saas" && !isV1Conversation && (

View File

@@ -12,6 +12,7 @@ interface ChatInputContainerProps {
chatContainerRef: React.RefObject<HTMLDivElement | null>;
isDragOver: boolean;
disabled: boolean;
isNewConversationPending?: boolean;
showButton: boolean;
buttonClassName: string;
chatInputRef: React.RefObject<HTMLDivElement | null>;
@@ -36,6 +37,7 @@ export function ChatInputContainer({
chatContainerRef,
isDragOver,
disabled,
isNewConversationPending = false,
showButton,
buttonClassName,
chatInputRef,
@@ -89,6 +91,7 @@ export function ChatInputContainer({
<ChatInputRow
chatInputRef={chatInputRef}
disabled={disabled}
isNewConversationPending={isNewConversationPending}
showButton={showButton}
buttonClassName={buttonClassName}
handleFileIconClick={handleFileIconClick}

View File

@@ -2,9 +2,11 @@ import React from "react";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { useConversationStore } from "#/stores/conversation-store";
import { cn } from "#/utils/utils";
interface ChatInputFieldProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
disabled?: boolean;
onInput: () => void;
onPaste: (e: React.ClipboardEvent) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
@@ -14,6 +16,7 @@ interface ChatInputFieldProps {
export function ChatInputField({
chatInputRef,
disabled = false,
onInput,
onPaste,
onKeyDown,
@@ -36,8 +39,11 @@ export function ChatInputField({
<div className="basis-0 flex flex-col font-normal grow justify-center leading-[0] min-h-px min-w-px overflow-ellipsis overflow-hidden relative shrink-0 text-[#d0d9fa] text-[16px] text-left">
<div
ref={chatInputRef}
className="chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap"
contentEditable
className={cn(
"chat-input bg-transparent text-white text-[16px] font-normal leading-[20px] outline-none resize-none custom-scrollbar min-h-[20px] max-h-[400px] [text-overflow:inherit] [text-wrap-mode:inherit] [white-space-collapse:inherit] block whitespace-pre-wrap",
disabled && "cursor-not-allowed opacity-50",
)}
contentEditable={!disabled}
data-placeholder={
isPlanMode
? t(I18nKey.COMMON$LET_S_WORK_ON_A_PLAN)

View File

@@ -7,6 +7,7 @@ import { ChatInputField } from "./chat-input-field";
interface ChatInputRowProps {
chatInputRef: React.RefObject<HTMLDivElement | null>;
disabled: boolean;
isNewConversationPending?: boolean;
showButton: boolean;
buttonClassName: string;
handleFileIconClick: (isDisabled: boolean) => void;
@@ -21,6 +22,7 @@ interface ChatInputRowProps {
export function ChatInputRow({
chatInputRef,
disabled,
isNewConversationPending = false,
showButton,
buttonClassName,
handleFileIconClick,
@@ -41,6 +43,7 @@ export function ChatInputRow({
<ChatInputField
chatInputRef={chatInputRef}
disabled={isNewConversationPending}
onInput={onInput}
onPaste={onPaste}
onKeyDown={onKeyDown}

View File

@@ -13,6 +13,7 @@ import { useConversationStore } from "#/stores/conversation-store";
export interface CustomChatInputProps {
disabled?: boolean;
isNewConversationPending?: boolean;
showButton?: boolean;
conversationStatus?: ConversationStatus | null;
onSubmit: (message: string) => void;
@@ -25,6 +26,7 @@ export interface CustomChatInputProps {
export function CustomChatInput({
disabled = false,
isNewConversationPending = false,
showButton = true,
conversationStatus = null,
onSubmit,
@@ -147,6 +149,7 @@ export function CustomChatInput({
chatContainerRef={chatContainerRef}
isDragOver={isDragOver}
disabled={isDisabled}
isNewConversationPending={isNewConversationPending}
showButton={showButton}
buttonClassName={buttonClassName}
chatInputRef={chatInputRef}

View File

@@ -13,9 +13,13 @@ import { isTaskPolling } from "#/utils/utils";
interface InteractiveChatBoxProps {
onSubmit: (message: string, images: File[], files: File[]) => void;
disabled?: boolean;
}
export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
export function InteractiveChatBox({
onSubmit,
disabled = false,
}: InteractiveChatBoxProps) {
const {
images,
files,
@@ -145,6 +149,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
// Allow users to submit messages during LOADING state - they will be
// queued server-side and delivered when the conversation becomes ready
const isDisabled =
disabled ||
curAgentState === AgentState.AWAITING_USER_CONFIRMATION ||
isTaskPolling(subConversationTaskStatus);
@@ -152,6 +157,7 @@ export function InteractiveChatBox({ onSubmit }: InteractiveChatBoxProps) {
<div data-testid="interactive-chat-box">
<CustomChatInput
disabled={isDisabled}
isNewConversationPending={disabled}
onSubmit={handleSubmit}
onFilesPaste={handleUpload}
conversationStatus={conversation?.status || null}

View File

@@ -0,0 +1,39 @@
import React from "react";
import { cn } from "#/utils/utils";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
interface ContextMenuContainerProps {
children: React.ReactNode;
onClose: () => void;
testId?: string;
className?: string;
}
export function ContextMenuContainer({
children,
onClose,
testId,
className,
}: ContextMenuContainerProps) {
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
return (
<div
ref={ref}
data-testid={testId}
className={cn(
// Base styling - same for ALL modes (SaaS, OSS, mobile, desktop)
"absolute rounded-[12px] p-[25px]",
"bg-[#050505] border border-[#242424]",
"text-white overflow-hidden z-[9999]",
"context-menu-box-shadow",
// Positioning
"right-0 md:right-auto md:left-full md:bottom-0",
"w-fit",
className,
)}
>
<div className="flex flex-row gap-4 items-stretch">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useTranslation } from "react-i18next";
import { cn } from "#/utils/utils";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { I18nKey } from "#/i18n/declaration";
import StackedIcon from "#/icons/stacked.svg?react";
import { useTracking } from "#/hooks/use-tracking";
export function ContextMenuCTA() {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "context_menu" });
};
return (
<Card
testId="context-menu-cta"
theme="dark"
className={cn(
"w-[286px] min-h-[200px] rounded-[6px]",
"flex-col justify-end",
"cta-card-gradient",
)}
>
<div
data-testid="context-menu-cta-content"
className={cn("flex flex-col gap-[11px] p-[25px]")}
>
<StackedIcon width={40} height={40} />
<CardTitle>{t(I18nKey.CTA$ENTERPRISE_TITLE)}</CardTitle>
<Typography.Text
className={cn(
"text-[#8C8C8C] font-inter font-normal",
"text-[14px] leading-[20px]",
)}
>
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
</Typography.Text>
<div className="flex mt-auto">
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"h-[40px] px-4 rounded-[4px]",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#0a0a0a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</div>
</Card>
);
}

View File

@@ -20,7 +20,7 @@ export function ConversationTabTitle({
conversationKey,
}: ConversationTabTitleProps) {
const { t } = useTranslation();
const { refetch } = useUnifiedGetGitChanges();
const { refetch, isFetching } = useUnifiedGetGitChanges();
const { handleBuildPlanClick } = useHandleBuildPlanClick();
const { curAgentState } = useAgentState();
const { planContent } = useConversationStore();
@@ -41,10 +41,16 @@ export function ConversationTabTitle({
{conversationKey === "editor" && (
<button
type="button"
className="flex w-[26px] py-1 justify-center items-center gap-[10px] rounded-[7px] hover:bg-[#474A54] cursor-pointer"
className="flex w-[26px] py-1 justify-center items-center gap-[10px] rounded-[7px] hover:enabled:bg-[#474A54] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleRefresh}
disabled={isFetching}
>
<RefreshIcon width={12.75} height={15} color="#ffffff" />
<RefreshIcon
width={12.75}
height={15}
color="#ffffff"
className={isFetching ? "animate-spin" : ""}
/>
</button>
)}
{conversationKey === "planner" && (

View File

@@ -1,14 +1,15 @@
import { FaExternalLinkAlt } from "react-icons/fa";
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import { RUNTIME_INACTIVE_STATES } from "#/types/agent-state";
import { useAgentState } from "#/hooks/use-agent-state";
import { useUnifiedVSCodeUrl } from "#/hooks/query/use-unified-vscode-url";
import { RUNTIME_STARTING_STATES } from "#/types/agent-state";
export function VSCodeTooltipContent() {
const { curAgentState } = useAgentState();
const { t } = useTranslation();
const { data, refetch } = useUnifiedVSCodeUrl();
const isRuntimeStarting = RUNTIME_STARTING_STATES.includes(curAgentState);
const handleVSCodeClick = async (e: React.MouseEvent) => {
e.preventDefault();
@@ -29,7 +30,7 @@ export function VSCodeTooltipContent() {
return (
<div className="flex items-center gap-2">
<span>{t(I18nKey.COMMON$CODE)}</span>
{!RUNTIME_INACTIVE_STATES.includes(curAgentState) ? (
{!isRuntimeStarting ? (
<FaExternalLinkAlt
className="w-3 h-3 text-inherit cursor-pointer"
onClick={handleVSCodeClick}

View File

@@ -3,7 +3,7 @@ import { usePostHog } from "posthog-js/react";
import { I18nKey } from "#/i18n/declaration";
import { H2, Text } from "#/ui/typography";
import CheckCircleFillIcon from "#/icons/check-circle-fill.svg?react";
import { PROJ_USER_JOURNEY } from "#/utils/feature-flags";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
const ENTERPRISE_FEATURE_KEYS: I18nKey[] = [
I18nKey.ENTERPRISE$FEATURE_DATA_PRIVACY,
@@ -16,7 +16,7 @@ export function EnterpriseBanner() {
const { t } = useTranslation();
const posthog = usePostHog();
if (!PROJ_USER_JOURNEY()) {
if (!ENABLE_PROJ_USER_JOURNEY()) {
return null;
}

View File

@@ -0,0 +1,75 @@
import { useTranslation } from "react-i18next";
import { Dispatch, SetStateAction } from "react";
import { Card } from "#/ui/card";
import { CardTitle } from "#/ui/card-title";
import { Typography } from "#/ui/typography";
import { cn } from "#/utils/utils";
import { I18nKey } from "#/i18n/declaration";
import { setCTADismissed } from "#/utils/local-storage";
import { useTracking } from "#/hooks/use-tracking";
import CloseIcon from "#/icons/close.svg?react";
interface HomepageCTAProps {
setShouldShowCTA: Dispatch<SetStateAction<boolean>>;
}
export function HomepageCTA({ setShouldShowCTA }: HomepageCTAProps) {
const { t } = useTranslation();
const { trackSaasSelfhostedInquiry } = useTracking();
const handleClose = () => {
setCTADismissed("homepage");
setShouldShowCTA(false);
};
const handleLearnMoreClick = () => {
trackSaasSelfhostedInquiry({ location: "home_page" });
};
return (
<Card theme="dark" className={cn("w-[320px] cta-card-gradient")}>
<button
type="button"
onClick={handleClose}
className={cn(
"absolute top-3 right-3 size-7 rounded-full",
"border border-[#242424] bg-[#0A0A0A]",
"flex items-center justify-center",
"text-white/60 hover:text-white cursor-pointer",
"shadow-[0px_1px_2px_-1px_#0000001A,0px_1px_3px_0px_#0000001A]",
)}
aria-label="Close"
>
<CloseIcon width={16} height={16} />
</button>
<div className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-2">
<CardTitle className="font-inter font-semibold text-xl leading-7 tracking-normal text-[#FAFAFA]">
{t(I18nKey.CTA$ENTERPRISE_TITLE)}
</CardTitle>
<Typography.Text className="font-inter font-normal text-sm leading-5 tracking-normal text-[#8C8C8C]">
{t(I18nKey.CTA$ENTERPRISE_DESCRIPTION)}
</Typography.Text>
</div>
<a
href="https://openhands.dev/enterprise/"
target="_blank"
rel="noopener noreferrer"
onClick={handleLearnMoreClick}
className={cn(
"inline-flex items-center justify-center",
"w-fit h-10 px-4 rounded",
"bg-[#050505] border border-[#242424]",
"text-white hover:bg-[#1a1a1a]",
"font-semibold text-sm",
)}
>
{t(I18nKey.CTA$LEARN_MORE)}
</a>
</div>
</Card>
);
}

View File

@@ -10,7 +10,7 @@ export function NewConversation() {
const { t } = useTranslation();
return (
<Card>
<Card className="flex-col p-5 gap-2.5 min-h-[286px] md:min-h-auto w-full">
<CardTitle icon={<PlusIcon width={17} height={14} />}>
{t(I18nKey.COMMON$START_FROM_SCRATCH)}
</CardTitle>

View File

@@ -1,35 +1,56 @@
import { StepOption } from "./step-option";
import { StepInput } from "./step-input";
export interface Option {
id: string;
label: string;
}
export interface InputField {
id: string;
label: string;
}
interface StepContentProps {
options: Option[];
selectedOptionId: string | null;
options?: Option[];
inputFields?: InputField[];
selectedOptionIds: string[];
inputValues?: Record<string, string>;
onSelectOption: (optionId: string) => void;
onInputChange?: (fieldId: string, value: string) => void;
}
export function StepContent({
options,
selectedOptionId,
inputFields,
selectedOptionIds,
inputValues = {},
onSelectOption,
onInputChange,
}: StepContentProps) {
return (
<div
data-testid="step-content"
className="flex flex-col mt-8 mb-8 gap-[12px] w-full"
>
{options.map((option) => (
{options?.map((option) => (
<StepOption
key={option.id}
id={option.id}
label={option.label}
selected={selectedOptionId === option.id}
selected={selectedOptionIds.includes(option.id)}
onClick={() => onSelectOption(option.id)}
/>
))}
{inputFields?.map((field) => (
<StepInput
key={field.id}
id={field.id}
label={field.label}
value={inputValues[field.id] || ""}
onChange={(value) => onInputChange?.(field.id, value)}
/>
))}
</div>
);
}

View File

@@ -3,11 +3,17 @@ import { cn } from "#/utils/utils";
interface StepHeaderProps {
title: string;
subtitle?: string;
currentStep: number;
totalSteps: number;
}
function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) {
function StepHeader({
title,
subtitle,
currentStep,
totalSteps,
}: StepHeaderProps) {
return (
<div data-testid="step-header" className="flex flex-col items-center gap-2">
<div className="flex justify-center gap-2 mb-2">
@@ -24,6 +30,11 @@ function StepHeader({ title, currentStep, totalSteps }: StepHeaderProps) {
<Typography.Text className="text-2xl font-semibold text-content text-center">
{title}
</Typography.Text>
{subtitle && (
<Typography.Text className="text-sm text-neutral-400 text-center">
{subtitle}
</Typography.Text>
)}
</div>
);
}

View File

@@ -0,0 +1,27 @@
interface StepInputProps {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
}
export function StepInput({ id, label, value, onChange }: StepInputProps) {
return (
<div className="flex flex-col gap-1.5 w-full">
<label
htmlFor={`step-input-${id}`}
className="text-sm font-medium text-neutral-400 cursor-pointer"
>
{label}
</label>
<input
id={`step-input-${id}`}
data-testid={`step-input-${id}`}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-md border border-[#3a3a3a] bg-transparent px-4 py-2.5 text-sm text-white placeholder:text-neutral-500 focus:border-white focus:outline-none transition-colors"
/>
</div>
);
}

View File

@@ -56,6 +56,7 @@ export function OrgSelector() {
label: getOrgDisplayName(org),
})) || []
}
className="bg-[#1F1F1F66] border-[#242424]"
/>
);
}

View File

@@ -10,6 +10,7 @@ import { BrandButton } from "../brand-button";
import { useGetSecrets } from "#/hooks/query/use-get-secrets";
import { GetSecretsResponse } from "#/api/secrets-service.types";
import { OptionalTag } from "../optional-tag";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
interface SecretFormProps {
mode: "add" | "edit";
@@ -24,6 +25,7 @@ export function SecretForm({
}: SecretFormProps) {
const queryClient = useQueryClient();
const { t } = useTranslation();
const { organizationId } = useSelectedOrganizationId();
const { data: secrets } = useGetSecrets();
const { mutate: createSecret } = useCreateSecret();
@@ -49,7 +51,9 @@ export function SecretForm({
{
onSettled: onCancel,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["secrets"] });
await queryClient.invalidateQueries({
queryKey: ["secrets", organizationId],
});
},
},
);
@@ -61,7 +65,7 @@ export function SecretForm({
description?: string,
) => {
queryClient.setQueryData<GetSecretsResponse["custom_secrets"]>(
["secrets"],
["secrets", organizationId],
(oldSecrets) => {
if (!oldSecrets) return [];
return oldSecrets.map((secret) => {
@@ -79,7 +83,7 @@ export function SecretForm({
};
const revertOptimisticUpdate = () => {
queryClient.invalidateQueries({ queryKey: ["secrets"] });
queryClient.invalidateQueries({ queryKey: ["secrets", organizationId] });
};
const handleEditSecret = (

View File

@@ -9,7 +9,6 @@ import {
import { FiUsers } from "react-icons/fi";
import { useLogout } from "#/hooks/mutation/use-logout";
import { OrganizationUserRole } from "#/types/org";
import { useClickOutsideElement } from "#/hooks/use-click-outside-element";
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
import { cn } from "#/utils/utils";
import { OrgSelector } from "../org/org-selector";
@@ -18,11 +17,16 @@ import { useSettingsNavItems } from "#/hooks/use-settings-nav-items";
import DocumentIcon from "#/icons/document.svg?react";
import { Divider } from "#/ui/divider";
import { ContextMenuListItem } from "../context-menu/context-menu-list-item";
import { ContextMenuContainer } from "../context-menu/context-menu-container";
import { ContextMenuCTA } from "../context-menu/context-menu-cta";
import { useShouldHideOrgSelector } from "#/hooks/use-should-hide-org-selector";
import { useBreakpoint } from "#/hooks/use-breakpoint";
import { useConfig } from "#/hooks/query/use-config";
import { ENABLE_PROJ_USER_JOURNEY } from "#/utils/feature-flags";
// Shared className for context menu list items in the user context menu
const contextMenuListItemClassName = cn(
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded",
"flex items-center gap-2 p-2 h-auto hover:bg-white/10 hover:text-white rounded text-xs",
);
interface UserContextMenuProps {
@@ -40,9 +44,10 @@ export function UserContextMenu({
const navigate = useNavigate();
const { mutate: logout } = useLogout();
const { isPersonalOrg } = useOrgTypeAndAccess();
const ref = useClickOutsideElement<HTMLDivElement>(onClose);
const settingsNavItems = useSettingsNavItems();
const shouldHideSelector = useShouldHideOrgSelector();
const isMobile = useBreakpoint(768);
const { data: config } = useConfig();
// Filter out org routes since they're handled separately via buttons in this menu
const navItems = settingsNavItems.filter(
@@ -51,7 +56,10 @@ export function UserContextMenu({
);
const isMember = type === "member";
const isSaasMode = config?.app_mode === "saas";
// CTA only renders in SaaS desktop with feature flag enabled
const showCta = isSaasMode && !isMobile && ENABLE_PROJ_USER_JOURNEY();
const handleLogout = () => {
logout();
onClose();
@@ -73,96 +81,96 @@ export function UserContextMenu({
};
return (
<div
data-testid="user-context-menu"
ref={ref}
className={cn(
"w-72 flex flex-col gap-3 bg-tertiary border border-tertiary rounded-xl p-4 context-menu-box-shadow",
"text-sm absolute left-full bottom-0 z-101",
)}
>
<h3 className="text-lg font-semibold text-white">
{t(I18nKey.ORG$ACCOUNT)}
</h3>
<ContextMenuContainer testId="user-context-menu" onClose={onClose}>
<div className="flex flex-col gap-3 w-[248px]">
<h3 className="text-lg font-semibold text-white">
{t(I18nKey.ORG$ACCOUNT)}
</h3>
<div className="flex flex-col items-start gap-2">
{!shouldHideSelector && (
<div className="w-full relative">
<OrgSelector />
</div>
)}
<div className="flex flex-col items-start gap-2">
{!shouldHideSelector && (
<div className="w-full relative">
<OrgSelector />
</div>
)}
{!isMember && !isPersonalOrg && (
<div className="flex flex-col items-start gap-0 w-full">
<ContextMenuListItem
onClick={handleInviteMemberClick}
className={contextMenuListItemClassName}
>
<IoPersonAddOutline className="text-white" size={16} />
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
<ContextMenuListItem
onClick={handleManageAccountClick}
className={contextMenuListItemClassName}
>
<IoCardOutline className="text-white" size={16} />
{t(I18nKey.COMMON$ORGANIZATION)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleManageOrganizationMembersClick}
className={contextMenuListItemClassName}
>
<FiUsers className="text-white shrink-0" size={16} />
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
</div>
)}
{!isMember && !isPersonalOrg && (
<div className="flex flex-col items-start gap-0 w-full">
<ContextMenuListItem
onClick={handleInviteMemberClick}
className={contextMenuListItemClassName}
>
<IoPersonAddOutline className="text-white" size={14} />
{t(I18nKey.ORG$INVITE_ORG_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
<ContextMenuListItem
onClick={handleManageAccountClick}
className={contextMenuListItemClassName}
>
<IoCardOutline className="text-white" size={14} />
{t(I18nKey.COMMON$ORGANIZATION)}
</ContextMenuListItem>
<ContextMenuListItem
onClick={handleManageOrganizationMembersClick}
className={contextMenuListItemClassName}
>
<FiUsers className="text-white shrink-0" size={14} />
{t(I18nKey.ORG$ORGANIZATION_MEMBERS)}
</ContextMenuListItem>
<Divider className="my-1.5" />
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
>
{React.cloneElement(item.icon, {
className: "text-white",
width: 16,
height: 16,
} as React.SVGProps<SVGSVGElement>)}
{t(item.text)}
</Link>
))}
</div>
)}
<div className="flex flex-col items-start gap-0 w-full">
{navItems.map((item) => (
<Link
key={item.to}
to={item.to}
<Divider className="my-1.5" />
<div className="flex flex-col items-start gap-0 w-full">
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full text-xs"
>
{React.cloneElement(item.icon, {
className: "text-white",
width: 14,
height: 14,
} as React.SVGProps<SVGSVGElement>)}
{t(item.text)}
</Link>
))}
</div>
<DocumentIcon className="text-white" width={16} height={16} />
{t(I18nKey.SIDEBAR$DOCS)}
</a>
<Divider className="my-1.5" />
<div className="flex flex-col items-start gap-0 w-full">
<a
href="https://docs.openhands.dev"
target="_blank"
rel="noopener noreferrer"
onClick={onClose}
className="flex items-center gap-2 p-2 cursor-pointer hover:bg-white/10 hover:text-white rounded w-full"
>
<DocumentIcon className="text-white" width={14} height={14} />
{t(I18nKey.SIDEBAR$DOCS)}
</a>
<ContextMenuListItem
onClick={handleLogout}
className={contextMenuListItemClassName}
>
<IoLogOutOutline className="text-white" size={14} />
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
{/* Only show logout in saas mode - oss mode has no session to invalidate */}
{isSaasMode && (
<ContextMenuListItem
onClick={handleLogout}
className={contextMenuListItemClassName}
>
<IoLogOutOutline className="text-white" size={16} />
{t(I18nKey.ACCOUNT_SETTINGS$LOGOUT)}
</ContextMenuListItem>
)}
</div>
</div>
</div>
</div>
{showCta && <ContextMenuCTA />}
</ContextMenuContainer>
);
}

View File

@@ -21,6 +21,8 @@ import {
BrowserListTabsAction,
BrowserSwitchTabAction,
BrowserCloseTabAction,
GlobAction,
GrepAction,
} from "#/types/v1/core/base/action";
const getRiskText = (risk: SecurityRisk) => {
@@ -39,6 +41,24 @@ const getRiskText = (risk: SecurityRisk) => {
const getNoContentActionContent = (): string => "";
// Grep/Glob search actions
const getSearchActionContent = (
event: ActionEvent<GlobAction | GrepAction>,
): string => {
const { action } = event;
const parts: string[] = [];
if (action.pattern) {
parts.push(`**Pattern:** \`${action.pattern}\``);
}
if (action.path) {
parts.push(`**Path:** \`${action.path}\``);
}
if ("include" in action && action.include) {
parts.push(`**Include:** \`${action.include}\``);
}
return parts.length > 0 ? parts.join("\n") : getNoContentActionContent();
};
// File Editor Actions
const getFileEditorActionContent = (
action: FileEditorAction | StrReplaceEditorAction,
@@ -228,6 +248,12 @@ export const getActionContent = (event: ActionEvent): string => {
case "BrowserCloseTabAction":
return getBrowserActionContent(action);
case "GrepAction":
case "GlobAction":
return getSearchActionContent(
event as ActionEvent<GlobAction | GrepAction>,
);
default:
return getDefaultEventContent(event);
}

View File

@@ -1,6 +1,6 @@
import { Trans } from "react-i18next";
import React from "react";
import { OpenHandsEvent, ObservationEvent } from "#/types/v1/core";
import { OpenHandsEvent, ObservationEvent, ActionEvent } from "#/types/v1/core";
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
import { MonoComponent } from "../../../features/chat/mono-component";
import { PathComponent } from "../../../features/chat/path-component";
@@ -37,6 +37,13 @@ const createTitleFromKey = (
);
};
const getSummaryTitleForActionEvent = (
event: ActionEvent,
): React.ReactNode | null => {
const summary = event.summary?.trim().replace(/\s+/g, " ") || "";
return summary || null;
};
// Action Event Processing
const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
// Early return if not an action event
@@ -44,6 +51,11 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
return "";
}
const summaryTitle = getSummaryTitleForActionEvent(event);
if (summaryTitle) {
return summaryTitle;
}
const actionType = event.action.kind;
let actionKey = "";
let actionValues: Record<string, unknown> = {};
@@ -84,6 +96,24 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
case "TaskTrackerAction":
actionKey = "ACTION_MESSAGE$TASK_TRACKING";
break;
case "GrepAction":
actionKey = "ACTION_MESSAGE$GREP";
actionValues = {
pattern:
"pattern" in event.action && event.action.pattern
? trimText(String(event.action.pattern), 50)
: "",
};
break;
case "GlobAction":
actionKey = "ACTION_MESSAGE$GLOB";
actionValues = {
pattern:
"pattern" in event.action && event.action.pattern
? trimText(String(event.action.pattern), 50)
: "",
};
break;
case "BrowserNavigateAction":
case "BrowserClickAction":
case "BrowserTypeAction":
@@ -109,12 +139,22 @@ const getActionEventTitle = (event: OpenHandsEvent): React.ReactNode => {
};
// Observation Event Processing
const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
const getObservationEventTitle = (
event: OpenHandsEvent,
correspondingAction?: ActionEvent,
): React.ReactNode => {
// Early return if not an observation event
if (!isObservationEvent(event)) {
return "";
}
if (correspondingAction) {
const summaryTitle = getSummaryTitleForActionEvent(correspondingAction);
if (summaryTitle) {
return summaryTitle;
}
}
const observationType = event.observation.kind;
let observationKey = "";
let observationValues: Record<string, unknown> = {};
@@ -190,7 +230,10 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
return observationType;
};
export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => {
export const getEventContent = (
event: OpenHandsEvent | SkillReadyEvent,
correspondingAction?: ActionEvent,
) => {
let title: React.ReactNode = "";
let details: string | React.ReactNode = "";
@@ -208,7 +251,7 @@ export const getEventContent = (event: OpenHandsEvent | SkillReadyEvent) => {
title = getActionEventTitle(event);
details = getActionContent(event);
} else if (isObservationEvent(event)) {
title = getObservationEventTitle(event);
title = getObservationEventTitle(event, correspondingAction);
// For TaskTrackerObservation, use React component instead of markdown
if (event.observation.kind === "TaskTrackerObservation") {

View File

@@ -1,4 +1,4 @@
import { OpenHandsEvent } from "#/types/v1/core";
import { OpenHandsEvent, ActionEvent } from "#/types/v1/core";
import { GenericEventMessage } from "../../../features/chat/generic-event-message";
import { getEventContent } from "../event-content-helpers/get-event-content";
import { getObservationResult } from "../event-content-helpers/get-observation-result";
@@ -13,13 +13,15 @@ import { ObservationResultStatus } from "../../../features/chat/event-content-he
interface GenericEventMessageWrapperProps {
event: OpenHandsEvent | SkillReadyEvent;
isLastMessage: boolean;
correspondingAction?: ActionEvent;
}
export function GenericEventMessageWrapper({
event,
isLastMessage,
correspondingAction,
}: GenericEventMessageWrapperProps) {
const { title, details } = getEventContent(event);
const { title, details } = getEventContent(event, correspondingAction);
// SkillReadyEvent is not an observation event, so skip the observation checks
if (!isSkillReadyEvent(event)) {

View File

@@ -265,6 +265,11 @@ export function EventMessage({
<GenericEventMessageWrapper
event={event}
isLastMessage={isLastMessage}
correspondingAction={
correspondingAction && isActionEvent(correspondingAction)
? correspondingAction
: undefined
}
/>
</>
);

View File

@@ -38,19 +38,24 @@ function groupEventsByPhase(events: OpenHandsEvent[]): OpenHandsEvent[][] {
return phases;
}
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
/**
* Finds the last PlanningFileEditorObservation in a phase.
* Finds the last PlanningFileEditorObservation for Plan.md in a phase.
*
* @param phase - Array of events in a phase
* @returns The event ID of the last PlanningFileEditorObservation, or null
* @returns The event ID of the last Plan.md observation, or null
*/
function findLastPlanningObservationInPhase(
phase: OpenHandsEvent[],
): string | null {
// Iterate backwards to find the last one
for (let i = phase.length - 1; i >= 0; i -= 1) {
const event = phase[i];
if (isPlanningFileEditorObservationEvent(event)) {
if (
isPlanningFileEditorObservationEvent(event) &&
isPlanFilePath(event.observation.path)
) {
return event.id;
}
}

View File

@@ -0,0 +1,101 @@
import { I18nKey } from "#/i18n/declaration";
export type OnboardingAppMode = "saas" | "self-hosted";
interface BaseOnboardingQuestion {
id: string;
app_mode: OnboardingAppMode[];
questionKey: I18nKey;
subtitleKey?: I18nKey;
}
interface InputQuestion extends BaseOnboardingQuestion {
type: "input";
inputOptions: { key: I18nKey; id: string }[];
}
interface SingleSelectQuestion extends BaseOnboardingQuestion {
type: "single";
answerOptions: { key: I18nKey; id: string }[];
}
interface MultiSelectQuestion extends BaseOnboardingQuestion {
type: "multi";
answerOptions: { key: I18nKey; id: string }[];
}
export type OnboardingQuestion =
| InputQuestion
| SingleSelectQuestion
| MultiSelectQuestion;
export const ONBOARDING_FORM: OnboardingQuestion[] = [
{
id: "org_name",
type: "input",
app_mode: ["self-hosted"],
questionKey: I18nKey.ONBOARDING$ORG_NAME_QUESTION,
inputOptions: [
{ key: I18nKey.ONBOARDING$ORG_NAME_INPUT_NAME, id: "org_name" },
{ key: I18nKey.ONBOARDING$ORG_NAME_INPUT_DOMAIN, id: "org_domain" },
],
},
{
id: "org_size",
type: "single",
app_mode: ["saas", "self-hosted"],
questionKey: I18nKey.ONBOARDING$ORG_SIZE_QUESTION,
subtitleKey: I18nKey.ONBOARDING$ORG_SIZE_SUBTITLE,
answerOptions: [
{ key: I18nKey.ONBOARDING$ORG_SIZE_SOLO, id: "solo" },
{ key: I18nKey.ONBOARDING$ORG_SIZE_2_10, id: "org_2_10" },
{ key: I18nKey.ONBOARDING$ORG_SIZE_11_50, id: "org_11_50" },
{ key: I18nKey.ONBOARDING$ORG_SIZE_51_200, id: "org_51_200" },
{ key: I18nKey.ONBOARDING$ORG_SIZE_200_PLUS, id: "org_200_plus" },
],
},
{
id: "use_case",
type: "multi",
app_mode: ["saas", "self-hosted"],
questionKey: I18nKey.ONBOARDING$USE_CASE_QUESTION,
subtitleKey: I18nKey.ONBOARDING$USE_CASE_SUBTITLE,
answerOptions: [
{ key: I18nKey.ONBOARDING$USE_CASE_NEW_FEATURES, id: "new_features" },
{
key: I18nKey.ONBOARDING$USE_CASE_APP_FROM_SCRATCH,
id: "app_from_scratch",
},
{ key: I18nKey.ONBOARDING$USE_CASE_FIXING_BUGS, id: "fixing_bugs" },
{ key: I18nKey.ONBOARDING$USE_CASE_REFACTORING, id: "refactoring" },
{
key: I18nKey.ONBOARDING$USE_CASE_AUTOMATING_TASKS,
id: "automating_tasks",
},
{ key: I18nKey.ONBOARDING$USE_CASE_NOT_SURE, id: "not_sure" },
],
},
{
id: "role",
type: "single",
app_mode: ["saas"],
questionKey: I18nKey.ONBOARDING$ROLE_QUESTION,
answerOptions: [
{
key: I18nKey.ONBOARDING$ROLE_SOFTWARE_ENGINEER,
id: "software_engineer",
},
{
key: I18nKey.ONBOARDING$ROLE_ENGINEERING_MANAGER,
id: "engineering_manager",
},
{ key: I18nKey.ONBOARDING$ROLE_CTO_FOUNDER, id: "cto_founder" },
{
key: I18nKey.ONBOARDING$ROLE_PRODUCT_OPERATIONS,
id: "product_operations",
},
{ key: I18nKey.ONBOARDING$ROLE_STUDENT_HOBBYIST, id: "student_hobbyist" },
{ key: I18nKey.ONBOARDING$ROLE_OTHER, id: "other" },
],
},
];

View File

@@ -1,4 +1,4 @@
import { FiUsers, FiBriefcase } from "react-icons/fi";
import { FiUsers, FiBriefcase, FiBarChart2 } from "react-icons/fi";
import CreditCardIcon from "#/icons/credit-card.svg?react";
import KeyIcon from "#/icons/key.svg?react";
import ServerProcessIcon from "#/icons/server-process.svg?react";
@@ -64,6 +64,11 @@ export const SAAS_NAV_ITEMS: SettingsNavItem[] = [
text: "Organization",
icon: <FiBriefcase size={22} />,
},
{
to: "/settings/usage",
text: "SETTINGS$NAV_USAGE",
icon: <FiBarChart2 size={22} />,
},
];
export const OSS_NAV_ITEMS: SettingsNavItem[] = [

View File

@@ -126,13 +126,15 @@ export function ConversationWebSocketProvider({
const receivedEventCountRefMain = useRef(0);
const receivedEventCountRefPlanning = useRef(0);
// Track the latest PlanningFileEditorObservation event during history replay
// We'll only call the API once after history loading completes
// Track the latest PlanningFileEditorObservation for Plan.md during history replay
const latestPlanningFileEventRef = useRef<{
path: string;
conversationId: string;
} | null>(null);
const isPlanFilePath = (path: string | null): boolean =>
path?.toUpperCase().endsWith("PLAN.MD") ?? false;
// Helper function to update metrics from stats event
const updateMetricsFromStats = useCallback(
(event: ConversationStateUpdateEventStats) => {
@@ -612,37 +614,39 @@ export function ConversationWebSocketProvider({
appendOutput(textContent);
}
// Handle PlanningFileEditorObservation events - read and update plan content
// Handle PlanningFileEditorObservation - only update plan for Plan.md
if (isPlanningFileEditorObservationEvent(event)) {
const planningAgentConversation = subConversations?.[0];
const planningConversationId = planningAgentConversation?.id;
const { path } = event.observation;
if (isPlanFilePath(path)) {
const planningAgentConversation = subConversations?.[0];
const planningConversationId = planningAgentConversation?.id;
if (planningConversationId && event.observation.path) {
// During history replay, track the latest event but don't call API
// After history loading completes, we'll call the API once with the latest event
if (isLoadingHistoryPlanning) {
latestPlanningFileEventRef.current = {
path: event.observation.path,
conversationId: planningConversationId,
};
} else {
// History loading is complete - this is a new real-time event
// Call the API immediately for real-time updates
readConversationFile(
{
if (planningConversationId && path) {
if (isLoadingHistoryPlanning) {
latestPlanningFileEventRef.current = {
path,
conversationId: planningConversationId,
filePath: event.observation.path,
},
{
onSuccess: (fileContent) => {
setPlanContent(fileContent);
};
} else {
readConversationFile(
{
conversationId: planningConversationId,
filePath: path,
},
onError: (error) => {
// eslint-disable-next-line no-console
console.warn("Failed to read conversation file:", error);
{
onSuccess: (fileContent) => {
setPlanContent(fileContent);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.warn(
"Failed to read conversation file:",
error,
);
},
},
},
);
);
}
}
}
}

View File

@@ -2,10 +2,12 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { SecretsService } from "#/api/secrets-service";
import { Provider, ProviderToken } from "#/types/settings";
import { useTracking } from "#/hooks/use-tracking";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
export const useAddGitProviders = () => {
const queryClient = useQueryClient();
const { trackGitProviderConnected } = useTracking();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: ({
@@ -25,7 +27,9 @@ export const useAddGitProviders = () => {
});
}
await queryClient.invalidateQueries({ queryKey: ["settings"] });
await queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
meta: {
disableToast: true,

View File

@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSettings } from "#/hooks/query/use-settings";
import SettingsService from "#/api/settings-service/settings-service.api";
import { MCPSSEServer, MCPStdioServer, MCPSHTTPServer } from "#/types/settings";
import { useSelectedOrganizationId } from "#/context/use-selected-organization";
type MCPServerType = "sse" | "stdio" | "shttp";
@@ -19,6 +20,7 @@ interface MCPServerConfig {
export function useAddMcpServer() {
const queryClient = useQueryClient();
const { data: settings } = useSettings();
const { organizationId } = useSelectedOrganizationId();
return useMutation({
mutationFn: async (server: MCPServerConfig): Promise<void> => {
@@ -64,7 +66,9 @@ export function useAddMcpServer() {
},
onSuccess: () => {
// Invalidate the settings query to trigger a refetch
queryClient.invalidateQueries({ queryKey: ["settings"] });
queryClient.invalidateQueries({
queryKey: ["settings", organizationId],
});
},
});
}

Some files were not shown because too many files have changed in this diff Show More