diff --git a/claw/core/config.py b/claw/core/config.py index f4a3b34..69505af 100644 --- a/claw/core/config.py +++ b/claw/core/config.py @@ -22,6 +22,7 @@ class AppConfig(BaseSettings): MESSAGE_HISTORY_LIMIT: int = 20 MAX_AGENT_RETRIES: int = 3 ENABLE_PROGRESS_UPDATES: bool = True + CLAW_REPLY_HEADER: bool = True @property def uc_mcp_connections(self) -> list[str]: diff --git a/claw/core/slack_poller.py b/claw/core/slack_poller.py index 8cb0e7c..adff160 100644 --- a/claw/core/slack_poller.py +++ b/claw/core/slack_poller.py @@ -383,6 +383,9 @@ async def _run_response_loop(self, thread_ts: str, channel: str, original_text: gen.is_error, ) + if self._config.CLAW_REPLY_HEADER: + full_response = f"*{self._config.CLAW_NAME.capitalize()}:*\n{full_response}" + # Reply in thread result = await reply_to_thread( self._ws, diff --git a/openspec/changes/reply-header/proposal.md b/openspec/changes/reply-header/proposal.md new file mode 100644 index 0000000..65ca5f1 --- /dev/null +++ b/openspec/changes/reply-header/proposal.md @@ -0,0 +1,17 @@ +# Message attribution header — prepend *Claw:* to all replies + +## Why +When Claw replies in a Slack thread, there is no visual attribution distinguishing +its messages from human messages. Prepending a bold header makes it immediately +clear which messages come from Claw. + +## What Changes +- `claw/core/config.py`: Add `CLAW_REPLY_HEADER: bool = True` to `AppConfig`. +- `claw/core/slack_poller.py`: In `_run_response_loop`, just before calling + `reply_to_thread`, conditionally prepend `"*Claw:*\n"` to `full_response` + when `CLAW_REPLY_HEADER` is enabled. + +## Impact +- Backend: `config.py` (new setting), `slack_poller.py` (header prepend) +- Tests: `test_slack_poller.py` (two new tests: enabled + disabled) +- No schema/migration changes. diff --git a/openspec/changes/reply-header/specs/config.md b/openspec/changes/reply-header/specs/config.md new file mode 100644 index 0000000..6a13063 --- /dev/null +++ b/openspec/changes/reply-header/specs/config.md @@ -0,0 +1,11 @@ +# Spec: CLAW_REPLY_HEADER config + +## File: claw/core/config.py + +Add field to AppConfig: +``` +CLAW_REPLY_HEADER: bool = True +``` + +Default is True so the header is on by default. Set env var +CLAW_REPLY_HEADER=false to disable. diff --git a/openspec/changes/reply-header/specs/slack_poller.md b/openspec/changes/reply-header/specs/slack_poller.md new file mode 100644 index 0000000..0c95fb0 --- /dev/null +++ b/openspec/changes/reply-header/specs/slack_poller.md @@ -0,0 +1,14 @@ +# Spec: Prepend reply header in _run_response_loop + +## File: claw/core/slack_poller.py + +In `_run_response_loop`, after `full_response = "".join(all_chunks)` and before +the `reply_to_thread` call, insert: + +```python +if self._config.CLAW_REPLY_HEADER: + full_response = f"*Claw:*\n{full_response}" +``` + +This prepends `*Claw:*\n` (bold Slack markdown) to every reply when the config +flag is enabled. diff --git a/openspec/changes/reply-header/tasks.md b/openspec/changes/reply-header/tasks.md new file mode 100644 index 0000000..f4b0470 --- /dev/null +++ b/openspec/changes/reply-header/tasks.md @@ -0,0 +1,8 @@ +# Tasks — reply-header + +- [x] OpenSpec proposal +- [ ] RED: Add test_reply_has_claw_header_when_enabled +- [ ] RED: Add test_reply_no_header_when_disabled +- [ ] GREEN: Add CLAW_REPLY_HEADER to AppConfig +- [ ] GREEN: Prepend header in _run_response_loop +- [ ] Full test suite passes (282+ tests, 0 failures) diff --git a/tests/test_e2e_smoke.py b/tests/test_e2e_smoke.py index 8e5c032..16c631e 100644 --- a/tests/test_e2e_smoke.py +++ b/tests/test_e2e_smoke.py @@ -33,6 +33,7 @@ def _make_config( cfg.MAX_ACTIVE_THREADS = 20 cfg.MESSAGE_HISTORY_LIMIT = 20 cfg.MAX_AGENT_RETRIES = 3 + cfg.CLAW_REPLY_HEADER = False return cfg diff --git a/tests/test_slack_poller.py b/tests/test_slack_poller.py index f397ac8..c74519d 100644 --- a/tests/test_slack_poller.py +++ b/tests/test_slack_poller.py @@ -34,6 +34,7 @@ def _make_config( cfg.AI_GATEWAY_ENDPOINT = "claude-sonnet" cfg.CLAW_MEMORY_VOLUME_PATH = "/Volumes/test/memory" cfg.DATABRICKS_HOST = "https://test.databricks.com" + cfg.CLAW_REPLY_HEADER = False return cfg @@ -1892,3 +1893,116 @@ async def _aiter(): call_args = info_calls[0][0] # positional args tuple cost_arg = call_args[3] # 4th arg after format string: cost assert cost_arg == 0.0 + + +# --------------------------------------------------------------------------- +# Issue #71: Message attribution header — prepend *Claw:* to replies +# --------------------------------------------------------------------------- + + +class TestReplyHeader: + """_run_response_loop prepends *Claw:* header based on CLAW_REPLY_HEADER config.""" + + @pytest.mark.asyncio + @patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock) + @patch("claw.core.slack_poller.iter_response") + @patch("claw.core.slack_poller.save_message") + @patch("claw.core.slack_poller.upsert_session") + @patch("claw.core.slack_poller.DbSession") + async def test_reply_has_claw_header_when_enabled( + self, + mock_db_session_cls, + mock_upsert_session, + mock_save_msg, + mock_iter_response, + mock_reply, + ): + """When CLAW_REPLY_HEADER=True, reply_to_thread is called with text starting '*Claw:*\\n'.""" + mock_db = MagicMock() + mock_db_session_cls.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_db_session_cls.return_value.__exit__ = MagicMock(return_value=False) + + async def _aiter(): + yield "hello world" + mock_wrapper = MagicMock() + mock_wrapper.progress_event_received = False + mock_wrapper.__aiter__ = lambda self: _aiter() + mock_wrapper.session_id = "sdk-new" + mock_wrapper.num_turns = 1 + mock_wrapper.total_cost_usd = 0.01 + mock_wrapper.duration_ms = 100 + mock_wrapper.is_error = False + mock_wrapper.stop_reason = None + mock_iter_response.return_value = mock_wrapper + + mock_reply.return_value = {"ok": True, "ts": "500.999"} + + ws = _make_ws({"ok": True, "messages": []}) + cfg = _make_config() + cfg.CLAW_REPLY_HEADER = True + cfg.ENABLE_PROGRESS_UPDATES = False + poller = SlackPoller(ws, cfg, _make_db_engine()) + poller._channel_id = "C123" + poller._user_id = "U_BOT" + + mock_client = MagicMock() + poller._clients["500.001"] = mock_client + + await poller._run_response_loop("500.001", "C123", "test msg") + + # The reply text should start with the Claw header + reply_text = mock_reply.call_args[1].get("text") or mock_reply.call_args[0][4] + assert reply_text.startswith("*Claw:*\n") + assert "hello world" in reply_text + + @pytest.mark.asyncio + @patch("claw.core.slack_poller.reply_to_thread", new_callable=AsyncMock) + @patch("claw.core.slack_poller.iter_response") + @patch("claw.core.slack_poller.save_message") + @patch("claw.core.slack_poller.upsert_session") + @patch("claw.core.slack_poller.DbSession") + async def test_reply_no_header_when_disabled( + self, + mock_db_session_cls, + mock_upsert_session, + mock_save_msg, + mock_iter_response, + mock_reply, + ): + """When CLAW_REPLY_HEADER=False, reply is sent unchanged (no header).""" + mock_db = MagicMock() + mock_db_session_cls.return_value.__enter__ = MagicMock(return_value=mock_db) + mock_db_session_cls.return_value.__exit__ = MagicMock(return_value=False) + + async def _aiter(): + yield "hello world" + mock_wrapper = MagicMock() + mock_wrapper.progress_event_received = False + mock_wrapper.__aiter__ = lambda self: _aiter() + mock_wrapper.session_id = "sdk-new" + mock_wrapper.num_turns = 1 + mock_wrapper.total_cost_usd = 0.01 + mock_wrapper.duration_ms = 100 + mock_wrapper.is_error = False + mock_wrapper.stop_reason = None + mock_iter_response.return_value = mock_wrapper + + mock_reply.return_value = {"ok": True, "ts": "600.999"} + + ws = _make_ws({"ok": True, "messages": []}) + cfg = _make_config() + cfg.CLAW_REPLY_HEADER = False + cfg.ENABLE_PROGRESS_UPDATES = False + poller = SlackPoller(ws, cfg, _make_db_engine()) + poller._channel_id = "C123" + poller._user_id = "U_BOT" + + mock_client = MagicMock() + poller._clients["600.001"] = mock_client + + await poller._run_response_loop("600.001", "C123", "test msg") + + # The reply text should be the raw response without header + 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:*")