Skip to content
Merged
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
5 changes: 5 additions & 0 deletions claw/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class AppConfig(BaseSettings):
MAX_AGENT_RETRIES: int = 3
ENABLE_PROGRESS_UPDATES: bool = True
CLAW_REPLY_HEADER: bool = True
CLAW_CLOSE_COMMANDS: str = "/close,close,close thread,close session,bye,goodbye,done"

@property
def close_commands(self) -> set[str]:
return {c.strip().lower() for c in self.CLAW_CLOSE_COMMANDS.split(",") if c.strip()}

@property
def uc_mcp_connections(self) -> list[str]:
Expand Down
31 changes: 31 additions & 0 deletions claw/core/slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,11 @@ async def _handle_message(self, message: dict) -> None:
channel = self._channel_id
text = message.get("text", "")

# Check for close command
if text.strip().lower() in self._config.close_commands:
await self._close_thread(thread_ts, channel)
return

try:
with DbSession(self._db_engine) as db:
# Get or create session
Expand Down Expand Up @@ -281,6 +286,32 @@ async def _handle_message(self, message: dict) -> None:
except Exception:
logger.exception("Error handling message ts=%s", ts)

async def _close_thread(self, thread_ts: str, channel: str) -> None:
# Cancel pending response task
task = self._response_tasks.pop(thread_ts, None)
if task is not None:
task.cancel()
# Disconnect client
client = self._clients.pop(thread_ts, None)
if client is not None:
try:
await client.disconnect()
except Exception:
logger.exception("Failed to disconnect client for thread %s", thread_ts)
# Close session in DB
with DbSession(self._db_engine) as db:
close_session(db, thread_ts)
# Send farewell
name = self._config.CLAW_NAME.capitalize()
result = await reply_to_thread(
self._ws, self._config, channel, thread_ts,
f"_{name} session closed. Mention me again to start a new conversation._",
)
reply_ts = result.get("ts")
if reply_ts:
self._posted_ts.add(reply_ts)
logger.info("Thread %s closed by user command", thread_ts)

async def _evict_lra_client(self) -> None:
"""Evict the least-recently-active client to free resources."""
if not self._clients:
Expand Down
28 changes: 28 additions & 0 deletions openspec/changes/manual-close/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# OpenSpec: Manual Thread Close via Slash Command

## Summary
Allow users to close an active Claw session by typing a close command (e.g. `/close`, `bye`, `done`).
When triggered, the poller cancels any pending response task, disconnects the agent client,
closes the DB session, and posts a farewell message.

## Motivation
Users currently have no way to explicitly end a conversation thread. Sessions only expire
via the 7-day inactivity eviction. A manual close command gives users immediate control.

## Changes

### 1. `claw/core/config.py`
- Add `CLAW_CLOSE_COMMANDS: str` setting with default close phrases.
- Add `close_commands` property that parses the CSV into a `set[str]`.

### 2. `claw/core/slack_poller.py`
- In `_handle_message`, check incoming text against `close_commands` BEFORE client lookup/agent dispatch.
- If matched, call new `_close_thread` method and return early (no agent run).
- `_close_thread`: cancel task, disconnect client, close DB session, send farewell.

## Test Plan (tests/test_slack_poller.py)
1. `test_close_command_closes_session` — `/close` triggers `close_session`
2. `test_close_command_disconnects_client` — client `.disconnect()` called
3. `test_close_command_sends_farewell` — `reply_to_thread` called with farewell text
4. `test_close_command_does_not_run_agent` — `create_client`/`send_message` NOT called
5. `test_non_close_message_runs_agent` — normal message still dispatches agent
106 changes: 106 additions & 0 deletions tests/test_slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -2055,3 +2055,109 @@ async def _aiter():
reply_text = mock_reply.call_args[1].get("text") or mock_reply.call_args[0][4]
assert reply_text == "hello world"
assert not reply_text.startswith("*Claw:*")


# ---------------------------------------------------------------------------
# Manual close command — issue #77
# ---------------------------------------------------------------------------

class TestManualClose:
"""Tests for the /close slash-command and close synonyms."""

def _make_poller(self):
ws = _make_ws({"ok": True, "messages": []})
cfg = _make_config()
cfg.close_commands = {"/close", "close", "close thread", "close session", "bye", "goodbye", "done"}
cfg.CLAW_NAME = "claw"
cfg.ENABLE_PROGRESS_UPDATES = False
cfg.CLAW_REPLY_HEADER = False
poller = SlackPoller(ws, cfg, _make_db_engine())
poller._channel_id = "C123"
poller._user_id = "U_BOT"
return poller

@pytest.mark.asyncio
async def test_close_command_closes_session(self):
"""'/close' triggers close_session in DB."""
poller = self._make_poller()

with patch("claw.core.slack_poller.DbSession"), \
patch("claw.core.slack_poller.close_session") as mock_close, \
patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}):
await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001"))

mock_close.assert_called_once()
# The second arg should be the thread_ts
assert mock_close.call_args[0][1] == "200.001"

@pytest.mark.asyncio
async def test_close_command_disconnects_client(self):
"""Close command disconnects an existing agent client."""
poller = self._make_poller()
mock_client = AsyncMock()
poller._clients["200.001"] = mock_client

with patch("claw.core.slack_poller.DbSession"), \
patch("claw.core.slack_poller.close_session"), \
patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}):
await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001"))

mock_client.disconnect.assert_awaited_once()
assert "200.001" not in poller._clients

@pytest.mark.asyncio
async def test_close_command_sends_farewell(self):
"""Close command posts a farewell message in the thread."""
poller = self._make_poller()

with patch("claw.core.slack_poller.DbSession"), \
patch("claw.core.slack_poller.close_session"), \
patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}) as mock_reply:
await poller._handle_message(_msg("bye", ts="200.001", thread_ts="200.001"))

mock_reply.assert_called_once()
farewell_text = mock_reply.call_args[0][4] if len(mock_reply.call_args[0]) > 4 else mock_reply.call_args[1].get("text", "")
assert "session closed" in farewell_text.lower()

@pytest.mark.asyncio
async def test_close_command_does_not_run_agent(self):
"""Close command should NOT create a client or send a message to the agent."""
poller = self._make_poller()

with patch("claw.core.slack_poller.DbSession"), \
patch("claw.core.slack_poller.close_session"), \
patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock, return_value={"ts": "999.001"}), \
patch("claw.core.slack_poller.create_client", new_callable=AsyncMock) as mock_create, \
patch("claw.core.slack_poller.send_message", new_callable=AsyncMock) as mock_send:
await poller._handle_message(_msg("/close", ts="200.001", thread_ts="200.001"))

mock_create.assert_not_called()
mock_send.assert_not_called()

@pytest.mark.asyncio
async def test_non_close_message_runs_agent(self):
"""A normal message should still dispatch the agent (not short-circuit)."""
poller = self._make_poller()

mock_client = AsyncMock()
mock_iter = MagicMock()
mock_iter.__aiter__ = MagicMock(return_value=AsyncMock(__anext__=AsyncMock(side_effect=StopAsyncIteration)))
mock_iter.is_error = False
mock_iter.stop_reason = "end_turn"
mock_iter.session_id = "sdk-123"
mock_iter.progress_event_received = False
mock_iter.num_turns = 1
mock_iter.total_cost_usd = 0.001
mock_iter.duration_ms = 100

with patch("claw.core.slack_poller.DbSession"), \
patch("claw.core.slack_poller.get_session", return_value=None), \
patch("claw.core.slack_poller.upsert_session"), \
patch("claw.core.slack_poller.build_mcp_servers", return_value=[]), \
patch("claw.core.slack_poller.create_client", new_callable=AsyncMock, return_value=mock_client) as mock_create, \
patch("claw.core.slack_poller.send_message", new_callable=AsyncMock) as mock_send, \
patch("claw.core.slack_poller.get_recent_messages", return_value=[]):
await poller._handle_message(_msg("hello there", ts="200.001", thread_ts="200.001"))

mock_create.assert_called_once()
mock_send.assert_called_once()
Loading