| 1 | import threading |
| 2 | from pathlib import Path |
| 3 | |
| 4 | import pytest |
| 5 | from flask import Response |
| 6 | |
| 7 | from agent import AgentContext |
| 8 | from api.stop import Stop |
| 9 | |
| 10 | |
| 11 | PROJECT_ROOT = Path(__file__).resolve().parents[1] |
| 12 | |
| 13 | |
| 14 | class _Log: |
| 15 | def __init__(self) -> None: |
| 16 | self.progress_calls = [] |
| 17 | self.entries = [] |
| 18 | |
| 19 | def set_progress(self, progress: str, *, active: bool) -> None: |
| 20 | self.progress_calls.append((progress, active)) |
| 21 | |
| 22 | def log(self, **kwargs) -> None: |
| 23 | self.entries.append(kwargs) |
| 24 | |
| 25 | |
| 26 | class _Context: |
| 27 | id = "chat-1" |
| 28 | |
| 29 | def __init__(self) -> None: |
| 30 | self.paused = True |
| 31 | self.killed = False |
| 32 | self.log = _Log() |
| 33 | |
| 34 | def is_running(self) -> bool: |
| 35 | return True |
| 36 | |
| 37 | def kill_process(self) -> None: |
| 38 | self.killed = True |
| 39 | |
| 40 | |
| 41 | @pytest.mark.asyncio |
| 42 | async def test_stop_endpoint_cancels_without_replacing_the_run(monkeypatch) -> None: |
| 43 | context = _Context() |
| 44 | handler = Stop(app=None, thread_lock=threading.RLock()) # type: ignore[arg-type] |
| 45 | |
| 46 | def use_context(ctxid: str): |
| 47 | assert ctxid == context.id |
| 48 | return context |
| 49 | |
| 50 | monkeypatch.setattr(AgentContext, "use", use_context) |
| 51 | |
| 52 | result = await handler.process( |
| 53 | {"context": context.id}, request=None # type: ignore[arg-type] |
| 54 | ) |
| 55 | |
| 56 | assert context.killed is True |
| 57 | assert context.paused is False |
| 58 | assert context.log.progress_calls == [("", False)] |
| 59 | assert context.log.entries == [ |
| 60 | { |
| 61 | "type": "info", |
| 62 | "content": "Agent process stopped.", |
| 63 | "finished": True, |
| 64 | } |
| 65 | ] |
| 66 | assert result == { |
| 67 | "message": "Agent process stopped.", |
| 68 | "context": context.id, |
| 69 | "stopped": True, |
| 70 | } |
| 71 | |
| 72 | |
| 73 | @pytest.mark.asyncio |
| 74 | async def test_stop_endpoint_requires_an_explicit_context() -> None: |
| 75 | handler = Stop(app=None, thread_lock=threading.RLock()) # type: ignore[arg-type] |
| 76 | |
| 77 | result = await handler.process({}, request=None) # type: ignore[arg-type] |
| 78 | |
| 79 | assert isinstance(result, Response) |
| 80 | assert result.status_code == 400 |
| 81 | assert Stop.requires_auth() is True |
| 82 | assert Stop.requires_csrf() is True |
| 83 | |
| 84 | |
| 85 | def test_composer_stop_button_preserves_queue_keyboard_behavior() -> None: |
| 86 | input_store = ( |
| 87 | PROJECT_ROOT / "webui/components/chat/input/input-store.js" |
| 88 | ).read_text(encoding="utf-8") |
| 89 | chat_bar = ( |
| 90 | PROJECT_ROOT / "webui/components/chat/input/chat-bar-input.html" |
| 91 | ).read_text(encoding="utf-8") |
| 92 | |
| 93 | assert 'if (running && !hasInput) return "stop";' in input_store |
| 94 | assert 'if (state === "stop") return "Stop agent";' in input_store |
| 95 | assert 'await globalThis.sendJsonData("/stop", { context });' in input_store |
| 96 | assert '$store.chatInput.activateSendButton()' in chat_bar |
| 97 | assert ':aria-label="$store.chatInput.sendButtonTitle"' in chat_bar |
| 98 | assert "#send-button.stop" in chat_bar |
| 99 | |
| 100 | # Enter keeps using sendMessage(), whose empty-input path sends the queue. |
| 101 | assert "$event.preventDefault();\n this.sendMessage();" in input_store |
| 102 | assert 'return "Press Enter to send queued messages";' in input_store |
| 103 | |
| 104 | |
| 105 | def test_terminal_stop_info_closes_the_active_process_group() -> None: |
| 106 | messages_js = (PROJECT_ROOT / "webui/js/messages.js").read_text(encoding="utf-8") |
| 107 | |
| 108 | assert "delete displayKvps.finished;" in messages_js |
| 109 | assert "if (kvps?.finished) completeLastProcessGroup();" in messages_js |