mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-08-29 19:01:41 +00:00
1831 lines
66 KiB
Python
1831 lines
66 KiB
Python
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
|
|
|
|
import asyncio
|
|
import os
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi import Response
|
|
from fastapi.responses import StreamingResponse
|
|
from fastapi.testclient import TestClient
|
|
from pydantic import ValidationError
|
|
|
|
from app.controller.chat_controller import (
|
|
_admission_request_id,
|
|
_classify_persisted_admission,
|
|
_prepare_browser_for_request,
|
|
_prepare_chat_run,
|
|
_PreparedChatRun,
|
|
_require_supported_bundle_session_mode,
|
|
human_reply,
|
|
improve,
|
|
install_mcp,
|
|
post,
|
|
start_chat_stream,
|
|
status,
|
|
stop,
|
|
supplement,
|
|
)
|
|
from app.exception.exception import UserException
|
|
from app.model.chat import Chat, HumanReply, McpServers, Status, SupplementChat
|
|
from app.run_context import RunContext
|
|
from app.run_journal import SQLiteRunJournal
|
|
from app.run_runtime import RunCoordinator
|
|
from app.workspace_bundle.runtime import EnvironmentSetupRequiredError
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def controller_run_journal():
|
|
journal = MagicMock()
|
|
journal.get_run.return_value = None
|
|
journal.create_run_attempt.return_value = SimpleNamespace(
|
|
attempt_id="attempt-1", status="pending"
|
|
)
|
|
journal.list_run_attempts.return_value = []
|
|
journal.create_run_attempt.return_value = SimpleNamespace(
|
|
attempt_id="attempt-1",
|
|
status="pending",
|
|
)
|
|
with patch(
|
|
"app.controller.chat_controller.get_default_run_journal",
|
|
return_value=journal,
|
|
):
|
|
yield journal
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestChatController:
|
|
"""Test cases for chat controller endpoints."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_electron_browser_uses_owned_embedded_target_only(
|
|
self, mock_request
|
|
):
|
|
mock_request.state = SimpleNamespace()
|
|
with (
|
|
patch.dict(
|
|
os.environ,
|
|
{
|
|
"EIGENT_RUNTIME": "electron",
|
|
"EIGENT_ELECTRON_CDP_PORT": "9333",
|
|
},
|
|
clear=True,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.has_eigent_embedded_browser_target",
|
|
return_value=True,
|
|
) as mock_owned_target,
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint"
|
|
) as mock_external_launcher,
|
|
):
|
|
assert await _prepare_browser_for_request(
|
|
mock_request,
|
|
9222,
|
|
"about:blank#eigent-browser-toolkit=7",
|
|
)
|
|
|
|
assert mock_request.state.cdp_url == "http://127.0.0.1:9333"
|
|
assert mock_request.state.browser_port == 9333
|
|
assert mock_request.state.browser_available is True
|
|
mock_owned_target.assert_called_once_with(
|
|
"http://127.0.0.1:9333",
|
|
"about:blank#eigent-browser-toolkit=7",
|
|
)
|
|
mock_external_launcher.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_electron_browser_does_not_fallback_to_external_browser(
|
|
self, mock_request
|
|
):
|
|
mock_request.state = SimpleNamespace()
|
|
with (
|
|
patch.dict(
|
|
os.environ,
|
|
{"EIGENT_RUNTIME": "electron"},
|
|
clear=True,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.is_cdp_url_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.has_eigent_embedded_browser_target",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint"
|
|
) as mock_external_launcher,
|
|
):
|
|
assert not await _prepare_browser_for_request(mock_request, 9222)
|
|
|
|
assert mock_request.state.cdp_url is None
|
|
assert mock_request.state.browser_available is False
|
|
mock_external_launcher.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_standalone_brain_retains_external_browser_fallback(
|
|
self, mock_request
|
|
):
|
|
mock_request.state = SimpleNamespace()
|
|
with (
|
|
patch.dict(os.environ, {}, clear=True),
|
|
patch(
|
|
"app.controller.chat_controller.is_cdp_url_available",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint",
|
|
return_value="http://127.0.0.1:9444",
|
|
) as mock_external_launcher,
|
|
):
|
|
assert await _prepare_browser_for_request(mock_request, 9222)
|
|
|
|
assert mock_request.state.cdp_url == "http://127.0.0.1:9444"
|
|
assert mock_request.state.browser_port == 9444
|
|
mock_external_launcher.assert_called_once_with(9222)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_chat_endpoint_success(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
mock_task_lock,
|
|
mock_environment_variables,
|
|
controller_run_journal,
|
|
):
|
|
"""Test successful chat initialization."""
|
|
chat_data = Chat(**sample_chat_data)
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
):
|
|
# Mock async generator
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
yield "data: test_response_2\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
response = await post(chat_data, mock_request)
|
|
|
|
assert isinstance(response, StreamingResponse)
|
|
assert response.media_type == "text/event-stream"
|
|
controller_run_journal.ensure_run.assert_called_once_with(
|
|
run_id=chat_data.run_id or chat_data.task_id,
|
|
project_id=chat_data.project_id,
|
|
status="pending",
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_chat_admission_attaches_without_repeating_setup(
|
|
self, sample_chat_data, mock_request, mock_task_lock
|
|
):
|
|
chat_data = Chat(**sample_chat_data)
|
|
run_id = chat_data.run_id or chat_data.task_id
|
|
run_context = SimpleNamespace(run_id=run_id)
|
|
mock_task_lock.run_context = run_context
|
|
mock_task_lock.queue = asyncio.Queue()
|
|
coordinator = RunCoordinator()
|
|
release = asyncio.Event()
|
|
|
|
async def source():
|
|
await release.wait()
|
|
yield "data: once\n\n"
|
|
|
|
prepare = AsyncMock(
|
|
return_value=_PreparedChatRun(
|
|
task_lock=mock_task_lock,
|
|
run_context=run_context,
|
|
attempt_id="attempt-1",
|
|
initial_action=MagicMock(),
|
|
)
|
|
)
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_chat_run",
|
|
new=prepare,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve",
|
|
side_effect=lambda *_args: source(),
|
|
) as execution,
|
|
):
|
|
first_stream = await start_chat_stream(chat_data, mock_request)
|
|
# Let the detached consumer enter the patched ExecutionBackend
|
|
# before the patch scope can end.
|
|
await asyncio.sleep(0)
|
|
retry_stream = await start_chat_stream(chat_data, mock_request)
|
|
|
|
handle = await coordinator.get_handle(run_id)
|
|
assert handle is not None
|
|
assert handle.subscriber_count == 2
|
|
prepare.assert_awaited_once_with(
|
|
chat_data,
|
|
mock_request,
|
|
admission_request_id=_admission_request_id(
|
|
run_id,
|
|
question=chat_data.question,
|
|
attaches=chat_data.attaches,
|
|
project_context=chat_data.project_context,
|
|
),
|
|
)
|
|
assert execution.call_count == 1
|
|
|
|
first_next = asyncio.create_task(first_stream.__anext__())
|
|
retry_next = asyncio.create_task(retry_stream.__anext__())
|
|
release.set()
|
|
assert await asyncio.gather(first_next, retry_next) == [
|
|
"data: once\n\n",
|
|
"data: once\n\n",
|
|
]
|
|
await first_stream.aclose()
|
|
await retry_stream.aclose()
|
|
await coordinator.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pending_partial_admission_is_retryable_but_reuse_conflicts(
|
|
self, tmp_path
|
|
):
|
|
run_id = "run-partial"
|
|
request_id = _admission_request_id(
|
|
run_id,
|
|
question="original",
|
|
attaches=[],
|
|
project_context=None,
|
|
)
|
|
with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal:
|
|
journal.ensure_run(
|
|
run_id=run_id, project_id="project-1", status="pending"
|
|
)
|
|
journal.create_run_attempt(
|
|
run_id,
|
|
request_id=request_id,
|
|
reason="initial_execution",
|
|
activate=False,
|
|
)
|
|
|
|
retry, _attempt = await _classify_persisted_admission(
|
|
journal,
|
|
run_id=run_id,
|
|
request_id=request_id,
|
|
)
|
|
conflict, _attempt = await _classify_persisted_admission(
|
|
journal,
|
|
run_id=run_id,
|
|
request_id=_admission_request_id(
|
|
run_id,
|
|
question="different",
|
|
attaches=[],
|
|
project_context=None,
|
|
),
|
|
)
|
|
|
|
assert retry == "retry"
|
|
assert conflict == "conflict"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_admission_without_fingerprint_is_replay_only(
|
|
self, tmp_path
|
|
):
|
|
run_id = "run-legacy-partial"
|
|
with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal:
|
|
journal.ensure_run(
|
|
run_id=run_id, project_id="project-1", status="pending"
|
|
)
|
|
journal.create_run_attempt(
|
|
run_id,
|
|
request_id=f"initial:{run_id}",
|
|
reason="initial_execution",
|
|
activate=False,
|
|
)
|
|
|
|
admission, _attempt = await _classify_persisted_admission(
|
|
journal,
|
|
run_id=run_id,
|
|
request_id=_admission_request_id(
|
|
run_id,
|
|
question="unverifiable payload",
|
|
attaches=[],
|
|
project_context=None,
|
|
),
|
|
)
|
|
|
|
assert admission == "duplicate"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_persisted_run_replays_without_implicit_restart(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
controller_run_journal,
|
|
):
|
|
chat_data = Chat(**sample_chat_data)
|
|
run_id = chat_data.run_id or chat_data.task_id
|
|
coordinator = RunCoordinator()
|
|
controller_run_journal.get_run.return_value = SimpleNamespace(
|
|
run_id=run_id,
|
|
status="completed",
|
|
)
|
|
controller_run_journal.list_run_attempts.return_value = [
|
|
SimpleNamespace(
|
|
resume_request_id=_admission_request_id(
|
|
run_id,
|
|
question=chat_data.question,
|
|
attaches=chat_data.attaches,
|
|
project_context=chat_data.project_context,
|
|
),
|
|
status="completed",
|
|
)
|
|
]
|
|
controller_run_journal.list_events.return_value = [
|
|
SimpleNamespace(
|
|
legacy_step="end",
|
|
event_type="legacy.end",
|
|
payload={"summary": "already finished"},
|
|
)
|
|
]
|
|
prepare = AsyncMock()
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_chat_run",
|
|
new=prepare,
|
|
),
|
|
):
|
|
stream = await start_chat_stream(chat_data, mock_request)
|
|
chunks = [chunk async for chunk in stream]
|
|
|
|
prepare.assert_not_awaited()
|
|
assert len(chunks) == 1
|
|
assert '"step": "end"' in chunks[0]
|
|
assert "already finished" in chunks[0]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_explicit_resume_creates_new_attempt_on_same_run(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
mock_task_lock,
|
|
tmp_path,
|
|
):
|
|
run_id = sample_chat_data["task_id"]
|
|
chat_data = Chat(
|
|
**sample_chat_data,
|
|
run_id=run_id,
|
|
resume_request_id="resume-request-1",
|
|
)
|
|
coordinator = RunCoordinator()
|
|
release = asyncio.Event()
|
|
run_context = RunContext(
|
|
space_id="space-1",
|
|
project_id=chat_data.project_id,
|
|
run_id=run_id,
|
|
task_id=chat_data.task_id,
|
|
email=chat_data.email,
|
|
user_id=(
|
|
str(chat_data.user_id)
|
|
if chat_data.user_id is not None
|
|
else None
|
|
),
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "output",
|
|
camel_log_dir=tmp_path / "camel-log",
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
browser_port=chat_data.browser_port,
|
|
)
|
|
mock_task_lock.queue = asyncio.Queue()
|
|
|
|
async def source():
|
|
await release.wait()
|
|
yield "data: resumed\n\n"
|
|
|
|
with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal:
|
|
journal.ensure_run(run_id=run_id, project_id=chat_data.project_id)
|
|
first = journal.create_run_attempt(
|
|
run_id,
|
|
request_id="initial-request",
|
|
reason="initial_execution",
|
|
activate=True,
|
|
now=1,
|
|
)
|
|
journal.reconcile_startup(now=2)
|
|
prepare = AsyncMock(
|
|
return_value=_PreparedChatRun(
|
|
task_lock=mock_task_lock,
|
|
run_context=run_context,
|
|
attempt_id="resume-attempt",
|
|
initial_action=MagicMock(),
|
|
)
|
|
)
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_journal",
|
|
return_value=journal,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_chat_run",
|
|
new=prepare,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve",
|
|
side_effect=lambda *_args: source(),
|
|
),
|
|
):
|
|
stream = await start_chat_stream(chat_data, mock_request)
|
|
attempts = journal.list_run_attempts(run_id)
|
|
assert len(attempts) == 2
|
|
assert attempts[0].attempt_id == first.attempt_id
|
|
assert attempts[1].attempt_number == 2
|
|
assert attempts[1].resume_reason == "explicit_resume"
|
|
assert attempts[1].resume_request_id == "resume-request-1"
|
|
prepare.assert_awaited_once()
|
|
assert prepare.await_args.kwargs[
|
|
"resume_attempt"
|
|
].attempt_id == (attempts[1].attempt_id)
|
|
|
|
release.set()
|
|
assert await stream.__anext__() == "data: resumed\n\n"
|
|
await stream.aclose()
|
|
await coordinator.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_setup_failure_returns_run_to_interrupted(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
tmp_path,
|
|
):
|
|
run_id = sample_chat_data["task_id"]
|
|
chat_data = Chat(
|
|
**sample_chat_data,
|
|
run_id=run_id,
|
|
resume_request_id="resume-request-fails",
|
|
)
|
|
coordinator = RunCoordinator()
|
|
with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal:
|
|
journal.ensure_run(run_id=run_id, project_id=chat_data.project_id)
|
|
journal.create_run_attempt(
|
|
run_id,
|
|
request_id="initial-request",
|
|
reason="initial_execution",
|
|
activate=True,
|
|
now=1,
|
|
)
|
|
journal.reconcile_startup(now=2)
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_journal",
|
|
return_value=journal,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_chat_run",
|
|
new=AsyncMock(side_effect=RuntimeError("binding failed")),
|
|
),
|
|
patch("app.controller.chat_controller.step_solve") as solve,
|
|
):
|
|
with pytest.raises(RuntimeError, match="binding failed"):
|
|
await start_chat_stream(chat_data, mock_request)
|
|
|
|
run = journal.get_run(run_id)
|
|
assert run is not None
|
|
assert run.status == "interrupted"
|
|
assert run.active_attempt_id is None
|
|
assert not any(
|
|
attempt.status in {"pending", "running", "waiting_for_user"}
|
|
for attempt in journal.list_run_attempts(run_id)
|
|
)
|
|
solve.assert_not_called()
|
|
assert (
|
|
journal.list_run_attempts(run_id)[-1].status == "interrupted"
|
|
)
|
|
assert journal.list_events(run_id)[-1].payload["reason"] == (
|
|
"resume_admission_failed"
|
|
)
|
|
await coordinator.close()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resume_silently_backfills_legacy_environment_spec(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
mock_task_lock,
|
|
tmp_path,
|
|
):
|
|
run_id = sample_chat_data["task_id"]
|
|
chat_data = Chat(
|
|
**sample_chat_data,
|
|
run_id=run_id,
|
|
resume_request_id="resume-with-current-environment",
|
|
session_mode="single-agent",
|
|
)
|
|
resolver = MagicMock()
|
|
resolver.freeze_task_directories.return_value = SimpleNamespace(
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "output",
|
|
base_snapshot_id=None,
|
|
snapshot=MagicMock(),
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
)
|
|
resolver.space_root.return_value = tmp_path
|
|
git_coordinator = MagicMock()
|
|
|
|
with SQLiteRunJournal(tmp_path / "journal.sqlite3") as journal:
|
|
journal.ensure_run(
|
|
run_id=run_id,
|
|
project_id=chat_data.project_id,
|
|
status="interrupted",
|
|
)
|
|
resume_attempt = journal.create_run_attempt(
|
|
run_id,
|
|
request_id=chat_data.resume_request_id,
|
|
reason="explicit_resume",
|
|
activate=False,
|
|
)
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_journal",
|
|
return_value=journal,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_workspace_resolver",
|
|
return_value=resolver,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller."
|
|
"get_default_workspace_git_coordinator",
|
|
return_value=git_coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller."
|
|
"_prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._assemble_runtime_environment",
|
|
return_value=None,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._camel_log_dir",
|
|
return_value=tmp_path / "camel-log",
|
|
),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
):
|
|
prepared = await _prepare_chat_run(
|
|
chat_data,
|
|
mock_request,
|
|
resume_attempt=resume_attempt,
|
|
)
|
|
|
|
rebound = journal.get_run_attempt(resume_attempt.attempt_id)
|
|
assert rebound is not None
|
|
assert rebound.environment_spec_id is not None
|
|
assert prepared.attempt_id == resume_attempt.attempt_id
|
|
assert prepared.run_context.attempt_id == resume_attempt.attempt_id
|
|
assert mock_task_lock.run_context.attempt_id == (
|
|
resume_attempt.attempt_id
|
|
)
|
|
assert mock_task_lock.environment_spec_id == (
|
|
rebound.environment_spec_id
|
|
)
|
|
assert journal.list_events(run_id)[-1].event_type == (
|
|
"run.attempt_environment_bound"
|
|
)
|
|
assert journal.list_events(run_id)[-1].payload["reason"] == (
|
|
"legacy_environment_backfill"
|
|
)
|
|
git_coordinator.admit_run.assert_called_once()
|
|
|
|
def test_bundle_runtime_rejects_legacy_workforce_session_mode(self):
|
|
with pytest.raises(EnvironmentSetupRequiredError) as error:
|
|
_require_supported_bundle_session_mode("workforce", object())
|
|
|
|
assert error.value.issues == ("bundle_session_mode_unsupported",)
|
|
|
|
def test_bundle_runtime_accepts_persisted_follow_up_single_agent_mode(
|
|
self,
|
|
):
|
|
from app.model.chat import SupplementChat
|
|
|
|
follow_up = SupplementChat(question="continue from the pinned files")
|
|
assert not hasattr(follow_up, "session_mode")
|
|
_require_supported_bundle_session_mode("single-agent", object())
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_status_distinguishes_lock_from_live_consumer(
|
|
self, mock_task_lock
|
|
):
|
|
coordinator = RunCoordinator()
|
|
release = asyncio.Event()
|
|
mock_task_lock.status = Status.processing
|
|
mock_task_lock.current_task_id = "task-1"
|
|
mock_task_lock.run_context = SimpleNamespace(run_id="run-1")
|
|
|
|
async def source():
|
|
await release.wait()
|
|
yield "done"
|
|
|
|
subscription = await coordinator.start_with_subscription(
|
|
run_id="run-1",
|
|
stream_factory=source,
|
|
)
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
):
|
|
result = await status("project-1")
|
|
|
|
assert result["has_lock"] is True
|
|
assert result["run_id"] == "run-1"
|
|
assert result["consumer_alive"] is True
|
|
assert result["subscriber_count"] == 1
|
|
|
|
await subscription.aclose()
|
|
release.set()
|
|
await subscription.handle.wait()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_chat_sets_run_context_and_third_party_env(
|
|
self, sample_chat_data, mock_request, mock_task_lock
|
|
):
|
|
"""Run-scoped values stay in RunContext; CAMEL path keys are published."""
|
|
chat_data = Chat(**sample_chat_data)
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request",
|
|
return_value=True,
|
|
),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
patch.dict(os.environ, {}, clear=True),
|
|
):
|
|
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
await post(chat_data, mock_request)
|
|
|
|
run_context = mock_task_lock.run_context
|
|
assert run_context.api_key == "test_key"
|
|
assert run_context.api_base_url == "https://api.openai.com/v1"
|
|
assert run_context.browser_port == 8080
|
|
assert os.environ.get("CAMEL_LOG_DIR") == str(
|
|
run_context.camel_log_dir
|
|
)
|
|
assert os.environ.get("CAMEL_WORKDIR") == str(
|
|
run_context.task_output_root
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_chat_sets_cdp_url_when_browser_ready(
|
|
self, sample_chat_data, mock_request, mock_task_lock
|
|
):
|
|
"""Web mode should set EIGENT_CDP_URL after successful browser ensure."""
|
|
chat_data = Chat(**sample_chat_data)
|
|
mock_request.state = SimpleNamespace()
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch(
|
|
"app.controller.chat_controller.is_cdp_url_available",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint",
|
|
return_value="http://127.0.0.1:8080",
|
|
),
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
patch.dict(os.environ, {}, clear=True),
|
|
):
|
|
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
await post(chat_data, mock_request)
|
|
|
|
assert (
|
|
mock_task_lock.run_context.cdp_url == "http://127.0.0.1:8080"
|
|
)
|
|
assert mock_task_lock.run_context.browser_port == 8080
|
|
assert mock_request.state.browser_available is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_chat_clears_cdp_url_when_browser_unavailable(
|
|
self, sample_chat_data, mock_request, mock_task_lock
|
|
):
|
|
"""Web mode should mark browser unavailable and clear EIGENT_CDP_URL."""
|
|
chat_data = Chat(**sample_chat_data)
|
|
mock_request.state = SimpleNamespace()
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch(
|
|
"app.controller.chat_controller.is_cdp_url_available",
|
|
return_value=False,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint",
|
|
return_value=None,
|
|
),
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
patch.dict(
|
|
os.environ,
|
|
{"EIGENT_CDP_URL": "http://127.0.0.1:9222"},
|
|
clear=True,
|
|
),
|
|
):
|
|
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
await post(chat_data, mock_request)
|
|
|
|
assert mock_task_lock.run_context.cdp_url is None
|
|
assert mock_task_lock.run_context.browser_port == 8080
|
|
assert mock_request.state.browser_available is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_chat_preserves_existing_cdp_url(
|
|
self, sample_chat_data, mock_request, mock_task_lock
|
|
):
|
|
chat_data = Chat(**sample_chat_data)
|
|
mock_request.state = SimpleNamespace()
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch(
|
|
"app.controller.chat_controller.is_cdp_url_available",
|
|
return_value=True,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.ensure_cdp_browser_endpoint",
|
|
) as mock_ensure_browser,
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
patch.dict(
|
|
os.environ,
|
|
{"EIGENT_CDP_URL": "http://worker-17:9222"},
|
|
clear=True,
|
|
),
|
|
):
|
|
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
await post(chat_data, mock_request)
|
|
|
|
assert (
|
|
mock_task_lock.run_context.cdp_url == "http://worker-17:9222"
|
|
)
|
|
assert mock_task_lock.run_context.browser_port == 9222
|
|
assert mock_request.state.browser_available is True
|
|
mock_ensure_browser.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_improve_chat_success(self, mock_task_lock, mock_request):
|
|
"""Test successful chat improvement."""
|
|
task_id = "test_task_123"
|
|
supplement_data = SupplementChat(question="Improve this code")
|
|
mock_task_lock.status = Status.processing
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller."
|
|
"_prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
response = await improve(task_id, supplement_data, mock_request)
|
|
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 201
|
|
mock_task_lock.put_queue.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_follow_up_admission_persists_and_rebinds_live_consumer(
|
|
self,
|
|
mock_task_lock,
|
|
mock_request,
|
|
controller_run_journal,
|
|
tmp_path,
|
|
):
|
|
coordinator = RunCoordinator()
|
|
release = asyncio.Event()
|
|
|
|
async def source():
|
|
await release.wait()
|
|
yield "done"
|
|
|
|
subscription = await coordinator.start_with_subscription(
|
|
run_id="run-old",
|
|
stream_factory=source,
|
|
)
|
|
mock_task_lock.status = Status.processing
|
|
mock_task_lock.runtime_session_mode = "single-agent"
|
|
mock_task_lock.environment_admission_template = MagicMock()
|
|
mock_task_lock.email = "u@example.com"
|
|
mock_task_lock.user_id = "42"
|
|
mock_task_lock.space_id = "space-1"
|
|
mock_task_lock.run_context = RunContext(
|
|
space_id="space-1",
|
|
project_id="project-1",
|
|
run_id="run-old",
|
|
task_id="run-old",
|
|
email="u@example.com",
|
|
user_id="42",
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "old-output",
|
|
camel_log_dir=tmp_path / "old-log",
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
browser_port=9222,
|
|
attempt_id="attempt-old",
|
|
)
|
|
mock_request.state = SimpleNamespace(browser_port=9222, cdp_url=None)
|
|
frozen_dirs = SimpleNamespace(
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "new-output",
|
|
snapshot=MagicMock(),
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
base_snapshot_id=None,
|
|
)
|
|
resolver = MagicMock()
|
|
resolver.freeze_task_directories_for.return_value = frozen_dirs
|
|
admission_service = MagicMock()
|
|
follow_up_spec = SimpleNamespace(
|
|
spec_id="envspec-follow-up",
|
|
thinking_effort_requested=SimpleNamespace(value="medium"),
|
|
thinking_effort_effective=SimpleNamespace(value="medium"),
|
|
provider_parameter_name=None,
|
|
provider_value=None,
|
|
provider_capability_revision="provider-test",
|
|
)
|
|
admission_service.persist_for_run.return_value = SimpleNamespace(
|
|
spec=follow_up_spec,
|
|
binding=object(),
|
|
)
|
|
data = SupplementChat(question="next turn", task_id="run-new")
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=coordinator,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_workspace_resolver",
|
|
return_value=resolver,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.SQLiteRunJournal",
|
|
new=MagicMock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.EnvironmentAdmissionTemplate",
|
|
new=MagicMock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.EnvironmentAdmissionService",
|
|
return_value=admission_service,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._assemble_runtime_environment",
|
|
return_value=object(),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._camel_log_dir",
|
|
return_value=tmp_path / "new-log",
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.apply_run_env_for_third_party"
|
|
),
|
|
):
|
|
response = await improve("project-1", data, mock_request)
|
|
|
|
assert response.status_code == 201
|
|
controller_run_journal.ensure_run.assert_called_once_with(
|
|
run_id="run-new",
|
|
project_id="project-1",
|
|
status="pending",
|
|
)
|
|
assert await coordinator.get_handle("run-old") is None
|
|
assert await coordinator.get_handle("run-new") is subscription.handle
|
|
mock_task_lock.put_queue.assert_awaited_once()
|
|
admission_service.persist_for_run.assert_called_once()
|
|
assert mock_task_lock.run_context.attempt_id == "attempt-1"
|
|
queued = mock_task_lock.put_queue.await_args.args[0]
|
|
assert queued.attempt_id == mock_task_lock.run_context.attempt_id
|
|
|
|
await subscription.aclose()
|
|
release.set()
|
|
await subscription.handle.wait()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_follow_up_run_is_not_queued_again(
|
|
self,
|
|
mock_request,
|
|
controller_run_journal,
|
|
):
|
|
data = SupplementChat(question="duplicate", task_id="run-existing")
|
|
controller_run_journal.get_run.return_value = SimpleNamespace(
|
|
run_id="run-existing", status="completed"
|
|
)
|
|
controller_run_journal.list_run_attempts.return_value = [
|
|
SimpleNamespace(
|
|
resume_request_id=_admission_request_id(
|
|
"run-existing",
|
|
question=data.question,
|
|
attaches=data.attaches,
|
|
project_context=data.project_context,
|
|
),
|
|
status="completed",
|
|
)
|
|
]
|
|
|
|
with patch(
|
|
"app.controller.chat_controller._improve_chat",
|
|
new=AsyncMock(),
|
|
) as improve_chat:
|
|
response = await improve("project-1", data, mock_request)
|
|
|
|
assert response.status_code == 201
|
|
improve_chat.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_follow_up_rebind_failure_precedes_durable_admission(
|
|
self,
|
|
mock_task_lock,
|
|
mock_request,
|
|
controller_run_journal,
|
|
tmp_path,
|
|
):
|
|
old_context = RunContext(
|
|
space_id="space-1",
|
|
project_id="project-1",
|
|
run_id="run-old",
|
|
task_id="run-old",
|
|
email="u@example.com",
|
|
user_id="42",
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "old-output",
|
|
camel_log_dir=tmp_path / "old-log",
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
browser_port=9222,
|
|
)
|
|
mock_task_lock.run_context = old_context
|
|
mock_task_lock.status = Status.processing
|
|
mock_task_lock.email = "u@example.com"
|
|
mock_task_lock.user_id = "42"
|
|
mock_request.state = SimpleNamespace(browser_port=9222, cdp_url=None)
|
|
frozen_dirs = SimpleNamespace(
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "new-output",
|
|
snapshot=MagicMock(),
|
|
binding_source="test",
|
|
)
|
|
resolver = MagicMock()
|
|
resolver.freeze_task_directories_for.return_value = frozen_dirs
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=RunCoordinator(),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_workspace_resolver",
|
|
return_value=resolver,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._camel_log_dir",
|
|
return_value=tmp_path / "new-log",
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.apply_run_env_for_third_party"
|
|
),
|
|
):
|
|
with pytest.raises(UserException, match="no live consumer"):
|
|
await improve(
|
|
"project-1",
|
|
SupplementChat(question="next", task_id="run-new"),
|
|
mock_request,
|
|
)
|
|
|
|
controller_run_journal.ensure_run.assert_not_called()
|
|
controller_run_journal.create_run_attempt.assert_not_called()
|
|
mock_task_lock.put_queue.assert_not_awaited()
|
|
assert mock_task_lock.run_context is old_context
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_improve_chat_task_done_resets_to_confirming(
|
|
self, mock_task_lock, mock_request
|
|
):
|
|
"""Test improvement when task is done resets status to confirming."""
|
|
task_id = "test_task_123"
|
|
supplement_data = SupplementChat(question="Improve this code")
|
|
mock_task_lock.status = Status.done
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
response = await improve(task_id, supplement_data, mock_request)
|
|
|
|
assert mock_task_lock.status == Status.confirming
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 201
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_improve_rejects_follow_up_when_run_rotation_failed(
|
|
self, mock_task_lock, mock_request
|
|
):
|
|
"""A failed directory rotation must not admit the follow-up Run."""
|
|
|
|
# Use a real RunContext-shaped stand-in so getattr access works.
|
|
stale_run = SimpleNamespace(
|
|
run_id="stale_run_from_previous_turn",
|
|
space_id="blank_space_x",
|
|
project_id="project_x",
|
|
task_id="stale_task_id",
|
|
browser_port=9222,
|
|
cdp_url=None,
|
|
user_id="42",
|
|
email="u@example.com",
|
|
)
|
|
# Make isinstance(stale_run, RunContext) return True via patching.
|
|
mock_task_lock.run_context = stale_run
|
|
mock_task_lock.status = Status.processing
|
|
mock_task_lock.email = "u@example.com"
|
|
|
|
supplement_data = SupplementChat(
|
|
question="next turn", task_id="brand_new_task_id"
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.RunContext",
|
|
new=SimpleNamespace,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_workspace_resolver",
|
|
side_effect=RuntimeError(
|
|
"simulated resolver failure -- rotation never happens"
|
|
),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
with pytest.raises(UserException, match="durably prepare"):
|
|
await improve("project_x", supplement_data, mock_request)
|
|
mock_task_lock.put_queue.assert_not_awaited()
|
|
assert mock_task_lock.run_context is stale_run
|
|
|
|
def test_supplement_chat_success(self, mock_task_lock):
|
|
"""Test successful chat supplementation."""
|
|
task_id = "test_task_123"
|
|
supplement_data = SupplementChat(question="Add more details")
|
|
mock_task_lock.status = Status.done
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._queue_action_from_worker"
|
|
) as mock_queue_action,
|
|
):
|
|
response = supplement(task_id, supplement_data)
|
|
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 201
|
|
mock_queue_action.assert_called_once()
|
|
|
|
def test_supplement_chat_task_not_done_error(self, mock_task_lock):
|
|
"""Test supplementation fails when task is not done."""
|
|
task_id = "test_task_123"
|
|
supplement_data = SupplementChat(question="Add more details")
|
|
mock_task_lock.status = Status.processing
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
):
|
|
with pytest.raises(UserException):
|
|
supplement(task_id, supplement_data)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_chat_success(self, mock_task_lock):
|
|
"""Test successful chat stopping."""
|
|
task_id = "test_task_123"
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
),
|
|
):
|
|
response = await stop(task_id)
|
|
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 204
|
|
mock_task_lock.put_queue.assert_awaited_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_human_reply_success(self, mock_task_lock, mock_request):
|
|
"""Test successful human reply."""
|
|
task_id = "test_task_123"
|
|
reply_data = HumanReply(agent="test_agent", reply="This is my reply")
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.sync_step_event"
|
|
) as mock_sync_step,
|
|
):
|
|
mock_request.headers = {"authorization": "Bearer test"}
|
|
mock_task_lock.memory_service = None
|
|
mock_task_lock.run_context = None
|
|
mock_task_lock.current_task_id = task_id
|
|
response = await human_reply(task_id, reply_data, mock_request)
|
|
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 201
|
|
mock_task_lock.put_human_input.assert_awaited_once_with(
|
|
"test_agent", "This is my reply"
|
|
)
|
|
mock_task_lock.add_conversation.assert_called_once_with(
|
|
"human_reply",
|
|
{"agent": "test_agent", "reply": "This is my reply"},
|
|
)
|
|
mock_sync_step.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_human_reply_missing_task_lock_returns_user_error(
|
|
self, mock_request
|
|
):
|
|
"""Expired human replies should not raise backend program errors."""
|
|
task_id = "test_task_123"
|
|
reply_data = HumanReply(agent="test_agent", reply="late reply")
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=None,
|
|
):
|
|
with pytest.raises(UserException):
|
|
await human_reply(task_id, reply_data, mock_request)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_human_reply_cannot_consume_pending_approval(
|
|
self,
|
|
tmp_path,
|
|
mock_task_lock,
|
|
mock_request,
|
|
controller_run_journal,
|
|
):
|
|
task_id = "test_task_approval"
|
|
mock_task_lock.run_context = RunContext(
|
|
space_id="space-1",
|
|
project_id=task_id,
|
|
run_id="run-1",
|
|
task_id=task_id,
|
|
email="user@example.com",
|
|
user_id="user-1",
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path,
|
|
camel_log_dir=tmp_path / "camel_logs",
|
|
binding_source="test",
|
|
workdir_mode="workspace",
|
|
browser_port=9222,
|
|
)
|
|
controller_run_journal.list_human_interactions.return_value = [
|
|
SimpleNamespace(
|
|
interaction_id="approval-1",
|
|
interaction_type="approval",
|
|
request={"agent": "test_agent"},
|
|
)
|
|
]
|
|
reply_data = HumanReply(agent="test_agent", reply="yes")
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
):
|
|
with pytest.raises(UserException, match="approval decision"):
|
|
await human_reply(task_id, reply_data, mock_request)
|
|
|
|
mock_task_lock.put_human_input.assert_not_awaited()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_human_reply_cannot_bypass_approval_when_question_is_also_pending(
|
|
self,
|
|
tmp_path,
|
|
mock_task_lock,
|
|
mock_request,
|
|
controller_run_journal,
|
|
):
|
|
task_id = "test_task_mixed_interactions"
|
|
mock_task_lock.run_context = RunContext(
|
|
space_id="space-1",
|
|
project_id=task_id,
|
|
run_id="run-1",
|
|
task_id=task_id,
|
|
email="user@example.com",
|
|
user_id="user-1",
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path,
|
|
camel_log_dir=tmp_path / "camel_logs",
|
|
binding_source="test",
|
|
workdir_mode="workspace",
|
|
browser_port=9222,
|
|
)
|
|
controller_run_journal.get_run.return_value = SimpleNamespace(
|
|
active_attempt_id="attempt-1"
|
|
)
|
|
controller_run_journal.list_human_interactions.return_value = [
|
|
SimpleNamespace(
|
|
interaction_id="question-old",
|
|
attempt_id="attempt-1",
|
|
interaction_type="question",
|
|
request={"agent": "test_agent"},
|
|
),
|
|
SimpleNamespace(
|
|
interaction_id="approval-current",
|
|
attempt_id="attempt-1",
|
|
interaction_type="approval",
|
|
request={"agent": "test_agent"},
|
|
),
|
|
]
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
):
|
|
with pytest.raises(UserException, match="approval decision"):
|
|
await human_reply(
|
|
task_id,
|
|
HumanReply(agent="test_agent", reply="yes"),
|
|
mock_request,
|
|
)
|
|
|
|
mock_task_lock.put_human_input.assert_not_awaited()
|
|
controller_run_journal.resolve_human_interaction.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_interrupted_approval_does_not_block_current_question(
|
|
self,
|
|
tmp_path,
|
|
mock_task_lock,
|
|
mock_request,
|
|
controller_run_journal,
|
|
):
|
|
task_id = "test_task_stale_approval"
|
|
mock_task_lock.run_context = RunContext(
|
|
space_id="space-1",
|
|
project_id=task_id,
|
|
run_id="run-1",
|
|
task_id=task_id,
|
|
email="user@example.com",
|
|
user_id="user-1",
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path,
|
|
camel_log_dir=tmp_path / "camel_logs",
|
|
binding_source="test",
|
|
workdir_mode="workspace",
|
|
browser_port=9222,
|
|
)
|
|
controller_run_journal.get_run.return_value = SimpleNamespace(
|
|
active_attempt_id="attempt-2"
|
|
)
|
|
controller_run_journal.list_human_interactions.return_value = [
|
|
SimpleNamespace(
|
|
interaction_id="approval-stale",
|
|
attempt_id="attempt-1",
|
|
interaction_type="approval",
|
|
request={"agent": "test_agent"},
|
|
),
|
|
SimpleNamespace(
|
|
interaction_id="question-current",
|
|
attempt_id="attempt-2",
|
|
interaction_type="question",
|
|
request={"agent": "test_agent"},
|
|
version=0,
|
|
),
|
|
]
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists",
|
|
return_value=mock_task_lock,
|
|
):
|
|
await human_reply(
|
|
task_id,
|
|
HumanReply(
|
|
agent="test_agent",
|
|
reply="continue",
|
|
interaction_id="question-current",
|
|
),
|
|
mock_request,
|
|
)
|
|
|
|
mock_task_lock.put_human_input.assert_awaited_once_with(
|
|
"test_agent", "continue"
|
|
)
|
|
controller_run_journal.resolve_human_interaction.assert_called_once()
|
|
|
|
def test_install_mcp_success(self, mock_task_lock):
|
|
"""Test successful MCP installation."""
|
|
task_id = "test_task_123"
|
|
mcp_data: McpServers = {
|
|
"mcpServers": {"test_server": {"config": "test"}}
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._queue_action_from_worker"
|
|
) as mock_queue_action,
|
|
):
|
|
response = install_mcp(task_id, mcp_data)
|
|
|
|
assert isinstance(response, Response)
|
|
assert response.status_code == 201
|
|
mock_queue_action.assert_called_once()
|
|
|
|
|
|
@pytest.mark.integration
|
|
class TestChatControllerIntegration:
|
|
"""Integration tests for chat controller."""
|
|
|
|
def test_chat_endpoint_integration(
|
|
self, client: TestClient, sample_chat_data
|
|
):
|
|
"""Test chat endpoint through FastAPI test client."""
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock"
|
|
) as mock_create_lock,
|
|
patch(
|
|
"app.controller.chat_controller.step_solve"
|
|
) as mock_step_solve,
|
|
patch("app.controller.chat_controller.load_dotenv"),
|
|
patch("app.controller.chat_controller.set_current_task_id"),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch("pathlib.Path.mkdir"),
|
|
patch("pathlib.Path.home", return_value=MagicMock()),
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_task_lock.put_queue = AsyncMock()
|
|
mock_create_lock.return_value = mock_task_lock
|
|
|
|
async def mock_generator():
|
|
yield "data: test_response\n\n"
|
|
|
|
mock_step_solve.return_value = mock_generator()
|
|
|
|
response = client.post("/chat", json=sample_chat_data)
|
|
|
|
assert response.status_code == 200
|
|
assert (
|
|
response.headers["content-type"]
|
|
== "text/event-stream; charset=utf-8"
|
|
)
|
|
|
|
def test_improve_chat_endpoint_integration(self, client: TestClient):
|
|
"""Test improve chat endpoint through FastAPI test client."""
|
|
task_id = "test_task_123"
|
|
supplement_data = {"question": "Improve this code"}
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock"
|
|
) as mock_get_lock,
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_task_lock.status = Status.processing
|
|
mock_task_lock.put_queue = AsyncMock()
|
|
mock_get_lock.return_value = mock_task_lock
|
|
|
|
response = client.post(f"/chat/{task_id}", json=supplement_data)
|
|
|
|
assert response.status_code == 201
|
|
|
|
def test_supplement_chat_endpoint_integration(self, client: TestClient):
|
|
"""Test supplement chat endpoint through FastAPI test client."""
|
|
task_id = "test_task_123"
|
|
supplement_data = {"question": "Add more details"}
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock"
|
|
) as mock_get_lock,
|
|
patch("app.controller.chat_controller._queue_action_from_worker"),
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_task_lock.status = Status.done
|
|
mock_get_lock.return_value = mock_task_lock
|
|
|
|
response = client.put(f"/chat/{task_id}", json=supplement_data)
|
|
|
|
assert response.status_code == 201
|
|
|
|
def test_stop_chat_endpoint_integration(self, client: TestClient):
|
|
"""Test stop chat endpoint through FastAPI test client."""
|
|
task_id = "test_task_123"
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists"
|
|
) as mock_get_lock,
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_task_lock.put_queue = AsyncMock()
|
|
mock_get_lock.return_value = mock_task_lock
|
|
|
|
response = client.delete(f"/chat/{task_id}")
|
|
|
|
assert response.status_code == 204
|
|
|
|
def test_human_reply_endpoint_integration(self, client: TestClient):
|
|
"""Test human reply endpoint through FastAPI test client."""
|
|
task_id = "test_task_123"
|
|
reply_data = {"agent": "test_agent", "reply": "This is my reply"}
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock_if_exists"
|
|
) as mock_get_lock,
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_task_lock.put_human_input = AsyncMock()
|
|
mock_task_lock.current_task_id = task_id
|
|
mock_task_lock.memory_service = None
|
|
mock_task_lock.run_context = None
|
|
mock_get_lock.return_value = mock_task_lock
|
|
|
|
response = client.post(
|
|
f"/chat/{task_id}/human-reply", json=reply_data
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
|
|
def test_install_mcp_endpoint_integration(self, client: TestClient):
|
|
"""Test install MCP endpoint through FastAPI test client."""
|
|
task_id = "test_task_123"
|
|
mcp_data = {"mcpServers": {"test_server": {"config": "test"}}}
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock"
|
|
) as mock_get_lock,
|
|
patch("app.controller.chat_controller._queue_action_from_worker"),
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_get_lock.return_value = mock_task_lock
|
|
|
|
response = client.post(
|
|
f"/chat/{task_id}/install-mcp", json=mcp_data
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
|
|
|
|
@pytest.mark.model_backend
|
|
class TestChatControllerWithLLM:
|
|
"""Tests that require LLM backend (marked for selective running)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_with_real_llm_model(
|
|
self, sample_chat_data, mock_request
|
|
):
|
|
"""Test chat endpoint with real LLM model (slow test)."""
|
|
# This test would use actual LLM models and should be marked accordingly
|
|
Chat(**sample_chat_data)
|
|
|
|
# Test implementation would involve real model calls
|
|
# This is marked as model_backend test for selective execution
|
|
assert True # Placeholder
|
|
|
|
@pytest.mark.very_slow
|
|
async def test_full_chat_workflow_with_llm(
|
|
self, sample_chat_data, mock_request
|
|
):
|
|
"""Test complete chat workflow with LLM (very slow test)."""
|
|
# This test would run the complete workflow including actual agent interactions
|
|
# Marked as very_slow for execution only in full test mode
|
|
assert True # Placeholder
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestChatControllerErrorCases:
|
|
"""Test error cases and edge conditions."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_with_invalid_data(self, mock_request):
|
|
"""Test chat endpoint with invalid data."""
|
|
# Construction itself should raise a validation error due to multiple invalid fields
|
|
with pytest.raises((ValueError, TypeError, ValidationError)):
|
|
Chat(
|
|
task_id="", # Invalid empty task_id
|
|
email="invalid_email", # Invalid email format
|
|
question="", # Empty question
|
|
attaches=[],
|
|
model="invalid_model", # Field not defined in model -> triggers error
|
|
model_platform="invalid_platform",
|
|
api_key="",
|
|
api_url="invalid_url",
|
|
new_agents=[],
|
|
env_path="nonexistent.env",
|
|
browser_port=-1, # Invalid port
|
|
summary_prompt="",
|
|
)
|
|
# If future validation moves to endpoint level, keep logic placeholder below.
|
|
# (Intentionally not calling post with invalid Chat object since creation fails.)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_improve_with_nonexistent_task(self):
|
|
"""Test improve endpoint with nonexistent task."""
|
|
task_id = "nonexistent_task"
|
|
supplement_data = SupplementChat(question="Improve this code")
|
|
request = SimpleNamespace()
|
|
|
|
with patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
side_effect=KeyError("Task not found"),
|
|
):
|
|
with pytest.raises(KeyError):
|
|
await improve(task_id, supplement_data, request)
|
|
|
|
def test_supplement_with_empty_question(self, mock_task_lock):
|
|
"""Test supplement endpoint with empty question."""
|
|
task_id = "test_task_123"
|
|
supplement_data = SupplementChat(question="")
|
|
mock_task_lock.status = Status.done
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch("app.controller.chat_controller._queue_action_from_worker"),
|
|
):
|
|
# Should handle empty question gracefully or raise appropriate error
|
|
response = supplement(task_id, supplement_data)
|
|
assert response.status_code == 201 # Or should it be an error?
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_environment_setup_failure(
|
|
self, sample_chat_data, mock_request
|
|
):
|
|
"""Test chat endpoint when environment setup fails."""
|
|
chat_data = Chat(**sample_chat_data)
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock"
|
|
) as mock_create_lock,
|
|
patch(
|
|
"app.controller.chat_controller.sanitize_env_path",
|
|
return_value="/tmp/fake.env",
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.load_dotenv",
|
|
side_effect=Exception("Env load failed"),
|
|
),
|
|
patch(
|
|
"pathlib.Path.mkdir",
|
|
side_effect=Exception("Directory creation failed"),
|
|
),
|
|
):
|
|
mock_task_lock = MagicMock()
|
|
mock_create_lock.return_value = mock_task_lock
|
|
|
|
# Should handle environment setup failures gracefully
|
|
with pytest.raises(Exception):
|
|
await post(chat_data, mock_request)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_bundle_runtime_setup_failure_prevents_attempt_and_dispatch(
|
|
self,
|
|
sample_chat_data,
|
|
mock_request,
|
|
mock_task_lock,
|
|
tmp_path,
|
|
):
|
|
chat_data = Chat(**sample_chat_data)
|
|
database = tmp_path / "run-journal.sqlite3"
|
|
journal = SQLiteRunJournal(database)
|
|
resolver = MagicMock()
|
|
resolver.freeze_task_directories.return_value = SimpleNamespace(
|
|
working_directory=tmp_path,
|
|
task_output_root=tmp_path / "output",
|
|
base_snapshot_id=None,
|
|
snapshot=MagicMock(),
|
|
binding_source="test",
|
|
workdir_mode=None,
|
|
)
|
|
resolver.space_root.return_value = tmp_path
|
|
|
|
with (
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_journal",
|
|
return_value=journal,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_default_run_coordinator",
|
|
return_value=RunCoordinator(),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_or_create_task_lock",
|
|
return_value=mock_task_lock,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller.get_workspace_resolver",
|
|
return_value=resolver,
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._prepare_browser_for_request_with_timeout",
|
|
new=AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._camel_log_dir",
|
|
return_value=tmp_path / "camel-log",
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._legacy_environment_template",
|
|
return_value=MagicMock(),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller."
|
|
"EnvironmentAdmissionService.persist_for_run",
|
|
return_value=SimpleNamespace(
|
|
spec=MagicMock(),
|
|
binding=MagicMock(),
|
|
),
|
|
),
|
|
patch(
|
|
"app.controller.chat_controller._assemble_runtime_environment",
|
|
side_effect=EnvironmentSetupRequiredError(
|
|
["mcp_destination_confirmation_required"]
|
|
),
|
|
),
|
|
patch("app.controller.chat_controller.step_solve") as solve,
|
|
patch(
|
|
"app.agent.factory.toolkit_assembler.assemble_single_agent_toolkits",
|
|
new=AsyncMock(),
|
|
) as assemble_toolkits,
|
|
):
|
|
with pytest.raises(UserException) as error:
|
|
await start_chat_stream(chat_data, mock_request)
|
|
|
|
assert error.value.error_code == "environment_setup_required"
|
|
assert (
|
|
journal.list_run_attempts(chat_data.run_id or chat_data.task_id)
|
|
== []
|
|
)
|
|
solve.assert_not_called()
|
|
assemble_toolkits.assert_not_awaited()
|
|
journal.close()
|