This commit is contained in:
Abhinav 2026-05-14 08:42:16 +08:00 committed by GitHub
commit 8623032256
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 188 additions and 0 deletions

View file

@ -240,6 +240,14 @@ class AgentContext:
self.task = self.communicate(UserMessage(self.agent0.read_prompt("fw.msg_nudge.md")))
return self.task
@extension.extensible
def stop(self):
"""Stop the running agent immediately, preserving conversation history."""
self.kill_process()
self.paused = False
self.streaming_agent = None
self.log.log(type="info", content="Agent stopped by user.")
@extension.extensible
def get_agent(self):
return self.streaming_agent or self.agent0

14
api/stop.py Normal file
View file

@ -0,0 +1,14 @@
from helpers.api import ApiHandler, Request, Response
class Stop(ApiHandler):
async def process(self, input: dict, request: Request) -> dict | Response:
ctxid = input.get("context", "")
if not ctxid:
raise Exception("No context id provided")
context = self.use_context(ctxid)
context.stop()
return {
"message": "Agent stopped.",
"context": context.id,
}

125
tests/test_stop_agent.py Normal file
View file

@ -0,0 +1,125 @@
import sys
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from agent import AgentContext
from initialize import initialize_agent
from api.stop import Stop
@pytest.fixture
def ctx():
ctxid = "ctx-stop-test"
context = AgentContext(config=initialize_agent(), id=ctxid, set_current=False)
yield context
AgentContext.remove(ctxid)
class TestAgentContextStop:
"""Unit tests for AgentContext.stop() method."""
def test_stop_clears_paused_state(self, ctx):
ctx.paused = True
ctx.stop()
assert ctx.paused is False
def test_stop_clears_streaming_agent(self, ctx):
ctx.streaming_agent = MagicMock()
ctx.stop()
assert ctx.streaming_agent is None
def test_stop_calls_kill_process(self, ctx):
with patch.object(ctx, "kill_process") as mock_kill:
ctx.stop()
mock_kill.assert_called_once()
def test_stop_logs_info_message(self, ctx):
initial_log_count = len(ctx.log.logs)
ctx.stop()
assert len(ctx.log.logs) > initial_log_count
last_log = ctx.log.logs[-1]
assert last_log.type == "info"
assert "stopped" in last_log.content.lower()
def test_stop_preserves_conversation_history(self, ctx):
ctx.log.log(type="user", heading="test", content="hello world")
log_count_before = len(ctx.log.logs)
ctx.stop()
# Should have original logs + the "stopped" info log
assert len(ctx.log.logs) == log_count_before + 1
def test_stop_does_not_reset_agent(self, ctx):
original_agent = ctx.agent0
ctx.stop()
assert ctx.agent0 is original_agent
def test_stop_when_not_running_is_safe(self, ctx):
"""Calling stop when nothing is running should not raise."""
ctx.task = None
ctx.paused = False
ctx.streaming_agent = None
ctx.stop() # should not raise
assert ctx.paused is False
def test_stop_differs_from_nudge(self, ctx):
"""Stop should NOT restart the agent (unlike nudge)."""
ctx.stop()
# After stop, task should be None (no new task started)
# nudge() would set self.task to a new communicate() call
assert ctx.task is None
def test_stop_differs_from_reset(self, ctx):
"""Stop should NOT clear the log (unlike reset)."""
ctx.log.log(type="user", heading="test", content="preserved")
ctx.stop()
# reset() calls log.reset() which clears everything
assert len(ctx.log.logs) >= 2 # original + stopped message
class TestStopApiHandler:
"""Tests for the api/stop.py endpoint."""
@pytest.mark.asyncio
async def test_stop_api_returns_success(self, ctx):
app = Flask("stop-api-test")
app.secret_key = "test-secret"
lock = threading.RLock()
handler = Stop(app, lock)
result = await handler.process({"context": ctx.id}, None)
assert result["message"] == "Agent stopped."
assert result["context"] == ctx.id
@pytest.mark.asyncio
async def test_stop_api_raises_without_context_id(self):
app = Flask("stop-api-test")
app.secret_key = "test-secret"
lock = threading.RLock()
handler = Stop(app, lock)
with pytest.raises(Exception, match="No context id provided"):
await handler.process({"context": ""}, None)
@pytest.mark.asyncio
async def test_stop_api_actually_stops_agent(self, ctx):
ctx.paused = True
ctx.streaming_agent = MagicMock()
app = Flask("stop-api-test")
app.secret_key = "test-secret"
lock = threading.RLock()
handler = Stop(app, lock)
await handler.process({"context": ctx.id}, None)
assert ctx.paused is False
assert ctx.streaming_agent is None

View file

@ -2,6 +2,7 @@
<head>
<script type="module">
import { store } from "/components/chat/input/input-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
</script>
</head>
<body>
@ -22,6 +23,16 @@
<span x-text="$store.chatInput.paused ? 'Resume Agent' : 'Pause Agent'"></span>
</button>
<button type="button" class="text-button stop-button"
@click="$store.chatInput.stopAgent()"
x-show="$store.chats?.selectedContext?.running"
title="Stop the agent immediately">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M6 6h12v12H6z"></path>
</svg>
<span>Stop Agent</span>
</button>
<button type="button" class="text-button" id="nudges_window" @click="$store.chatInput.nudge()">
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 49 58" fill="currentColor" width="14" height="14" aria-hidden="true">
<path d="m11.97,16.32c-.46,0-.91-.25-1.15-.68-.9-1.63-1.36-3.34-1.36-5.1C9.45,4.73,14.18,0,20,0s10.55,4.73,10.55,10.55c0,.87-.13,1.75-.41,2.76-.19.7-.9,1.13-1.62.93-.7-.19-1.12-.92-.93-1.62.21-.79.31-1.44.31-2.07,0-4.36-3.55-7.91-7.91-7.91s-7.91,3.55-7.91,7.91c0,1.3.35,2.59,1.03,3.82.36.64.13,1.44-.51,1.79-.21.11-.42.17-.64.17Z" stroke-width="0.5" stroke="currentColor"/>
@ -77,6 +88,10 @@
flex-shrink: 0;
}
.text-button p { margin-block: 0; }
.stop-button { color: var(--color-error, #e74c3c) !important; }
.stop-button:hover:not(:disabled) {
background-color: rgba(231, 76, 60, 0.1) !important;
}
@media (max-width: 768px) {
.text-buttons-row {

View file

@ -121,6 +121,18 @@ const model = {
}
},
async stopAgent() {
try {
const context = globalThis.getContext();
if (!globalThis.sendJsonData) throw new Error("sendJsonData not available");
await globalThis.sendJsonData("/stop", { context });
} catch (e) {
if (globalThis.toastFetchError) {
globalThis.toastFetchError("Error stopping agent", e);
}
}
},
async loadKnowledge() {
try {
const resp = await shortcuts.callJsonApi(

View file

@ -524,6 +524,20 @@ globalThis.pauseAgent = async function (paused) {
await inputStore.pauseAgent(paused);
};
globalThis.stopAgent = async function () {
await inputStore.stopAgent();
};
// Escape key to stop running agent (only when not typing in an input/textarea)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && chatsStore.selectedContext?.running) {
const tag = document.activeElement?.tagName;
if (tag === "TEXTAREA" || tag === "INPUT") return;
e.preventDefault();
globalThis.stopAgent();
}
});
function generateShortId() {
const chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";