Compare commits

..

7 Commits

Author SHA1 Message Date
Xingyao Wang 8391bb432b [Automations Phase 1] Task 3: Scheduler (#13331)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-11 17:49:25 +00:00
Juan Michelini 5e5950b091 Add Gemini-3.1-Pro-Preview model support to frontend (#13253)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Ray Myers <ray.myers@gmail.com>
2026-03-10 16:18:13 +00:00
John-Mason P. Shackelford c7ff560465 Fix getGitPath to handle nested GitLab group paths (#13006)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-03-10 11:12:08 -05:00
Joe Laverty 3432bbbb88 fix: Remove N+1 request from Bitbucket Data Center integration (#13281) 2026-03-10 11:08:30 -05:00
Hiep Le fc24be2627 fix(frontend): preserve login_method param to enable session re-authentication (#13310) 2026-03-10 22:52:40 +07:00
Hiep Le bc72b38d6e fix(backend): propagate LLM settings to all org members when admin saves settings (#13326) 2026-03-10 22:52:01 +07:00
Dream 145f1266e6 feat(frontend): create a separate UI tab for monitoring tasks (#13065)
Co-authored-by: hieptl <hieptl.developer@gmail.com>
2026-03-10 22:31:38 +07:00
39 changed files with 2106 additions and 161 deletions
+2 -2
View File
@@ -55,7 +55,7 @@ jobs:
- name: Build Environment
run: make build
- name: Run Unit Tests
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest -n auto -s ./tests/unit --cov=openhands --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
env:
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
- name: Run Runtime Tests with CLIRuntime
@@ -91,7 +91,7 @@ jobs:
run: poetry install --with dev,test
- name: Run Unit Tests
# Use base working directory for coverage paths to line up.
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
run: PYTHONPATH=".:$PYTHONPATH" poetry run --project=enterprise pytest --forked -n auto -s -p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark ./enterprise/tests/unit --cov=enterprise --cov-branch
env:
COVERAGE_FILE: ".coverage.enterprise.${{ matrix.python_version }}"
- name: Store coverage file
+38 -10
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "agent-client-protocol"
@@ -3501,7 +3501,7 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.71.2"
protobuf = ">=5.26.1,<6.0.dev0"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
name = "gspread"
@@ -3819,7 +3819,7 @@ pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
@@ -4258,7 +4258,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@@ -4648,7 +4648,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.5.14"
certifi = ">=14.05.14"
durationpy = ">=0.7"
google-auth = ">=1.0.1"
oauthlib = ">=3.2.2"
@@ -6889,7 +6889,7 @@ files = [
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
@@ -7551,6 +7551,18 @@ files = [
{file = "puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9"},
]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["test"]
files = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
@@ -11691,6 +11703,22 @@ pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-forked"
version = "1.6.0"
description = "run tests in isolated forked subprocesses"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f"},
{file = "pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0"},
]
[package.dependencies]
py = "*"
pytest = ">=3.10"
[[package]]
name = "pytest-xdist"
version = "3.8.0"
@@ -12838,10 +12866,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "scantree"
@@ -14978,9 +15006,9 @@ files = [
]
[package.extras]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "4221146bf5d0dda799dde9ecdec5d38db556db8a759549efe7d67372b5750b67"
content-hash = "ef037f6d6085d26166d35c56ce266439f8f1a4fea90bc43ccf15cfeaf116cae5"
+3
View File
@@ -14,6 +14,7 @@ readme = "README.md"
repository = "https://github.com/OpenHands/OpenHands"
packages = [
{ include = "server" },
{ include = "services" },
{ include = "storage" },
{ include = "sync" },
{ include = "integrations" },
@@ -49,6 +50,7 @@ pandas = "^2.2.0"
numpy = "^2.2.0"
mcp = "^1.10.0"
pillow = "^12.1.1"
croniter = "^6.0.0"
[tool.poetry.group.dev.dependencies]
ruff = "0.8.3"
@@ -61,6 +63,7 @@ types-requests = "^2.32.4.20250611"
pytest = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
flake8 = "*"
openai = "*"
+33
View File
@@ -0,0 +1,33 @@
"""Entry point for the automation scheduler CronJob.
Usage: python -m run_automation_scheduler
This runs as a Kubernetes CronJob (every minute). It evaluates cron schedules
for all enabled automations and inserts events for those that are due.
The process runs, evaluates, inserts events, and exits — it is NOT a
long-running daemon.
"""
import asyncio
import sys
from server.logger import logger
from services.automation_scheduler import run_scheduler
from storage.database import a_session_maker
async def main() -> int:
"""Run the automation scheduler and return an exit code."""
try:
async with a_session_maker() as session:
events_created = await run_scheduler(session)
logger.info('Automation scheduler finished: %d events created', events_created)
return 0
except Exception:
logger.exception('Error running automation scheduler')
return 1
if __name__ == '__main__':
exit_code = asyncio.run(main())
sys.exit(exit_code)
View File
+152
View File
@@ -0,0 +1,152 @@
"""Automation scheduler — evaluates cron schedules and inserts tick events.
This module is the core logic for the automation scheduler CronJob.
It queries all enabled cron automations, determines which are due based on
their cron expressions and last_triggered_at timestamps, and inserts events
into the automation_events inbox table for the executor to process.
The scheduler does NOT create automation_runs — that's the executor's job.
"""
from __future__ import annotations
import logging
from datetime import datetime
from datetime import timezone as tz
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from croniter import croniter
from services.automation_event_publisher import (
pg_notify_new_event,
publish_automation_event,
)
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from storage.automation import Automation
logger = logging.getLogger(__name__)
async def run_scheduler(session: AsyncSession) -> int:
"""Evaluate all enabled cron automations and insert events for those that are due.
Args:
session: An async SQLAlchemy session (caller manages the transaction).
Returns:
Number of events created.
"""
now = datetime.now(tz.utc)
result = await session.execute(
select(Automation).where(
Automation.enabled == True, # noqa: E712
Automation.trigger_type == 'cron',
)
)
automations = result.scalars().all()
events_created = 0
for automation in automations:
try:
events_created += await _process_automation(session, automation, now)
except Exception:
# Broad catch is intentional: one broken automation must never prevent
# the rest of the scheduler run from completing. The savepoint inside
# _process_automation ensures the outer session is not poisoned.
logger.exception('Error processing automation %s', automation.id)
continue
await session.commit()
logger.info(
'Scheduler run complete: %d events created from %d automations',
events_created,
len(automations),
)
return events_created
async def _process_automation(
session: AsyncSession, automation: Automation, now: datetime
) -> int:
"""Check a single automation and insert an event if it is due.
Returns 1 if an event was created, 0 otherwise.
"""
cron_config = automation.config.get('triggers', {}).get('cron', {})
schedule = cron_config.get('schedule')
timezone_str = cron_config.get('timezone', 'UTC')
if not schedule:
logger.warning('Automation %s has no cron schedule, skipping', automation.id)
return 0
if not croniter.is_valid(schedule):
logger.warning(
'Automation %s has invalid cron expression %r, skipping',
automation.id,
schedule,
)
return 0
try:
tz_info = ZoneInfo(timezone_str)
except (ZoneInfoNotFoundError, KeyError):
logger.warning(
'Automation %s has invalid timezone %r, falling back to UTC',
automation.id,
timezone_str,
)
tz_info = ZoneInfo('UTC')
reference_time = automation.last_triggered_at or automation.created_at
if reference_time.tzinfo is None:
reference_time = reference_time.replace(tzinfo=tz.utc)
# Compute next run from the reference time in the automation's timezone
ref_in_tz = reference_time.astimezone(tz_info)
cron = croniter(schedule, ref_in_tz)
next_run = cron.get_next(datetime)
# Compare in UTC
if next_run.tzinfo is None:
next_run = next_run.replace(tzinfo=tz.utc)
next_run_utc = next_run.astimezone(tz.utc)
if next_run_utc > now:
return 0
# Automation is due — insert an event
automation_id = str(automation.id)
scheduled_minute = now.strftime('%Y-%m-%dT%H:%MZ')
dedup_key = f'cron-{automation_id}-{scheduled_minute}'
try:
async with session.begin_nested():
event = publish_automation_event(
session=session,
source_type='cron',
payload={
'automation_id': automation_id,
'scheduled_time': now.isoformat(),
},
dedup_key=dedup_key,
metadata={'cron_expression': schedule},
)
automation.last_triggered_at = now
pg_notify_new_event(session, event.id)
await session.flush()
logger.info('Created cron event for automation %s', automation_id)
return 1
except IntegrityError:
# Only the nested transaction (savepoint) is rolled back — events
# from previously-processed automations in the same run are preserved.
logger.debug(
'Dedup: event already exists for automation %s at %s',
automation_id,
scheduled_minute,
)
return 0
+26 -1
View File
@@ -11,9 +11,10 @@ from pydantic import SecretStr
from server.auth.token_manager import TokenManager
from server.constants import LITE_LLM_API_URL
from server.logger import logger
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import joinedload
from storage.database import a_session_maker
from storage.encrypt_utils import encrypt_value
from storage.lite_llm_manager import LiteLlmManager, get_openhands_cloud_key_alias
from storage.org import Org
from storage.org_member import OrgMember
@@ -186,6 +187,30 @@ class SaasSettingsStore(SettingsStore):
if hasattr(model, key):
setattr(model, key, value)
# Propagate LLM settings to all org members
# This ensures all members see the same LLM configuration when an admin saves
# Note: Concurrent saves by multiple admins will result in last-write-wins.
# Consider adding optimistic locking if this becomes a problem.
member_update_values: dict = {}
if item.llm_model is not None:
member_update_values['llm_model'] = item.llm_model
if item.llm_base_url is not None:
member_update_values['llm_base_url'] = item.llm_base_url
if item.max_iterations is not None:
member_update_values['max_iterations'] = item.max_iterations
if item.llm_api_key is not None:
member_update_values['_llm_api_key'] = encrypt_value(
item.llm_api_key.get_secret_value()
)
if member_update_values:
stmt = (
update(OrgMember)
.where(OrgMember.org_id == org_id)
.values(**member_update_values)
)
await session.execute(stmt)
await session.commit()
@classmethod
@@ -0,0 +1,596 @@
"""Unit tests for the automation scheduler.
Tests verify the core scheduler logic: cron evaluation, event creation,
idempotency, timezone handling, and error resilience.
"""
from __future__ import annotations
import sys
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from unittest.mock import MagicMock
import pytest
from sqlalchemy import JSON, Boolean, Column, DateTime, String, select
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
# ---------------------------------------------------------------------------
# Stub models for Task 1 dependencies (Automation + AutomationEvent)
# These will be replaced by real imports once Task 1 is merged.
# ---------------------------------------------------------------------------
class _Base(DeclarativeBase):
pass
class Automation(_Base):
__tablename__ = 'automations'
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
user_id = Column(String, nullable=False)
org_id = Column(String, nullable=True)
name = Column(String, nullable=False)
enabled = Column(Boolean, nullable=False, default=True)
config = Column(JSON, nullable=False)
trigger_type = Column(String, nullable=False)
file_store_key = Column(String, nullable=False, default='')
last_triggered_at = Column(DateTime(timezone=True), nullable=True)
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
updated_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
class AutomationEvent(_Base):
__tablename__ = 'automation_events'
id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex)
source_type = Column(String, nullable=False)
payload = Column(JSON, nullable=False)
metadata_ = Column('metadata', JSON, nullable=True)
dedup_key = Column(String, nullable=False, unique=True)
status = Column(String, nullable=False, default='NEW')
created_at = Column(
DateTime(timezone=True),
nullable=False,
default=lambda: datetime.now(timezone.utc),
)
# ---------------------------------------------------------------------------
# Mock the Task 1 modules so the scheduler can be imported
# ---------------------------------------------------------------------------
_published_events: list[AutomationEvent] = []
_notified_event_ids: list[Any] = []
def _fake_publish_automation_event(
session: Any,
source_type: str,
payload: dict,
dedup_key: str,
metadata: dict | None = None,
) -> AutomationEvent:
"""Fake publisher that adds an event to the session."""
event = AutomationEvent(
source_type=source_type,
payload=payload,
dedup_key=dedup_key,
metadata_=metadata,
)
session.add(event)
_published_events.append(event)
return event
def _fake_pg_notify(session: Any, event_id: Any) -> None:
_notified_event_ids.append(event_id)
# Register stub modules before importing the scheduler
_mock_storage_automation = MagicMock()
_mock_storage_automation.Automation = Automation
_mock_publisher = MagicMock()
_mock_publisher.publish_automation_event = _fake_publish_automation_event
_mock_publisher.pg_notify_new_event = _fake_pg_notify
sys.modules.setdefault('storage.automation', _mock_storage_automation)
sys.modules.setdefault('services.automation_event_publisher', _mock_publisher)
from services.automation_scheduler import run_scheduler # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _reset_tracking():
"""Clear tracking lists between tests."""
_published_events.clear()
_notified_event_ids.clear()
@pytest.fixture
async def async_engine(tmp_path):
db_path = tmp_path / 'test_scheduler.db'
engine = create_async_engine(
f'sqlite+aiosqlite:///{db_path}',
connect_args={'check_same_thread': False},
)
async with engine.begin() as conn:
await conn.run_sync(_Base.metadata.create_all)
yield engine
await engine.dispose()
@pytest.fixture
async def async_session_maker(async_engine):
return async_sessionmaker(
bind=async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
def _make_automation(
*,
enabled: bool = True,
trigger_type: str = 'cron',
schedule: str = '*/5 * * * *',
timezone_str: str = 'UTC',
last_triggered_at: datetime | None = None,
created_at: datetime | None = None,
) -> Automation:
now = datetime.now(timezone.utc)
return Automation(
id=uuid.uuid4().hex,
user_id='user-1',
name='test automation',
enabled=enabled,
config={
'triggers': {
trigger_type: {
'schedule': schedule,
'timezone': timezone_str,
}
}
if trigger_type == 'cron'
else {}
},
trigger_type=trigger_type,
last_triggered_at=last_triggered_at,
created_at=created_at or now,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestRunScheduler:
"""Tests for the run_scheduler function."""
@pytest.mark.asyncio
async def test_automation_is_due(self, async_session_maker):
"""An automation whose next fire time has passed should produce one event."""
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 1
assert len(_published_events) == 1
assert _published_events[0].source_type == 'cron'
assert auto.id in _published_events[0].dedup_key
@pytest.mark.asyncio
async def test_automation_not_due(self, async_session_maker):
"""An automation that was just triggered should NOT produce an event."""
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(seconds=30),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
assert len(_published_events) == 0
@pytest.mark.asyncio
async def test_disabled_automations_skipped(self, async_session_maker):
"""Disabled automations must not produce events."""
auto = _make_automation(
enabled=False,
schedule='* * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
@pytest.mark.asyncio
async def test_non_cron_automations_skipped(self, async_session_maker):
"""Automations with trigger_type != 'cron' must not be processed."""
auto = _make_automation(
trigger_type='github',
last_triggered_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
@pytest.mark.asyncio
async def test_idempotency_same_minute(self, async_session_maker):
"""Running the scheduler twice in the same minute produces exactly one event.
The dedup_key (based on automation_id + minute) causes the second insert
to fail with an IntegrityError, which the scheduler handles gracefully.
"""
auto = _make_automation(
schedule='* * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=5),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
# First run
async with async_session_maker() as session:
count1 = await run_scheduler(session)
assert count1 == 1
# Second run — the dedup_key collision triggers IntegrityError
# which the scheduler handles by rolling back and continuing.
async with async_session_maker() as session:
count2 = await run_scheduler(session)
# Second run creates 0 new events (dedup catches it via
# last_triggered_at having been updated in the first run).
assert count2 == 0
@pytest.mark.asyncio
async def test_timezone_handling(self, async_session_maker):
"""Timezone-aware schedules should be evaluated correctly."""
auto = _make_automation(
schedule='*/5 * * * *',
timezone_str='America/New_York',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 1
assert len(_published_events) == 1
@pytest.mark.asyncio
async def test_last_triggered_at_updated(self, async_session_maker):
"""After creating an event, last_triggered_at must be updated."""
old_time = datetime.now(timezone.utc) - timedelta(hours=1)
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=old_time,
)
auto_id = auto.id
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 1
# Re-read from the database to verify update
async with async_session_maker() as session:
result = await session.get(Automation, auto_id)
assert result.last_triggered_at is not None
# SQLite strips tz info; compare naive-to-naive
result_ts = result.last_triggered_at.replace(tzinfo=None)
assert result_ts > old_time.replace(tzinfo=None)
@pytest.mark.asyncio
async def test_invalid_cron_expression(self, async_session_maker):
"""An automation with an invalid cron expression should be skipped, not crash."""
auto = _make_automation(
schedule='not-a-cron',
last_triggered_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
assert len(_published_events) == 0
@pytest.mark.asyncio
async def test_missing_cron_schedule(self, async_session_maker):
"""An automation with empty cron config should be skipped."""
auto = Automation(
id=uuid.uuid4().hex,
user_id='user-1',
name='empty cron',
enabled=True,
config={'triggers': {'cron': {}}},
trigger_type='cron',
created_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
@pytest.mark.asyncio
async def test_never_triggered_uses_created_at(self, async_session_maker):
"""When last_triggered_at is None, the scheduler falls back to created_at."""
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=None,
created_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 1
@pytest.mark.asyncio
async def test_invalid_timezone_falls_back_to_utc(self, async_session_maker):
"""An invalid timezone should fall back to UTC and still work."""
auto = _make_automation(
schedule='*/5 * * * *',
timezone_str='Invalid/Timezone',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 1
@pytest.mark.asyncio
async def test_multiple_automations(self, async_session_maker):
"""Multiple due automations should each get their own event."""
auto1 = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
auto2 = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
auto_not_due = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(seconds=30),
)
async with async_session_maker() as session:
session.add_all([auto1, auto2, auto_not_due])
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 2
assert len(_published_events) == 2
@pytest.mark.asyncio
async def test_pg_notify_called(self, async_session_maker):
"""pg_notify_new_event should be called for each created event."""
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
await run_scheduler(session)
assert len(_notified_event_ids) == 1
@pytest.mark.asyncio
async def test_dedup_key_format(self, async_session_maker):
"""The dedup_key must follow the format 'cron-{automation_id}-{minute}'."""
auto = _make_automation(
schedule='* * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=5),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
await run_scheduler(session)
assert len(_published_events) == 1
dedup = _published_events[0].dedup_key
assert dedup.startswith(f'cron-{auto.id}-')
# The minute portion should look like an ISO-ish timestamp ending with Z
minute_part = dedup.split(f'cron-{auto.id}-')[1]
assert minute_part.endswith('Z')
@pytest.mark.asyncio
async def test_no_automations(self, async_session_maker):
"""Running on an empty table should succeed with 0 events."""
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
@pytest.mark.asyncio
async def test_event_payload_contains_automation_id(self, async_session_maker):
"""The event payload must include automation_id and scheduled_time."""
auto = _make_automation(
schedule='*/5 * * * *',
last_triggered_at=datetime.now(timezone.utc) - timedelta(minutes=10),
)
async with async_session_maker() as session:
session.add(auto)
await session.commit()
async with async_session_maker() as session:
await run_scheduler(session)
payload = _published_events[0].payload
assert payload['automation_id'] == auto.id
assert 'scheduled_time' in payload
@pytest.mark.asyncio
async def test_integrity_error_dedup_in_same_session(self, async_session_maker):
"""Pre-inserted event with the same dedup_key triggers IntegrityError.
The savepoint should roll back only the duplicate insert; the scheduler
should return 0 without corrupting the session.
"""
now = datetime.now(timezone.utc)
auto = _make_automation(
schedule='* * * * *', # every minute — always due
last_triggered_at=now - timedelta(minutes=5),
)
# Pre-compute the dedup_key the scheduler will generate
scheduled_minute = now.strftime('%Y-%m-%dT%H:%MZ')
dedup_key = f'cron-{auto.id}-{scheduled_minute}'
# Pre-insert an event with the same dedup_key
existing_event = AutomationEvent(
source_type='cron',
payload={'automation_id': auto.id},
dedup_key=dedup_key,
)
async with async_session_maker() as session:
session.add(auto)
session.add(existing_event)
await session.commit()
# Scheduler tries to insert same dedup_key → IntegrityError
async with async_session_maker() as session:
count = await run_scheduler(session)
assert count == 0
# The _fake_publish_automation_event still appended to the tracking list
# before the flush raised IntegrityError; the important thing is the
# database only has the original event.
async with async_session_maker() as session:
result = await session.execute(
select(AutomationEvent).where(
AutomationEvent.dedup_key == dedup_key
)
)
assert len(result.scalars().all()) == 1
@pytest.mark.asyncio
async def test_savepoint_preserves_other_automations_on_dedup(
self, async_session_maker
):
"""When one automation hits IntegrityError, others must not be rolled back.
This verifies the begin_nested() savepoint isolation: automation B's
event should be preserved even though automation A's insert fails.
"""
now = datetime.now(timezone.utc)
auto_a = _make_automation(
schedule='* * * * *',
last_triggered_at=now - timedelta(minutes=5),
)
auto_b = _make_automation(
schedule='* * * * *',
last_triggered_at=now - timedelta(minutes=5),
)
# Pre-insert a conflicting event for automation A only
scheduled_minute = now.strftime('%Y-%m-%dT%H:%MZ')
dedup_key_a = f'cron-{auto_a.id}-{scheduled_minute}'
existing_event = AutomationEvent(
source_type='cron',
payload={'automation_id': auto_a.id},
dedup_key=dedup_key_a,
)
async with async_session_maker() as session:
session.add_all([auto_a, auto_b, existing_event])
await session.commit()
async with async_session_maker() as session:
count = await run_scheduler(session)
# Only automation B should have produced an event
assert count == 1
# Verify automation B's event was committed to the database
dedup_key_b = f'cron-{auto_b.id}-{scheduled_minute}'
async with async_session_maker() as session:
result = await session.execute(
select(AutomationEvent).where(
AutomationEvent.dedup_key == dedup_key_b
)
)
assert len(result.scalars().all()) == 1
# Verify automation B's last_triggered_at was persisted
async with async_session_maker() as session:
result_b = await session.get(Automation, auto_b.id)
assert result_b.last_triggered_at is not None
# Verify automation A's last_triggered_at was NOT updated (savepoint rolled back)
async with async_session_maker() as session:
result_a = await session.get(Automation, auto_a.id)
ts = result_a.last_triggered_at
if ts is not None and ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
assert ts is not None
assert ts < now
@@ -1,3 +1,4 @@
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -233,3 +234,165 @@ async def test_ensure_api_key_generates_new_key_when_verification_fails(
assert item.llm_api_key is not None
assert item.llm_api_key.get_secret_value() == new_key
@pytest.fixture
def org_with_multiple_members_fixture(session_maker):
"""Set up an organization with multiple members for testing LLM settings propagation.
Uses sync session to avoid UUID conversion issues with async SQLite.
"""
from storage.encrypt_utils import decrypt_value
from storage.org import Org
from storage.org_member import OrgMember
from storage.role import Role
from storage.user import User
# Use realistic UUIDs that work well with SQLite
org_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5081')
admin_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5082')
member1_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5083')
member2_user_id = uuid.UUID('5594c7b6-f959-4b81-92e9-b09c206f5084')
with session_maker() as session:
# Create role
role = Role(id=10, name='member', rank=3)
session.add(role)
# Create org
org = Org(
id=org_id,
name='test-org',
org_version=1,
enable_default_condenser=True,
enable_proactive_conversation_starters=True,
)
session.add(org)
# Create users
admin_user = User(
id=admin_user_id, current_org_id=org_id, user_consents_to_analytics=True
)
session.add(admin_user)
member1_user = User(
id=member1_user_id, current_org_id=org_id, user_consents_to_analytics=True
)
session.add(member1_user)
member2_user = User(
id=member2_user_id, current_org_id=org_id, user_consents_to_analytics=True
)
session.add(member2_user)
# Create org members with DIFFERENT initial LLM settings
admin_member = OrgMember(
org_id=org_id,
user_id=admin_user_id,
role_id=10,
llm_api_key='admin-initial-key',
llm_model='old-model-v1',
llm_base_url='http://old-url-1.com',
max_iterations=10,
status='active',
)
session.add(admin_member)
member1 = OrgMember(
org_id=org_id,
user_id=member1_user_id,
role_id=10,
llm_api_key='member1-initial-key',
llm_model='old-model-v2',
llm_base_url='http://old-url-2.com',
max_iterations=20,
status='active',
)
session.add(member1)
member2 = OrgMember(
org_id=org_id,
user_id=member2_user_id,
role_id=10,
llm_api_key='member2-initial-key',
llm_model='old-model-v3',
llm_base_url='http://old-url-3.com',
max_iterations=30,
status='active',
)
session.add(member2)
session.commit()
return {
'org_id': org_id,
'admin_user_id': admin_user_id,
'member1_user_id': member1_user_id,
'member2_user_id': member2_user_id,
'decrypt_value': decrypt_value,
}
@pytest.mark.asyncio
async def test_store_propagates_llm_settings_to_all_org_members(
session_maker, async_session_maker, mock_config, org_with_multiple_members_fixture
):
"""When admin saves LLM settings, all org members should receive the updated settings.
This test verifies using a real database that:
1. The bulk UPDATE targets the correct organization (WHERE clause is correct)
2. All LLM fields are correctly set (llm_model, llm_base_url, max_iterations, llm_api_key)
3. The llm_api_key is properly encrypted
4. All members in the org receive the same updated values
"""
from sqlalchemy import select
from storage.org_member import OrgMember
# Arrange
fixture = org_with_multiple_members_fixture
org_id = fixture['org_id']
admin_user_id = str(fixture['admin_user_id'])
decrypt_value = fixture['decrypt_value']
store = SaasSettingsStore(admin_user_id, mock_config)
new_settings = DataSettings(
llm_model='new-shared-model/gpt-4',
llm_base_url='http://new-shared-url.com',
max_iterations=100,
llm_api_key=SecretStr('new-shared-api-key'),
)
# Act - call store() with async session
with patch('storage.saas_settings_store.a_session_maker', async_session_maker):
await store.store(new_settings)
# Assert - verify ALL org members have the updated LLM settings using sync session
with session_maker() as session:
result = session.execute(select(OrgMember).where(OrgMember.org_id == org_id))
members = result.scalars().all()
# Verify we have all 3 members
assert len(members) == 3, f'Expected 3 org members, got {len(members)}'
for member in members:
# Verify LLM model is updated
assert (
member.llm_model == 'new-shared-model/gpt-4'
), f'Expected llm_model to be updated for member {member.user_id}'
# Verify LLM base URL is updated
assert (
member.llm_base_url == 'http://new-shared-url.com'
), f'Expected llm_base_url to be updated for member {member.user_id}'
# Verify max_iterations is updated
assert (
member.max_iterations == 100
), f'Expected max_iterations to be 100 for member {member.user_id}'
# Verify the API key is encrypted and decrypts to the correct value
decrypted_key = decrypt_value(member._llm_api_key)
assert (
decrypted_key == 'new-shared-api-key'
), f'Expected llm_api_key to decrypt to new-shared-api-key for member {member.user_id}'
@@ -84,12 +84,12 @@ describe("TaskTrackingObservationContent", () => {
expect(taskItems).toHaveLength(3);
});
it("displays task IDs and notes", () => {
it("does not display task IDs but displays notes", () => {
render(<TaskTrackingObservationContent event={mockEvent} />);
expect(screen.getByText("ID: task-1")).toBeInTheDocument();
expect(screen.getByText("ID: task-2")).toBeInTheDocument();
expect(screen.getByText("ID: task-3")).toBeInTheDocument();
expect(screen.queryByText("ID: task-1")).not.toBeInTheDocument();
expect(screen.queryByText("ID: task-2")).not.toBeInTheDocument();
expect(screen.queryByText("ID: task-3")).not.toBeInTheDocument();
expect(screen.getByText("Notes: This is a test task")).toBeInTheDocument();
expect(
@@ -0,0 +1,83 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
const CONVERSATION_ID = "conv-abc123";
vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: CONVERSATION_ID }),
}));
let mockHasTaskList = false;
vi.mock("#/hooks/use-task-list", () => ({
useTaskList: () => ({
hasTaskList: mockHasTaskList,
taskList: [],
}),
}));
describe("ConversationTabsContextMenu", () => {
beforeEach(() => {
localStorage.clear();
mockHasTaskList = false;
});
it("should render nothing when isOpen is false", () => {
const { container } = render(
<ConversationTabsContextMenu isOpen={false} onClose={vi.fn()} />,
);
expect(container.innerHTML).toBe("");
});
it("should render all default tabs when open", () => {
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
const expectedTabs = [
"COMMON$PLANNER",
"COMMON$CHANGES",
"COMMON$CODE",
"COMMON$TERMINAL",
"COMMON$APP",
"COMMON$BROWSER",
];
for (const tab of expectedTabs) {
expect(screen.getByText(tab)).toBeInTheDocument();
}
});
it("should re-pin a tab when clicking an unpinned tab", async () => {
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
const terminalItem = screen.getByText("COMMON$TERMINAL");
// Unpin
await user.click(terminalItem);
let storedState = JSON.parse(
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
);
expect(storedState.unpinnedTabs).toContain("terminal");
// Re-pin
await user.click(terminalItem);
storedState = JSON.parse(
localStorage.getItem(`conversation-state-${CONVERSATION_ID}`)!,
);
expect(storedState.unpinnedTabs).not.toContain("terminal");
});
describe("with tasklist", () => {
beforeEach(() => {
mockHasTaskList = true;
});
it("should show tasklist in context menu when hasTaskList is true", () => {
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
expect(screen.getByText("COMMON$TASK_LIST")).toBeInTheDocument();
});
});
});
@@ -4,7 +4,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter } from "react-router";
import { ConversationTabs } from "#/components/features/conversation/conversation-tabs/conversation-tabs";
import { ConversationTabsContextMenu } from "#/components/features/conversation/conversation-tabs/conversation-tabs-context-menu";
import { useConversationStore } from "#/stores/conversation-store";
const TASK_CONVERSATION_ID = "task-ec03fb2ab8604517b24af632b058c2fd";
@@ -16,6 +15,14 @@ vi.mock("#/hooks/use-conversation-id", () => ({
useConversationId: () => ({ conversationId: mockConversationId }),
}));
let mockHasTaskList = false;
vi.mock("#/hooks/use-task-list", () => ({
useTaskList: () => ({
hasTaskList: mockHasTaskList,
taskList: [],
}),
}));
const createWrapper = (conversationId: string) => {
return ({ children }: { children: React.ReactNode }) => (
<MemoryRouter initialEntries={[`/conversations/${conversationId}`]}>
@@ -31,6 +38,7 @@ describe("ConversationTabs localStorage behavior", () => {
localStorage.clear();
vi.resetAllMocks();
mockConversationId = TASK_CONVERSATION_ID;
mockHasTaskList = false;
useConversationStore.setState({
selectedTab: null,
isRightPanelShown: false,
@@ -71,47 +79,6 @@ describe("ConversationTabs localStorage behavior", () => {
expect(parsed).toHaveProperty("rightPanelShown");
expect(parsed).toHaveProperty("unpinnedTabs");
});
it("should store unpinned tabs in consolidated key via context menu", async () => {
mockConversationId = REAL_CONVERSATION_ID;
const user = userEvent.setup();
render(<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />);
const terminalItem = screen.getByText("COMMON$TERMINAL");
await user.click(terminalItem);
const consolidatedKey = `conversation-state-${REAL_CONVERSATION_ID}`;
const storedState = localStorage.getItem(consolidatedKey);
expect(storedState).not.toBeNull();
const parsed = JSON.parse(storedState!);
expect(parsed.unpinnedTabs).toContain("terminal");
});
it("should hide a tab after unpinning it from context menu", async () => {
mockConversationId = REAL_CONVERSATION_ID;
const user = userEvent.setup();
render(
<>
<ConversationTabs />
<ConversationTabsContextMenu isOpen={true} onClose={vi.fn()} />
</>,
{ wrapper: createWrapper(REAL_CONVERSATION_ID) },
);
expect(
screen.getByTestId("conversation-tab-terminal"),
).toBeInTheDocument();
const terminalItem = screen.getByText("COMMON$TERMINAL");
await user.click(terminalItem);
expect(
screen.queryByTestId("conversation-tab-terminal"),
).not.toBeInTheDocument();
});
});
describe("hook integration", () => {
@@ -205,4 +172,37 @@ describe("ConversationTabs localStorage behavior", () => {
expect(storedState.selectedTab).toBe("browser");
});
});
describe("tasklist tab", () => {
beforeEach(() => {
mockConversationId = REAL_CONVERSATION_ID;
mockHasTaskList = true;
});
it("should show tasklist tab when hasTaskList is true", () => {
render(<ConversationTabs />, {
wrapper: createWrapper(REAL_CONVERSATION_ID),
});
expect(
screen.getByTestId("conversation-tab-tasklist"),
).toBeInTheDocument();
});
it("should select tasklist tab when clicked", async () => {
const user = userEvent.setup();
render(<ConversationTabs />, {
wrapper: createWrapper(REAL_CONVERSATION_ID),
});
const tasklistTab = screen.getByTestId("conversation-tab-tasklist");
await user.click(tasklistTab);
const { selectedTab, hasRightPanelToggled } =
useConversationStore.getState();
expect(selectedTab).toBe("tasklist");
expect(hasRightPanelToggled).toBe(true);
});
});
});
@@ -0,0 +1,279 @@
import { describe, expect, it, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { useTaskList } from "#/hooks/use-task-list";
import { useEventStore } from "#/stores/use-event-store";
import type { OHEvent } from "#/stores/use-event-store";
import type { TaskTrackingObservation } from "#/types/core/observations";
function createV0TaskTrackingObservation(
id: number,
command: string,
taskList: TaskTrackingObservation["extras"]["task_list"],
): TaskTrackingObservation {
return {
id,
source: "agent",
observation: "task_tracking",
message: "Task tracking update",
timestamp: `2025-07-01T00:00:0${id}Z`,
cause: 0,
content: "",
extras: {
command,
task_list: taskList,
},
};
}
function createV1TaskTrackerObservation(
id: string,
command: string,
taskList: Array<{
title: string;
notes: string;
status: "todo" | "in_progress" | "done";
}>,
): OHEvent {
return {
id,
timestamp: `2025-07-01T00:00:0${id}Z`,
source: "environment",
tool_name: "task_tracker",
tool_call_id: `call_${id}`,
action_id: `action_${id}`,
observation: {
kind: "TaskTrackerObservation",
content: "Task list updated",
command,
task_list: taskList,
},
} as unknown as OHEvent;
}
beforeEach(() => {
useEventStore.setState({
events: [],
eventIds: new Set(),
uiEvents: [],
});
});
describe("useTaskList", () => {
it("returns empty taskList and hasTaskList=false when no events exist", () => {
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
it("returns empty taskList when no task tracking observations exist", () => {
useEventStore.setState({
events: [
{
id: 1,
source: "user",
action: "message",
args: { content: "Hello", image_urls: [], file_urls: [] },
message: "Hello",
timestamp: "2025-07-01T00:00:01Z",
},
],
eventIds: new Set([1]),
uiEvents: [],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
describe("v0 events", () => {
it('returns the task list from a TaskTrackingObservation with command="plan"', () => {
const tasks = [
{ id: "1", title: "First task", status: "todo" as const },
{ id: "2", title: "Second task", status: "in_progress" as const },
];
const event = createV0TaskTrackingObservation(1, "plan", tasks);
useEventStore.setState({
events: [event],
eventIds: new Set([1]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual(tasks);
expect(result.current.hasTaskList).toBe(true);
});
it('ignores TaskTrackingObservation events with command !== "plan"', () => {
const tasks = [{ id: "1", title: "First task", status: "todo" as const }];
const event = createV0TaskTrackingObservation(1, "update", tasks);
useEventStore.setState({
events: [event],
eventIds: new Set([1]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
it("returns the latest task list when multiple plan events exist", () => {
const earlyTasks = [
{ id: "1", title: "First task", status: "todo" as const },
];
const lateTasks = [
{ id: "1", title: "First task", status: "done" as const },
{ id: "2", title: "New task", status: "in_progress" as const },
];
const event1 = createV0TaskTrackingObservation(1, "plan", earlyTasks);
const event2 = createV0TaskTrackingObservation(2, "plan", lateTasks);
useEventStore.setState({
events: [event1, event2],
eventIds: new Set([1, 2]),
uiEvents: [event1, event2],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual(lateTasks);
expect(result.current.hasTaskList).toBe(true);
});
it("updates when new events are added to the store", () => {
const { result } = renderHook(() => useTaskList());
expect(result.current.hasTaskList).toBe(false);
const tasks = [{ id: "1", title: "New task", status: "todo" as const }];
const event = createV0TaskTrackingObservation(1, "plan", tasks);
act(() => {
useEventStore.setState({
events: [event],
eventIds: new Set([1]),
uiEvents: [event],
});
});
expect(result.current.taskList).toEqual(tasks);
expect(result.current.hasTaskList).toBe(true);
});
it("returns hasTaskList=false when the latest plan has an empty task list", () => {
const event = createV0TaskTrackingObservation(1, "plan", []);
useEventStore.setState({
events: [event],
eventIds: new Set([1]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
});
describe("v1 events", () => {
it('returns the task list from a v1 TaskTrackerObservation with command="plan"', () => {
const tasks = [
{ title: "First task", notes: "", status: "todo" as const },
{
title: "Second task",
notes: "some note",
status: "in_progress" as const,
},
];
const event = createV1TaskTrackerObservation("1", "plan", tasks);
useEventStore.setState({
events: [event],
eventIds: new Set(["1"]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([
{ id: "1", title: "First task", notes: undefined, status: "todo" },
{
id: "2",
title: "Second task",
notes: "some note",
status: "in_progress",
},
]);
expect(result.current.hasTaskList).toBe(true);
});
it('ignores v1 TaskTrackerObservation with command !== "plan"', () => {
const tasks = [
{ title: "First task", notes: "", status: "todo" as const },
];
const event = createV1TaskTrackerObservation("1", "view", tasks);
useEventStore.setState({
events: [event],
eventIds: new Set(["1"]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
it("returns the latest v1 task list when multiple plan events exist", () => {
const earlyTasks = [
{ title: "First task", notes: "", status: "todo" as const },
];
const lateTasks = [
{ title: "First task", notes: "", status: "done" as const },
{ title: "New task", notes: "wip", status: "in_progress" as const },
];
const event1 = createV1TaskTrackerObservation("1", "plan", earlyTasks);
const event2 = createV1TaskTrackerObservation("2", "plan", lateTasks);
useEventStore.setState({
events: [event1, event2],
eventIds: new Set(["1", "2"]),
uiEvents: [event1, event2],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([
{ id: "1", title: "First task", notes: undefined, status: "done" },
{ id: "2", title: "New task", notes: "wip", status: "in_progress" },
]);
expect(result.current.hasTaskList).toBe(true);
});
it("returns hasTaskList=false when the latest v1 plan has an empty task list", () => {
const event = createV1TaskTrackerObservation("1", "plan", []);
useEventStore.setState({
events: [event],
eventIds: new Set(["1"]),
uiEvents: [event],
});
const { result } = renderHook(() => useTaskList());
expect(result.current.taskList).toEqual([]);
expect(result.current.hasTaskList).toBe(false);
});
});
});
+71 -13
View File
@@ -2,7 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { createRoutesStub } from "react-router";
import { createRoutesStub, useSearchParams } from "react-router";
import LoginPage from "#/routes/login";
import OptionService from "#/api/option-service/option-service.api";
import AuthService from "#/api/auth-service/auth-service.api";
@@ -80,6 +80,29 @@ const RouterStub = createRoutesStub([
},
]);
function DestinationStub() {
const [params] = useSearchParams();
const loginMethod = params.get("login_method");
return (
<div data-testid="destination-page">
{loginMethod && (
<span data-testid="login-method-param">{loginMethod}</span>
)}
</div>
);
}
const RouterStubWithDestination = createRoutesStub([
{
Component: LoginPage,
path: "/login",
},
{
Component: DestinationStub,
path: "/settings",
},
]);
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
@@ -282,7 +305,9 @@ describe("LoginPage", () => {
await user.click(gitlabButton);
// URL includes state parameter added by handleAuthRedirect
expect(window.location.href).toContain("https://gitlab.com/oauth/authorize");
expect(window.location.href).toContain(
"https://gitlab.com/oauth/authorize",
);
});
it("should redirect to Bitbucket auth URL when Bitbucket button is clicked", async () => {
@@ -347,6 +372,30 @@ describe("LoginPage", () => {
);
});
it("should preserve login_method param when redirecting authenticated users", async () => {
// Arrange
vi.spyOn(AuthService, "authenticate").mockResolvedValue(true);
// Act
render(
<RouterStubWithDestination
initialEntries={["/login?returnTo=/settings&login_method=github"]}
/>,
{ wrapper: createWrapper() },
);
// Assert
await waitFor(
() => {
expect(screen.getByTestId("destination-page")).toBeInTheDocument();
expect(screen.getByTestId("login-method-param")).toHaveTextContent(
"github",
);
},
{ timeout: 2000 },
);
});
it("should redirect OSS mode users to home", async () => {
// @ts-expect-error - partial mock for testing
vi.spyOn(OptionService, "getConfig").mockResolvedValue({
@@ -552,10 +601,12 @@ describe("LoginPage", () => {
it("should pass buildOAuthStateData to LoginContent for OAuth state encoding", async () => {
const user = userEvent.setup();
const mockBuildOAuthStateData = vi.fn((baseState: Record<string, string>) => ({
...baseState,
invitation_token: "inv-test-token-12345",
}));
const mockBuildOAuthStateData = vi.fn(
(baseState: Record<string, string>) => ({
...baseState,
invitation_token: "inv-test-token-12345",
}),
);
useInvitationMock.mockReturnValue({
invitationToken: "inv-test-token-12345",
@@ -585,10 +636,12 @@ describe("LoginPage", () => {
it("should include invitation token in OAuth state when invitation is present", async () => {
const user = userEvent.setup();
const mockBuildOAuthStateData = vi.fn((baseState: Record<string, string>) => ({
...baseState,
invitation_token: "inv-test-token-12345",
}));
const mockBuildOAuthStateData = vi.fn(
(baseState: Record<string, string>) => ({
...baseState,
invitation_token: "inv-test-token-12345",
}),
);
useInvitationMock.mockReturnValue({
invitationToken: "inv-test-token-12345",
@@ -634,9 +687,14 @@ describe("LoginPage", () => {
clearInvitation: vi.fn(),
});
render(<RouterStub initialEntries={["/login?invitation_token=inv-url-token-67890"]} />, {
wrapper: createWrapper(),
});
render(
<RouterStub
initialEntries={["/login?invitation_token=inv-url-token-67890"]}
/>,
{
wrapper: createWrapper(),
},
);
await waitFor(() => {
expect(screen.getByText("AUTH$INVITATION_PENDING")).toBeInTheDocument();
@@ -366,6 +366,130 @@ describe("MainApp", () => {
});
});
describe("Re-authentication with stored login method", () => {
it("should show ReauthModal instead of redirecting to /login when login method exists", async () => {
// Arrange - user is unauthenticated but has a stored login method
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
response: { status: 401 },
isAxiosError: true,
});
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => {
if (key === "openhands_login_method") {
return "github";
}
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
// Act
renderWithLoginStub(RouterStubWithLogin, ["/"]);
// Assert - should show ReauthModal (with "Logging back in" text), not redirect to /login
await waitFor(
() => {
expect(screen.getByText("AUTH$LOGGING_BACK_IN")).toBeInTheDocument();
},
{ timeout: 2000 },
);
// Login page should NOT be shown when login method exists
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
});
it("should redirect to /login when no login method is stored", async () => {
// Arrange - user is unauthenticated and has no stored login method
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
response: { status: 401 },
isAxiosError: true,
});
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
// Act
renderWithLoginStub(RouterStubWithLogin, ["/"]);
// Assert - should redirect to /login
await waitFor(
() => {
expect(screen.getByTestId("login-page")).toBeInTheDocument();
},
{ timeout: 2000 },
);
});
});
describe("Loading states", () => {
it("should show loading spinner while config is loading without redirecting", async () => {
// Arrange - config never resolves (loading state)
vi.spyOn(OptionService, "getConfig").mockImplementation(
() => new Promise(() => {}),
);
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => {
if (key === "openhands_login_method") {
return "github";
}
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
// Act
renderWithLoginStub(RouterStubWithLogin, ["/"]);
// Assert - should show loading spinner
await waitFor(() => {
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
// Should NOT redirect to login while loading
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
});
it("should show loading spinner while auth is loading without redirecting", async () => {
// Arrange - auth never resolves (loading state)
vi.spyOn(AuthService, "authenticate").mockImplementation(
() => new Promise(() => {}),
);
vi.stubGlobal("localStorage", {
getItem: vi.fn((key: string) => {
if (key === "openhands_login_method") {
return "github";
}
return null;
}),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
});
// Act
renderWithLoginStub(RouterStubWithLogin, ["/"]);
// Assert - should show loading spinner
await waitFor(() => {
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
});
// Should NOT redirect to login while loading
expect(screen.queryByTestId("login-page")).not.toBeInTheDocument();
});
});
describe("Invitation URL Parameters", () => {
beforeEach(() => {
vi.spyOn(AuthService, "authenticate").mockRejectedValue({
@@ -0,0 +1,167 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import TaskListTab from "#/routes/task-list-tab";
import { useEventStore } from "#/stores/use-event-store";
import type { TaskTrackingObservation } from "#/types/core/observations";
// Mock i18n
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => {
const translations: Record<string, string> = {
COMMON$NO_TASKS: "No tasks yet",
TASK_TRACKING_OBSERVATION$TASK_NOTES: "Notes",
};
return translations[key] || key;
},
}),
}));
function createTaskTrackingObservation(
id: number,
tasks: TaskTrackingObservation["extras"]["task_list"],
): TaskTrackingObservation {
return {
id,
source: "agent",
observation: "task_tracking",
message: "Task tracking update",
timestamp: `2025-07-01T00:00:0${id}Z`,
cause: 0,
content: "",
extras: {
command: "plan",
task_list: tasks,
},
};
}
function setTasks(tasks: TaskTrackingObservation["extras"]["task_list"]) {
const event = createTaskTrackingObservation(1, tasks);
useEventStore.setState({
events: [event],
eventIds: new Set([1]),
uiEvents: [event],
});
}
beforeEach(() => {
useEventStore.setState({
events: [],
eventIds: new Set(),
uiEvents: [],
});
});
describe("TaskListTab", () => {
it("renders empty state with icon and message when there are no tasks", () => {
const { container } = render(<TaskListTab />);
expect(screen.getByText("No tasks yet")).toBeInTheDocument();
// Empty state should show the check-circle icon (rendered as SVG)
expect(container.querySelector("svg")).toBeInTheDocument();
});
it("renders empty state message using Text component (span)", () => {
render(<TaskListTab />);
const message = screen.getByText("No tasks yet");
expect(message.tagName).toBe("SPAN");
});
it("renders task items when tasks exist", () => {
setTasks([
{ id: "1", title: "Implement feature", status: "todo" },
{ id: "2", title: "Write tests", status: "in_progress" },
{ id: "3", title: "Deploy", status: "done" },
]);
const { container } = render(<TaskListTab />);
expect(screen.getByText("Implement feature")).toBeInTheDocument();
expect(screen.getByText("Write tests")).toBeInTheDocument();
expect(screen.getByText("Deploy")).toBeInTheDocument();
const taskItems = container.querySelectorAll('[data-name="item"]');
expect(taskItems).toHaveLength(3);
});
it("does not display task IDs", () => {
setTasks([
{ id: "task-1", title: "First task", status: "todo" },
]);
render(<TaskListTab />);
expect(screen.queryByText(/task-1/)).not.toBeInTheDocument();
});
it("highlights in_progress tasks with a background", () => {
setTasks([
{ id: "1", title: "Todo task", status: "todo" },
{ id: "2", title: "Active task", status: "in_progress" },
{ id: "3", title: "Done task", status: "done" },
]);
render(<TaskListTab />);
// Find each task item via its text, then check the wrapper div
const activeItem = screen.getByText("Active task").closest("[data-name]");
const activeWrapper = activeItem?.parentElement;
expect(activeWrapper?.className).toContain("bg-[#2D3039]");
const todoItem = screen.getByText("Todo task").closest("[data-name]");
expect(todoItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
const doneItem = screen.getByText("Done task").closest("[data-name]");
expect(doneItem?.parentElement?.className).not.toContain("bg-[#2D3039]");
});
it("displays task notes when present and omits when absent", () => {
setTasks([
{
id: "1",
title: "Task with notes",
status: "todo",
notes: "Important note",
},
{ id: "2", title: "Task without notes", status: "todo" },
]);
render(<TaskListTab />);
expect(screen.getByText("Notes: Important note")).toBeInTheDocument();
expect(screen.getAllByText(/^Notes:/)).toHaveLength(1);
});
it("uses the latest plan event when multiple exist", () => {
const event1 = createTaskTrackingObservation(1, [
{ id: "1", title: "Old task", status: "todo" },
]);
const event2 = createTaskTrackingObservation(2, [
{ id: "1", title: "Updated task", status: "done" },
{ id: "2", title: "New task", status: "in_progress" },
]);
useEventStore.setState({
events: [event1, event2],
eventIds: new Set([1, 2]),
uiEvents: [event1, event2],
});
render(<TaskListTab />);
expect(screen.queryByText("Old task")).not.toBeInTheDocument();
expect(screen.getByText("Updated task")).toBeInTheDocument();
expect(screen.getByText("New task")).toBeInTheDocument();
});
it("renders as a scrollable main element when tasks exist", () => {
setTasks([{ id: "1", title: "A task", status: "todo" }]);
render(<TaskListTab />);
const main = screen.getByRole("main");
expect(main).toBeInTheDocument();
});
});
@@ -0,0 +1,28 @@
import { describe, it, expect } from "vitest";
import { getGitPath } from "#/utils/get-git-path";
describe("getGitPath", () => {
it("should return /workspace/project when no repository is selected", () => {
expect(getGitPath(null)).toBe("/workspace/project");
expect(getGitPath(undefined)).toBe("/workspace/project");
});
it("should handle standard owner/repo format (GitHub)", () => {
expect(getGitPath("OpenHands/OpenHands")).toBe("/workspace/project/OpenHands");
expect(getGitPath("facebook/react")).toBe("/workspace/project/react");
});
it("should handle nested group paths (GitLab)", () => {
expect(getGitPath("modernhealth/frontend-guild/pan")).toBe("/workspace/project/pan");
expect(getGitPath("group/subgroup/repo")).toBe("/workspace/project/repo");
expect(getGitPath("a/b/c/d/repo")).toBe("/workspace/project/repo");
});
it("should handle single segment paths", () => {
expect(getGitPath("repo")).toBe("/workspace/project/repo");
});
it("should handle empty string", () => {
expect(getGitPath("")).toBe("/workspace/project");
});
});
@@ -35,23 +35,17 @@ export function TaskItem({ task }: TaskItemProps) {
const isDoneStatus = task.status === "done";
return (
<div
className="flex gap-[14px] items-center px-4 py-2 w-full"
data-name="item"
>
<div className="flex gap-2 items-center w-full" data-name="item">
<div className="shrink-0">{icon}</div>
<div className="flex flex-col items-start justify-center leading-[20px] text-nowrap whitespace-pre font-normal">
<div className="flex flex-col items-start justify-center leading-[16px] text-nowrap whitespace-pre font-normal">
<Typography.Text
className={cn(
"text-[12px] text-white",
isDoneStatus && "text-[#A3A3A3]",
"text-[12px]",
isDoneStatus ? "text-[#A3A3A3]" : "text-white",
)}
>
{task.title}
</Typography.Text>
<Typography.Text className="text-[10px] text-[#A3A3A3] font-normal">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_ID)}: {task.id}
</Typography.Text>
{task.notes && (
<Typography.Text className="text-[10px] text-[#A3A3A3]">
{t(I18nKey.TASK_TRACKING_OBSERVATION$TASK_NOTES)}: {task.notes}
@@ -16,8 +16,13 @@ const BrowserTab = lazy(() => import("#/routes/browser-tab"));
const ServedTab = lazy(() => import("#/routes/served-tab"));
const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
const PlannerTab = lazy(() => import("#/routes/planner-tab"));
const TaskListTab = lazy(() => import("#/routes/task-list-tab"));
const TAB_CONFIG = {
tasklist: {
component: TaskListTab,
titleKey: I18nKey.COMMON$TASK_LIST,
},
editor: {
component: EditorTab,
titleKey: I18nKey.COMMON$CHANGES,
@@ -27,7 +27,7 @@ export function ConversationTabNav({
data-testid={`conversation-tab-${tabValue}`}
className={cn(
"flex items-center gap-2 rounded-md cursor-pointer",
"pl-1.5 pr-2 py-1",
"pl-1.5 pr-2 py-1 lg:py-1.5",
"text-[#9299AA] bg-[#0D0F11]",
isActive && "bg-[#25272D] text-white",
isActive
@@ -13,6 +13,8 @@ import VSCodeIcon from "#/icons/vscode.svg?react";
import PillIcon from "#/icons/pill.svg?react";
import PillFillIcon from "#/icons/pill-fill.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import DoubleCheckIcon from "#/icons/double-check.svg?react";
import { useTaskList } from "#/hooks/use-task-list";
interface ConversationTabsContextMenuProps {
isOpen: boolean;
@@ -29,6 +31,8 @@ export function ConversationTabsContextMenu({
const { state, setUnpinnedTabs } =
useConversationLocalStorageState(conversationId);
const { hasTaskList } = useTaskList();
const tabConfig = [
{
tab: "planner",
@@ -42,6 +46,14 @@ export function ConversationTabsContextMenu({
{ tab: "browser", icon: GlobeIcon, i18nKey: I18nKey.COMMON$BROWSER },
];
if (hasTaskList) {
tabConfig.unshift({
tab: "tasklist",
icon: DoubleCheckIcon,
i18nKey: I18nKey.COMMON$TASK_LIST,
});
}
if (!isOpen) return null;
const handleTabClick = (tab: string) => {
@@ -7,6 +7,7 @@ import GitChanges from "#/icons/git_changes.svg?react";
import VSCodeIcon from "#/icons/vscode.svg?react";
import ThreeDotsVerticalIcon from "#/icons/three-dots-vertical.svg?react";
import LessonPlanIcon from "#/icons/lesson-plan.svg?react";
import DoubleCheckIcon from "#/icons/double-check.svg?react";
import { cn } from "#/utils/utils";
import { useConversationLocalStorageState } from "#/utils/conversation-local-storage";
import { ConversationTabNav } from "./conversation-tab-nav";
@@ -17,6 +18,7 @@ import { useConversationStore } from "#/stores/conversation-store";
import { ConversationTabsContextMenu } from "./conversation-tabs-context-menu";
import { useConversationId } from "#/hooks/use-conversation-id";
import { useSelectConversationTab } from "#/hooks/use-select-conversation-tab";
import { useTaskList } from "#/hooks/use-task-list";
export function ConversationTabs() {
const { conversationId } = useConversationId();
@@ -27,6 +29,8 @@ export function ConversationTabs() {
const { state: persistedState } =
useConversationLocalStorageState(conversationId);
const { hasTaskList } = useTaskList();
const {
selectTab,
isTabActive,
@@ -120,6 +124,18 @@ export function ConversationTabs() {
},
];
if (hasTaskList) {
tabs.unshift({
tabValue: "tasklist",
isActive: isTabActive("tasklist"),
icon: DoubleCheckIcon,
onClick: () => selectTab("tasklist"),
tooltipContent: t(I18nKey.COMMON$TASK_LIST),
tooltipAriaLabel: t(I18nKey.COMMON$TASK_LIST),
label: t(I18nKey.COMMON$TASK_LIST),
});
}
// Filter out unpinned tabs
const visibleTabs = tabs.filter(
(tab) => !persistedState.unpinnedTabs.includes(tab.tabValue),
+64
View File
@@ -0,0 +1,64 @@
import { useMemo } from "react";
import { useEventStore } from "#/stores/use-event-store";
import type { OHEvent } from "#/stores/use-event-store";
import { isTaskTrackingObservation } from "#/types/core/guards";
import type { OpenHandsParsedEvent } from "#/types/core";
import { isObservationEvent } from "#/types/v1/type-guards";
import type { OpenHandsEvent } from "#/types/v1/core";
import type { TaskTrackerObservation } from "#/types/v1/core/base/observation";
import type { ObservationEvent } from "#/types/v1/core/events/observation-event";
export interface TaskListItem {
id: string;
title: string;
status: "todo" | "in_progress" | "done";
notes?: string;
}
function getTaskListFromEvent(event: OHEvent): TaskListItem[] | null {
// v0 event format: observation is a string "task_tracking"
const v0 = event as OpenHandsParsedEvent;
if (isTaskTrackingObservation(v0) && v0.extras.command === "plan") {
return v0.extras.task_list.map((t) => ({
id: t.id,
title: t.title,
status: t.status,
notes: t.notes,
}));
}
// v1 event format: observation is an object with kind "TaskTrackerObservation"
const v1 = event as OpenHandsEvent;
if (
isObservationEvent(v1) &&
v1.observation.kind === "TaskTrackerObservation"
) {
const obs = (v1 as ObservationEvent<TaskTrackerObservation>).observation;
if (obs.command === "plan") {
return obs.task_list.map((t, i) => ({
id: String(i + 1),
title: t.title,
status: t.status,
notes: t.notes || undefined,
}));
}
}
return null;
}
export function useTaskList() {
const events = useEventStore((state) => state.events);
return useMemo(() => {
// Iterate in reverse to find the latest TaskTrackingObservation with command="plan"
for (let i = events.length - 1; i >= 0; i -= 1) {
const taskList = getTaskListFromEvent(events[i]);
if (taskList) {
return { taskList, hasTaskList: taskList.length > 0 };
}
}
return { taskList: [] as TaskListItem[], hasTaskList: false };
}, [events]);
}
+2
View File
@@ -993,6 +993,8 @@ export enum I18nKey {
COMMON$MORE_OPTIONS = "COMMON$MORE_OPTIONS",
COMMON$CREATE_A_PLAN = "COMMON$CREATE_A_PLAN",
COMMON$TASKS = "COMMON$TASKS",
COMMON$TASK_LIST = "COMMON$TASK_LIST",
COMMON$NO_TASKS = "COMMON$NO_TASKS",
COMMON$PLAN_MD = "COMMON$PLAN_MD",
COMMON$READ_MORE = "COMMON$READ_MORE",
COMMON$BUILD = "COMMON$BUILD",
+32
View File
@@ -15891,6 +15891,38 @@
"de": "Aufgaben",
"uk": "Завдання"
},
"COMMON$TASK_LIST": {
"en": "Task List",
"ja": "タスクリスト",
"zh-CN": "任务列表",
"zh-TW": "任務列表",
"ko-KR": "작업 목록",
"no": "Oppgaveliste",
"it": "Elenco attività",
"pt": "Lista de tarefas",
"es": "Lista de tareas",
"ar": "قائمة المهام",
"fr": "Liste des tâches",
"tr": "Görev listesi",
"de": "Aufgabenliste",
"uk": "Список завдань"
},
"COMMON$NO_TASKS": {
"en": "No tasks yet",
"ja": "タスクはまだありません",
"zh-CN": "暂无任务",
"zh-TW": "尚無任務",
"ko-KR": "아직 작업이 없습니다",
"no": "Ingen oppgaver ennå",
"it": "Nessuna attività",
"pt": "Nenhuma tarefa ainda",
"es": "Sin tareas aún",
"ar": "لا توجد مهام بعد",
"fr": "Aucune tâche pour le moment",
"tr": "Henüz görev yok",
"de": "Noch keine Aufgaben",
"uk": "Завдань поки немає"
},
"COMMON$PLAN_MD": {
"en": "Plan.md",
"ja": "Plan.md",
+4
View File
@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 10.5L6.5 14L14 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7 10.5L10.5 14L18 6" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 348 B

+9 -2
View File
@@ -40,11 +40,18 @@ export default function LoginPage() {
}, [config.isLoading, config.data?.app_mode, navigate]);
// Redirect authenticated users away from login page
// Preserve login_method param so useAuthCallback can store it for auto-login
React.useEffect(() => {
if (!isAuthLoading && isAuthed) {
navigate(returnTo, { replace: true });
const loginMethod = searchParams.get("login_method");
let destination = returnTo;
if (loginMethod) {
const separator = returnTo.includes("?") ? "&" : "?";
destination = `${returnTo}${separator}login_method=${encodeURIComponent(loginMethod)}`;
}
navigate(destination, { replace: true });
}
}, [isAuthed, isAuthLoading, navigate, returnTo]);
}, [isAuthed, isAuthLoading, navigate, returnTo, searchParams]);
if (isAuthLoading || config.isLoading) {
return (
+12 -8
View File
@@ -173,14 +173,17 @@ export default function MainApp() {
setLoginMethodExists(checkLoginMethodExists());
}, [isAuthed, checkLoginMethodExists]);
// Show loading spinner while config or auth is loading
const isLoading = config.isLoading || isAuthLoading;
// Only decide to redirect AFTER loading completes
const shouldRedirectToLogin =
config.isLoading ||
isAuthLoading ||
(!isAuthed &&
!isAuthError &&
!isOnIntermediatePage &&
config.data?.app_mode === "saas" &&
!loginMethodExists);
!isLoading &&
!isAuthed &&
!isAuthError &&
!isOnIntermediatePage &&
config.data?.app_mode === "saas" &&
!loginMethodExists;
React.useEffect(() => {
if (shouldRedirectToLogin) {
@@ -197,7 +200,8 @@ export default function MainApp() {
}
}, [shouldRedirectToLogin, pathname, searchParams, navigate]);
if (shouldRedirectToLogin) {
// Show loading spinner while loading OR when about to redirect
if (isLoading || shouldRedirectToLogin) {
return (
<div className="min-h-screen flex items-center justify-center bg-base">
<LoadingSpinner size="large" />
+41
View File
@@ -0,0 +1,41 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
import CheckCircleIcon from "#/icons/u-check-circle.svg?react";
import { TaskItem } from "#/components/features/chat/task-tracking/task-item";
import { useTaskList } from "#/hooks/use-task-list";
import { Text } from "#/ui/typography";
import { cn } from "#/utils/utils";
function TaskListTab() {
const { t } = useTranslation();
const { taskList } = useTaskList();
if (taskList.length === 0) {
return (
<div className="flex flex-col items-center justify-center w-full h-full p-10 gap-4">
<CheckCircleIcon width={109} height={109} color="#A1A1A1" />
<Text className="text-[#8D95A9] text-[19px] font-normal leading-5">
{t(I18nKey.COMMON$NO_TASKS)}
</Text>
</div>
);
}
return (
<main className="h-full overflow-y-auto flex flex-col custom-scrollbar-always">
{taskList.map((task) => (
<div
key={task.id}
className={cn(
"px-4 py-2",
task.status === "in_progress" && "bg-[#2D3039]",
)}
>
<TaskItem task={task} />
</div>
))}
</main>
);
}
export default TaskListTab;
+2 -1
View File
@@ -11,7 +11,8 @@ export type ConversationTab =
| "served"
| "vscode"
| "terminal"
| "planner";
| "planner"
| "tasklist";
export type ConversationMode = "code" | "plan";
+4 -4
View File
@@ -3,7 +3,7 @@
* If a repository is selected, returns /workspace/project/{repo-name}
* Otherwise, returns /workspace/project
*
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands" or "owner/repo")
* @param selectedRepository The selected repository (e.g., "OpenHands/OpenHands", "owner/repo", or "group/subgroup/repo")
* @returns The git path to use
*/
export function getGitPath(
@@ -13,10 +13,10 @@ export function getGitPath(
return "/workspace/project";
}
// Extract the repository name from "owner/repo" format
// The folder name is the second part after "/"
// Extract the repository name from the path
// The folder name is always the last part (handles both "owner/repo" and "group/subgroup/repo" formats)
const parts = selectedRepository.split("/");
const repoName = parts.length > 1 ? parts[1] : parts[0];
const repoName = parts[parts.length - 1];
return `/workspace/project/${repoName}`;
}
+2
View File
@@ -16,6 +16,7 @@ export const VERIFIED_MODELS = [
"gpt-5.2",
"minimax-m2.5",
"gemini-3-pro-preview",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"deepseek-chat",
"devstral-medium-2512",
@@ -65,6 +66,7 @@ export const VERIFIED_OPENHANDS_MODELS = [
"gpt-5.2",
"minimax-m2.5",
"gemini-3-pro-preview",
"gemini-3.1-pro-preview",
"gemini-3-flash-preview",
"devstral-medium-2512",
"kimi-k2-0711-preview",
@@ -216,13 +216,18 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
)
async def _parse_repository(
self, repo: dict, link_header: str | None = None
self,
repo: dict,
link_header: str | None = None,
fetch_default_branch: bool = False,
) -> Repository:
"""Parse a Bitbucket data center API repository response into a Repository object.
Args:
repo: Repository data from Bitbucket data center API
link_header: Optional link header for pagination
fetch_default_branch: Whether to make an additional API call to fetch the
default branch. Set to False for listing endpoints to avoid N+1 queries.
Returns:
Repository object
@@ -240,14 +245,15 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
is_public = repo.get('public', False)
main_branch: str | None = None
try:
default_branch_url = (
f'{self._repo_api_base(project_key, repo_slug)}/default-branch'
)
default_branch_data, _ = await self._make_request(default_branch_url)
main_branch = default_branch_data.get('displayId') or None
except Exception as e:
logger.debug(f'Could not fetch default branch for {full_name}: {e}')
if fetch_default_branch:
try:
default_branch_url = (
f'{self._repo_api_base(project_key, repo_slug)}/default-branch'
)
default_branch_data, _ = await self._make_request(default_branch_url)
main_branch = default_branch_data.get('displayId') or None
except Exception as e:
logger.debug(f'Could not fetch default branch for {full_name}: {e}')
return Repository(
id=str(repo.get('id', '')),
@@ -275,7 +281,7 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
owner, repo = self._extract_owner_and_repo(repository)
url = self._repo_api_base(owner, repo)
data, _ = await self._make_request(url)
return await self._parse_repository(data)
return await self._parse_repository(data, fetch_default_branch=True)
async def _get_cursorrules_url(self, repository: str) -> str:
"""Get the URL for checking .cursorrules file."""
+2
View File
@@ -115,6 +115,7 @@ REASONING_EFFORT_PATTERNS: list[str] = [
'o4-mini-2025-04-16',
'gemini-2.5-flash',
'gemini-2.5-pro',
'gemini-3.1-pro*',
'gpt-5*',
# DeepSeek reasoning family
'deepseek-r1-0528*',
@@ -139,6 +140,7 @@ PROMPT_CACHE_PATTERNS: list[str] = [
'claude-3-opus-20240229',
'claude-sonnet-4*',
'claude-opus-4*',
'gemini-3.1-pro*',
# Kimi series - verified via litellm config
'kimi-k2.5',
# GLM series - verified via litellm config
+1
View File
@@ -22,6 +22,7 @@ OPENHANDS_MODELS = [
'openhands/gpt-5.2',
'openhands/minimax-m2.5',
'openhands/gemini-3-pro-preview',
'openhands/gemini-3.1-pro-preview',
'openhands/gemini-3-flash-preview',
'openhands/deepseek-chat',
'openhands/devstral-medium-2512',
Generated
+38 -12
View File
@@ -1,4 +1,4 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
[[package]]
name = "agent-client-protocol"
@@ -3468,7 +3468,7 @@ files = [
[package.dependencies]
googleapis-common-protos = ">=1.5.5"
grpcio = ">=1.71.2"
protobuf = ">=5.26.1,<6.0.dev0"
protobuf = ">=5.26.1,<6.0dev"
[[package]]
name = "grpclib"
@@ -3836,7 +3836,7 @@ pfzy = ">=0.3.1,<0.4.0"
prompt-toolkit = ">=3.0.1,<4.0.0"
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "installer"
@@ -4287,7 +4287,7 @@ fqdn = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
idna = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
isoduration = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
jsonpointer = {version = ">1.13", optional = true, markers = "extra == \"format-nongpl\""}
jsonschema-specifications = ">=2023.3.6"
jsonschema-specifications = ">=2023.03.6"
referencing = ">=0.28.4"
rfc3339-validator = {version = "*", optional = true, markers = "extra == \"format-nongpl\""}
rfc3986-validator = {version = ">0.1.0", optional = true, markers = "extra == \"format-nongpl\""}
@@ -4752,7 +4752,7 @@ files = [
]
[package.dependencies]
certifi = ">=14.5.14"
certifi = ">=14.05.14"
durationpy = ">=0.7"
google-auth = ">=1.0.1"
oauthlib = ">=3.2.2"
@@ -6942,7 +6942,6 @@ files = [
{file = "pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523"},
{file = "pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f"},
]
markers = {runtime = "sys_platform != \"win32\" and sys_platform != \"emscripten\""}
[package.dependencies]
ptyprocess = ">=0.5"
@@ -6960,7 +6959,7 @@ files = [
]
[package.extras]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17b43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
docs = ["Sphinx (>=4.1.2,<5.0.0)", "furo (>=2021.8.17-beta.43,<2022.0.0)", "myst-parser (>=0.15.1,<0.16.0)", "sphinx-autobuild (>=2021.3.14,<2022.0.0)", "sphinx-copybutton (>=0.4.0,<0.5.0)"]
[[package]]
name = "pg8000"
@@ -7517,7 +7516,6 @@ files = [
{file = "ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35"},
{file = "ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220"},
]
markers = {runtime = "sys_platform != \"win32\" and sys_platform != \"emscripten\" or os_name != \"nt\""}
[[package]]
name = "pure-eval"
@@ -7546,6 +7544,18 @@ files = [
{file = "puremagic-1.30.tar.gz", hash = "sha256:f9ff7ac157d54e9cf3bff1addfd97233548e75e685282d84ae11e7ffee1614c9"},
]
[[package]]
name = "py"
version = "1.11.0"
description = "library with cross-python path, ini-parsing, io, code, log facilities"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["test"]
files = [
{file = "py-1.11.0-py2.py3-none-any.whl", hash = "sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"},
{file = "py-1.11.0.tar.gz", hash = "sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719"},
]
[[package]]
name = "py-key-value-aio"
version = "0.4.4"
@@ -11690,6 +11700,22 @@ pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "pytest-forked"
version = "1.6.0"
description = "run tests in isolated forked subprocesses"
optional = false
python-versions = ">=3.7"
groups = ["test"]
files = [
{file = "pytest-forked-1.6.0.tar.gz", hash = "sha256:4dafd46a9a600f65d822b8f605133ecf5b3e1941ebb3588e943b4e3eb71a5a3f"},
{file = "pytest_forked-1.6.0-py3-none-any.whl", hash = "sha256:810958f66a91afb1a1e2ae83089d8dc1cd2437ac96b12963042fbb9fb4d16af0"},
]
[package.dependencies]
py = "*"
pytest = ">=3.10"
[[package]]
name = "pytest-playwright"
version = "0.7.2"
@@ -12875,10 +12901,10 @@ files = [
]
[package.dependencies]
botocore = ">=1.37.4,<2.0a0"
botocore = ">=1.37.4,<2.0a.0"
[package.extras]
crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"]
[[package]]
name = "scantree"
@@ -14814,7 +14840,7 @@ files = [
]
[package.extras]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b0) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and python_version < \"3.14\"", "cffi (>=2.0.0b) ; platform_python_implementation != \"PyPy\" and python_version >= \"3.14\""]
[extras]
third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api-client"]
@@ -14822,4 +14848,4 @@ third-party-runtimes = ["daytona", "e2b-code-interpreter", "modal", "runloop-api
[metadata]
lock-version = "2.1"
python-versions = "^3.12,<3.14"
content-hash = "54d8d1b20ca7d88287c479f43f7bbe0402c9202cbdb24c9d091b2c23245d6c47"
content-hash = "7319bfec87aed5ed2803ad7cb947f875e83fa62216b1662a87b9b84078dc03e4"
+2
View File
@@ -130,6 +130,7 @@ test = [
"pytest",
"pytest-asyncio",
"pytest-cov",
"pytest-forked",
"pytest-playwright>=0.7",
"pytest-timeout>=2.4",
"pytest-xdist",
@@ -280,6 +281,7 @@ optional = true
pytest = "*"
pytest-cov = "*"
pytest-asyncio = "*"
pytest-forked = "*"
pytest-xdist = "*"
pytest-playwright = "^0.7.0"
pytest-timeout = "^2.4.0"
@@ -112,21 +112,15 @@ async def test_search_repositories_slash_query():
query = 'PROJ/myrepo'
mock_repo = _repo_dict('PROJ', slug='myrepo', name='My Repository')
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_fetch_paginated_data',
new=AsyncMock(return_value=[mock_repo]),
) as mock_fetch:
with patch.object(
svc,
'_make_request',
new=AsyncMock(return_value=(mock_default_branch, {})),
):
repos = await svc.search_repositories(
query, 25, 'name', 'asc', False, AppMode.SAAS
)
repos = await svc.search_repositories(
query, 25, 'name', 'asc', False, AppMode.SAAS
)
mock_fetch.assert_called_once_with(
'https://host.example.com/rest/api/1.0/projects/PROJ/repos',
@@ -135,6 +129,7 @@ async def test_search_repositories_slash_query():
)
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/myrepo'
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -143,24 +138,19 @@ async def test_search_repositories_slash_query_filters_by_name():
svc = make_service()
matching = _repo_dict('PROJ', slug='proj-alpha', name='My Repository')
non_matching = _repo_dict('PROJ', slug='proj-beta', name='Other Repo')
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_fetch_paginated_data',
new=AsyncMock(return_value=[matching, non_matching]),
):
with patch.object(
svc,
'_make_request',
new=AsyncMock(return_value=(mock_default_branch, {})),
):
repos = await svc.search_repositories(
'PROJ/my repository', 25, 'name', 'asc', False, AppMode.SAAS
)
repos = await svc.search_repositories(
'PROJ/my repository', 25, 'name', 'asc', False, AppMode.SAAS
)
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/proj-alpha'
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -169,24 +159,19 @@ async def test_search_repositories_slash_query_filters_by_slug():
svc = make_service()
matching = _repo_dict('PROJ', slug='my-repo', name='My Repository')
non_matching = _repo_dict('PROJ', slug='other-repo', name='Other Repository')
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_fetch_paginated_data',
new=AsyncMock(return_value=[matching, non_matching]),
):
with patch.object(
svc,
'_make_request',
new=AsyncMock(return_value=(mock_default_branch, {})),
):
repos = await svc.search_repositories(
'PROJ/my-repo', 25, 'name', 'asc', False, AppMode.SAAS
)
repos = await svc.search_repositories(
'PROJ/my-repo', 25, 'name', 'asc', False, AppMode.SAAS
)
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/my-repo'
assert repos[0].main_branch is None
# ── get_paginated_repos ───────────────────────────────────────────────────────
@@ -199,18 +184,18 @@ async def test_get_paginated_repos_parses_values():
'values': [_repo_dict()],
'isLastPage': True,
}
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_make_request',
side_effect=[(mock_response, {}), (mock_default_branch, {})],
return_value=(mock_response, {}),
):
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/myrepo'
assert repos[0].link_header == ''
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -221,17 +206,17 @@ async def test_get_paginated_repos_has_next_page():
'isLastPage': False,
'nextPageStart': 25,
}
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_make_request',
side_effect=[(mock_response, {}), (mock_default_branch, {})],
return_value=(mock_response, {}),
):
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
assert len(repos) == 1
assert 'rel="next"' in repos[0].link_header
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -241,17 +226,17 @@ async def test_get_paginated_repos_last_page():
'values': [_repo_dict()],
'isLastPage': True,
}
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_make_request',
side_effect=[(mock_response, {}), (mock_default_branch, {})],
return_value=(mock_response, {}),
):
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ')
assert len(repos) == 1
assert repos[0].link_header == ''
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -265,17 +250,17 @@ async def test_get_paginated_repos_filters_by_slug():
],
'isLastPage': True,
}
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_make_request',
side_effect=[(mock_response, {}), (mock_default_branch, {})],
return_value=(mock_response, {}),
):
repos = await svc.get_paginated_repos(1, 25, 'name', 'PROJ', query='my-repo')
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/my-repo'
assert repos[0].main_branch is None
@pytest.mark.asyncio
@@ -289,12 +274,11 @@ async def test_get_paginated_repos_filters_by_name():
],
'isLastPage': True,
}
mock_default_branch = {'displayId': 'main'}
with patch.object(
svc,
'_make_request',
side_effect=[(mock_response, {}), (mock_default_branch, {})],
return_value=(mock_response, {}),
):
repos = await svc.get_paginated_repos(
1, 25, 'name', 'PROJ', query='my repository'
@@ -302,6 +286,7 @@ async def test_get_paginated_repos_filters_by_name():
assert len(repos) == 1
assert repos[0].full_name == 'PROJ/proj-alpha'
assert repos[0].main_branch is None
# ── get_all_repositories ──────────────────────────────────────────────────────
@@ -320,14 +305,14 @@ async def test_get_all_repositories_iterates_projects():
return [_repo_dict('PROJ2', 'repo2')]
return []
mock_default_branch = {'displayId': 'main'}
with patch.object(svc, '_fetch_paginated_data', side_effect=fake_fetch):
with patch.object(svc, '_make_request', return_value=(mock_default_branch, {})):
repos = await svc.get_all_repositories('name', AppMode.SAAS)
repos = await svc.get_all_repositories('name', AppMode.SAAS)
full_names = {r.full_name for r in repos}
assert 'PROJ1/repo1' in full_names
assert 'PROJ2/repo2' in full_names
for repo in repos:
assert repo.main_branch is None
# ── get_installations ─────────────────────────────────────────────────────────
@@ -352,4 +337,4 @@ async def test_get_installations_returns_project_keys():
async def _make_parsed_repo(svc, repo_dict):
"""Helper to create a parsed Repository from a repo dict (with mocked default branch)."""
with patch.object(svc, '_make_request', return_value=({'displayId': 'main'}, {})):
return await svc._parse_repository(repo_dict)
return await svc._parse_repository(repo_dict, fetch_default_branch=True)