Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,14 @@ def nudge(self):
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
Expand Down
14 changes: 14 additions & 0 deletions api/stop.py
Original file line number Diff line number Diff line change
@@ -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 changes: 125 additions & 0 deletions tests/test_stop_agent.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions webui/components/chat/input/bottom-actions-bar.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand All @@ -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"/>
Expand Down Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions webui/components/chat/input/input-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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(
Expand Down
14 changes: 14 additions & 0 deletions webui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down