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