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
1 change: 1 addition & 0 deletions claw/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down
3 changes: 3 additions & 0 deletions claw/core/slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions openspec/changes/reply-header/proposal.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions openspec/changes/reply-header/specs/config.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions openspec/changes/reply-header/specs/slack_poller.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions openspec/changes/reply-header/tasks.md
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/test_e2e_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
114 changes: 114 additions & 0 deletions tests/test_slack_poller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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:*")
Loading