From b00fb51764d7d4fbc2d95f0adb72389fb5dbe704 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:34:53 -0400 Subject: [PATCH 01/13] feat: revive Slack as an owner-only DM connector --- container/bot/slack_adapter.py | 208 +++++++++++--------------- docs/slack-dm-agent-mvp.md | 98 ++++++++++++ pyproject.toml | 2 + src/woltspace/channels.py | 103 ++++++++++++- src/woltspace/container_entrypoint.py | 50 +------ test/test_channel_connectors.py | 57 ++++++- test/test_container_entrypoint.py | 55 ------- test/test_native_resilience.py | 2 +- test/test_slack_adapter.py | 172 +++++++++++++++++++++ test/test_tui_bridge.py | 6 +- test/test_wolf_connector.py | 2 +- 11 files changed, 520 insertions(+), 235 deletions(-) create mode 100644 docs/slack-dm-agent-mvp.md create mode 100644 test/test_slack_adapter.py diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 5916edb2..e9c794b3 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -1,14 +1,10 @@ -""" -Slack adapter — thin layer over core. -Responds to @mentions, reads full thread context, follows up in threads. -Uses Socket Mode (no public HTTP endpoint needed). +"""Owner-only Slack DM adapter over Socket Mode. Thread ownership model: - - @bot in channel → new thread, dog (Haiku) responds + - owner starts a DM thread → dog responds - Dog spawns a session → thread becomes session-owned - Messages in session-owned thread → routed directly to Claude Code session - - @bot in session-owned thread → escape hatch back to dog - - Dead session → error message, @bot to recover + - Dead session → ownership clears so the dog can recover in that thread """ import os @@ -19,13 +15,13 @@ import asyncio import random import urllib.request +from collections import deque from datetime import datetime, timezone from pathlib import Path -from collections import defaultdict from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler import sys -from bot.core import get_response, message_session, list_sessions, kill_session, get_tunnel_url, switch_wolt, list_wolts, _bot_log, build_ack_text, _sanitize_history +from bot.core import get_response, message_session, _bot_log, build_ack_text, _sanitize_history from wolts import get_active_creature sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) @@ -117,6 +113,11 @@ def _save_thread_sessions(): "🐶 *full body wiggle*", ] +SLACK_USER_ID_RE = re.compile(r"^[UW][A-Z0-9]{8,}$") +_SEEN_EVENT_LIMIT = 2048 +_seen_event_ids: set[str] = set() +_seen_event_order: deque[str] = deque() + # --- Helpers --- @@ -163,9 +164,43 @@ def _strip_mention(text: str, bot_user_id: str) -> str: return re.sub(rf"<@{bot_user_id}>", "", text).strip() -def _has_mention(text: str, bot_user_id: str) -> bool: - """Check if text contains an @mention of the bot.""" - return bool(bot_user_id) and f"<@{bot_user_id}>" in text +def _owner_user_id() -> str | None: + """Return the explicitly configured owner, never an inferred user.""" + value = os.environ.get("SLACK_OWNER_USER", "").strip() + return value if SLACK_USER_ID_RE.fullmatch(value) else None + + +def _event_identity(body: dict, event: dict) -> str: + return str( + body.get("event_id") + or event.get("client_msg_id") + or event.get("event_ts") + or event.get("ts") + or "" + ) + + +def _accept_owner_dm(event: dict, body: dict) -> tuple[bool, str]: + """Fail closed before history, downloads, model calls, or session routing.""" + owner = _owner_user_id() + if owner is None: + return False, "owner_not_configured" + if event.get("bot_id") or event.get("subtype"): + return False, "non_human_event" + if event.get("channel_type") != "im": + return False, "not_dm" + if event.get("user") != owner: + return False, "not_owner" + event_id = _event_identity(body, event) + if not event_id: + return False, "missing_event_id" + if event_id in _seen_event_ids: + return False, "duplicate" + _seen_event_ids.add(event_id) + _seen_event_order.append(event_id) + if len(_seen_event_order) > _SEEN_EVENT_LIMIT: + _seen_event_ids.discard(_seen_event_order.popleft()) + return True, "accepted" def _extract_image(event: dict) -> tuple[bytes, str] | None: @@ -275,13 +310,6 @@ def _set_session_owner(channel: str, thread_ts: str, session_name: str, wolt_nam _save_thread_sessions() -def _clear_session_owner(channel: str, thread_ts: str): - """Release thread ownership (back to dog).""" - key = _thread_key(channel, thread_ts) - _thread_sessions.pop(key, None) - _save_thread_sessions() - - def _extract_session_info(result: dict) -> dict | None: """Extract session name, wolt, and creature from a get_response result.""" if result.get("type") != "session": @@ -297,7 +325,26 @@ def _extract_session_info(result: dict) -> dict | None: # --- Posting results --- -async def _post_result(client, channel: str, thread_ts: str, result: dict): +async def _post_text(client, channel: str, thread_ts: str, user: str, text: str): + """Prefer Slack's agent stream surface, with a plain-message fallback.""" + try: + started = await client.chat_startStream( + channel=channel, + thread_ts=thread_ts, + recipient_user_id=user, + markdown_text=text, + ) + stream_ts = started.get("ts") + if not stream_ts: + raise RuntimeError("chat.startStream returned no message timestamp") + await client.chat_stopStream(channel=channel, ts=stream_ts) + return + except Exception as exc: + logger.info("Slack streaming unavailable; using chat.postMessage: %s", exc) + await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text) + + +async def _post_result(client, channel: str, thread_ts: str, user: str, result: dict): """Post a result to Slack — handles text, session, and image types. Sends tool call logs before the final response.""" for tc in result.get("tool_calls_log", []): @@ -321,11 +368,7 @@ async def _post_result(client, channel: str, thread_ts: str, result: dict): initial_comment=caption or None, ) else: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=format_response(result), - ) + await _post_text(client, channel, thread_ts, user, format_response(result)) # If a session was spawned, take ownership of the thread session_info = _extract_session_info(result) @@ -382,11 +425,13 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t }) else: error = result.get("error", "unknown error") - # Session is dead — tell user how to recover + # Return the thread to the dog so the next owner message can recover. + _thread_sessions.pop(_thread_key(channel, thread_ts), None) + _save_thread_sessions() await client.chat_postMessage( channel=channel, thread_ts=thread_ts, - text=f"session {session_name} is no longer active — @mention me to start a new conversation", + text=f"session {session_name} is no longer active — send again to start a new conversation", ) _append_message(channel, thread_ts, { "role": "assistant", @@ -397,108 +442,22 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t # --- Main app --- def create_app(): - """Create and configure the Slack Bolt app.""" + """Create the owner-only DM app; channel mentions are intentionally absent.""" app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) - @app.event("app_mention") - async def handle_mention(event, client, context): - """Handle @mentions — always goes to dog (Haiku). - - This is the entry point for new conversations and the escape hatch - from session-owned threads. - """ - channel = event["channel"] - user = event.get("user", "unknown") - text = event.get("text", "") - thread_ts = event.get("thread_ts", event["ts"]) - - bot_user_id = context.get("bot_user_id", "") - user_message = _strip_mention(text, bot_user_id) - - # Check for image attachment - image_result = await asyncio.get_event_loop().run_in_executor(None, _extract_image, event) - user_content = None - if image_result: - image_bytes, mime_type = image_result - user_content = _image_content(image_bytes, mime_type, user_message) - user_message = f"[image] {user_message}" if user_message else "[image]" - - if not user_message and user_content is None: - return - - logger.info(f"Mention from user={user} in channel={channel} thread={thread_ts}") - - # If this thread was session-owned, release it back to dog - owner = _get_session_owner(channel, thread_ts) - if owner: - _clear_session_owner(channel, thread_ts) - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"🐶 dog is back in this thread", - ) - - # Mark thread as active - _active_threads.add(_thread_key(channel, thread_ts)) - _save_active_threads() - - # Immediate ack - try: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=random.choice(DOG_ACK_MESSAGES), - ) - except Exception: - pass - - # Build context from full thread - history = await _build_thread_context(client, channel, thread_ts, bot_user_id) - if history and history[-1]["role"] == "user": - history = history[:-1] - - routing = {"adapter": "slack", "chat_id": channel, "thread_ts": thread_ts} - try: - result = get_response(user_message, conversation_history=list(history), routing=routing, user_content=user_content) - except Exception as e: - logger.error(f"Error getting response: {e}") - await client.chat_postMessage(channel=channel, thread_ts=thread_ts, - text="Something broke on my end. Try again in a sec.") - return - - _append_history(channel, thread_ts, "user", user_message) - for msg in result["history_messages"]: - _append_message(channel, thread_ts, msg) - - await _post_result(client, channel, thread_ts, result) - @app.event("message") - async def handle_message(event, client, context): - """Handle follow-up messages in active threads. - - If thread is session-owned → route directly to Claude Code session. - If thread is dog-owned → route to dog (Haiku). - Skip if message contains @mention (handled by handle_mention). - """ - if event.get("bot_id") or event.get("subtype"): + async def handle_message(event, client, context, body): + """Start or continue one owner DM thread.""" + accepted, reason = _accept_owner_dm(event, body) + if not accepted: + logger.info("Ignoring Slack event: %s", reason) return channel = event["channel"] - thread_ts = event.get("thread_ts") - - if not thread_ts: - return - if _thread_key(channel, thread_ts) not in _active_threads: - return - - user = event.get("user", "unknown") + thread_ts = event.get("thread_ts") or event["ts"] + user = event["user"] text = event.get("text", "") bot_user_id = context.get("bot_user_id", "") - - # Skip if this is an @mention (handled by handle_mention) - if _has_mention(text, bot_user_id): - return - user_message = _strip_mention(text, bot_user_id) # Check for image attachment @@ -512,6 +471,11 @@ async def handle_message(event, client, context): if not user_message and user_content is None: return + key = _thread_key(channel, thread_ts) + if key not in _active_threads: + _active_threads.add(key) + _save_active_threads() + # --- Session-owned thread: route directly to session --- owner = _get_session_owner(channel, thread_ts) if owner: @@ -549,7 +513,7 @@ async def handle_message(event, client, context): for msg in result["history_messages"]: _append_message(channel, thread_ts, msg) - await _post_result(client, channel, thread_ts, result) + await _post_result(client, channel, thread_ts, user, result) return app diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md new file mode 100644 index 00000000..27edaf8d --- /dev/null +++ b/docs/slack-dm-agent-mvp.md @@ -0,0 +1,98 @@ +# Slack owner-DM MVP handoff + +## What this slice ships + +Slack is a native supervised connector with the same start, stop, health, +bounded-restart and orphan-reaping lifecycle as Telegram. It accepts only +`message.im` events from one exact configured Slack member ID. Channel +mentions, multi-person DMs, bot/subtype events, malformed owner configuration +and duplicate events are rejected before history, attachment, model or session +work. Existing persisted Slack thread-to-session ownership remains the routing +model. + +Final text prefers `chat.startStream`/`chat.stopStream` and independently falls +back to `chat.postMessage`. Status, native Stop and customized per-wolt sender +identity are not enabled by this slice. + +## Reusable private-lodge manifest + +Create one Slack app per lodge from a manifest like this, then create an +app-level token with `connections:write` and install the app to the workspace. +Do not add `app_mentions:read` or channel message events for this DM-only MVP. + +```yaml +display_information: + name: Woltspace +features: + bot_user: + display_name: Woltspace + always_online: false +oauth_config: + scopes: + bot: + - chat:write + - im:history +settings: + event_subscriptions: + bot_events: + - message.im + interactivity: + is_enabled: false + org_deploy_enabled: false + socket_mode_enabled: true + token_rotation_enabled: false +``` + +Configure the lodge only after copying the intended human's immutable Slack +member ID (starts with `U` or `W`): + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "", + "app_token": "", + "owner_user": "" + } + } +} +``` + +The first live acceptance is deliberately separate: confirm the member ID with +the owner, start the lodge, verify Slack is healthy, send one owner DM, observe +one threaded response and session handoff, verify a non-owner DM and a channel +mention cause no work, restart, then confirm the same thread still routes to +its session. Never infer the owner from the first sender. + +## Modern Slack UX and command inventory + +These are follow-ups, not current behavior: + +| Surface | Slack support | Additional app work | +|---|---|---| +| Slash commands | Yes | Declare each command and its request URL; Socket Mode can receive interactive payloads. | +| Global/message shortcuts | Yes | Enable Interactivity and declare callbacks. | +| Buttons, selects and modals | Yes, via Block Kit | Enable Interactivity; validate action/view payloads and preserve the owner gate. | +| Suggested prompts | Yes, in Slack's agent/assistant UI | Add `assistant:write` and Agent View handlers. | +| Processing/active status | Yes | Add `assistant:write`; explicitly return status to `active`. | +| Streaming responses | Yes | Implemented opportunistically with `chat:write`; plain message fallback remains required. | +| Native Stop | Yes, through `agent_session_stopped` | Convert the app to Agent View, subscribe to the event, and map it to real session interruption. | +| Per-wolt name/icon | Yes | Add `chat:write.customize`; keep sender identity auditable and owner-controlled. | + +Switching an existing Slack app to Agent View and adding scopes/events are +external app mutations and require explicit owner approval plus reinstall. + +## From private setup to OAuth installation + +The private MVP stores one lodge's bot/app tokens and owner ID locally. A later +multi-lodge installer should use Slack OAuth v2 for the bot installation, +persist each installation by team/enterprise ID, verify the installing user, +ask them to confirm the owner member ID, and create/rotate the Socket Mode +app-level token through an owner-controlled setup step. The reusable manifest +remains the source of truth for scopes and subscriptions. OAuth must not turn +"installer" into ambient multi-user authority: each lodge still pins one owner +and rejects every other inbound identity by default. + +Official references: Slack's `chat.startStream`, `assistant.threads.setStatus`, +`agent_session_stopped`, app manifests, Socket Mode and OAuth v2 documentation. diff --git a/pyproject.toml b/pyproject.toml index a2f2382d..848e002f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,6 +59,7 @@ packages = ["src/woltspace"] "docs/shared-skills.md" = "woltspace/_bundle/docs/shared-skills.md" "docs/colony-seeds.md" = "woltspace/_bundle/docs/colony-seeds.md" "docs/digging-v0.md" = "woltspace/_bundle/docs/digging-v0.md" +"docs/slack-dm-agent-mvp.md" = "woltspace/_bundle/docs/slack-dm-agent-mvp.md" [tool.hatch.build.targets.sdist] include = [ @@ -73,5 +74,6 @@ include = [ "/docs/shared-skills.md", "/docs/colony-seeds.md", "/docs/digging-v0.md", + "/docs/slack-dm-agent-mvp.md", "/LICENSE", ] diff --git a/src/woltspace/channels.py b/src/woltspace/channels.py index cb2b7b34..581ef907 100644 --- a/src/woltspace/channels.py +++ b/src/woltspace/channels.py @@ -17,6 +17,7 @@ import importlib.util import os +import re import shutil import subprocess import sys @@ -75,7 +76,7 @@ def _bot_project(bot_dir: str, install_root: Path) -> Path | None: def _interpreter( - bot_dir: str, isolation: str, install_root: Path + bot_dir: str, isolation: str, install_root: Path, dependency: str = "telegram" ) -> tuple[str, ...]: """The interpreter that owns the adapter's dependencies. @@ -88,7 +89,7 @@ def _interpreter( A container built some other way (a checkout with missing dependencies) still falls back to that uv project, which is what it has always used. """ - if isolation != "host" and not _module_available("telegram"): + if isolation != "host" and not _module_available(dependency): uv = shutil.which("uv") project = _bot_project(bot_dir, install_root) if uv and project is not None: @@ -268,6 +269,97 @@ def plan( ) +class SlackConnector: + """A fail-closed, owner-only Slack DM connector.""" + + name = "slack" + + def plan( + self, layout: RuntimeLayout, env: Mapping[str, str] | None = None + ) -> ConnectorPlan: + values = dict(os.environ if env is None else env) + settings = channel_config(layout, self.name, values) + path = config_path(layout, values) + configured = bool(settings.get("enabled", False)) + raw_enabled = values.get("ENABLE_SLACK_BOT") + from_env = _truthy(raw_enabled) if raw_enabled is not None else False + env_disables = raw_enabled is not None and not from_env + bot_token = (values.get("SLACK_BOT_TOKEN") or settings.get("bot_token") or "").strip() + app_token = (values.get("SLACK_APP_TOKEN") or settings.get("app_token") or "").strip() + owner_user = (values.get("SLACK_OWNER_USER") or settings.get("owner_user") or "").strip() + remedy = ( + f'Set channels.slack = {{"enabled": true, "bot_token": "", ' + f'"app_token": "", "owner_user": ""}} in {path}.' + ) + if env_disables or not (configured or from_env): + return ConnectorPlan(self.name, False, "disabled", remedy=remedy) + if not _truthy(values.get("WOLTSPACE_ENTRYPOINT", "")): + return ConnectorPlan( + self.name, False, + "not the platform entrypoint; a guest never opens Socket Mode", + remedy="Run the control plane through `woltspace start` to own Slack.", + ) + missing = [ + label for label, value in ( + ("bot token", bot_token), + ("app token", app_token), + ("owner user ID", owner_user), + ) if not value + ] + if missing: + return ConnectorPlan( + self.name, False, f"enabled without {', '.join(missing)}", remedy=remedy + ) + if re.fullmatch(r"[UW][A-Z0-9]{8,}", owner_user) is None: + return ConnectorPlan( + self.name, False, "enabled with a malformed owner user ID", remedy=remedy + ) + + bot_dir = values.get("SLACK_BOT_DIR") or str(layout.install_root / "container") + module = values.get("SLACK_BOT_MODULE") or "bot.slack_adapter" + child_env = export_both({ + "SLACK_BOT_TOKEN": bot_token, + "SLACK_APP_TOKEN": app_token, + "SLACK_OWNER_USER": owner_user, + "WOLTSPACE_WOLTS_DIR": str(layout.wolts_dir), + "WOLTSPACE_DIR": str(layout.install_root), + "WOLTSPACE_ISOLATION": layout.isolation, + "WOLTSPACE_HOST": layout.host, + "WOLTSPACE_PORT": str(layout.port), + "WOLTSPACE_API": layout.endpoint, + "PYTHONPATH": os.pathsep.join( + part for part in ( + bot_dir, str(layout.runtime_lib), values.get("PYTHONPATH", "") + ) if part + ), + }) + interpreter = _interpreter( + bot_dir, layout.isolation, layout.install_root, "slack_bolt" + ) + if interpreter == (sys.executable,) and not _module_available("slack_bolt"): + return ConnectorPlan( + self.name, False, "enabled but slack-bolt is not installed", + remedy=( + "Reinstall woltspace to restore its dependencies: " + "`uv tool install --force woltspace` " + "(from a checkout: `uv tool install --force .`), then `woltspace start`." + ), + ) + detail = f"owner-only DMs · {module} from {bot_dir}" + if _truthy(values.get("DEV_MODE", "")) and _module_available("watchfiles"): + command = ( + *interpreter, "-m", "watchfiles", "--filter", "python", + f"{' '.join(interpreter)} -m {module}", "bot/", + ) + detail += " (dev reload)" + else: + command = (*interpreter, "-m", module) + return ConnectorPlan( + self.name, True, detail, command, bot_dir, child_env, remedy, + ("-m", module), + ) + + TOKEN_BUSY_DETAIL = "another process is already polling this bot token" TOKEN_BUSY_REMEDY = ( "One bot token can only be polled by one process. Stop the other poller, or " @@ -535,7 +627,7 @@ def plan( CONNECTORS: tuple[ChannelConnector, ...] = ( - TelegramConnector(), TuiBridgeConnector(), WolfConnector(), + TelegramConnector(), SlackConnector(), TuiBridgeConnector(), WolfConnector(), ) @@ -554,7 +646,10 @@ def connector_secrets(plans: list[ConnectorPlan]) -> dict[str, str]: for plan in plans: if not plan.enabled: continue - for key in ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS"): + for key in ( + "TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", + "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_OWNER_USER", + ): value = plan.env.get(key) if value: secrets[key] = value diff --git a/src/woltspace/container_entrypoint.py b/src/woltspace/container_entrypoint.py index 6d2b2a90..16f7f620 100644 --- a/src/woltspace/container_entrypoint.py +++ b/src/woltspace/container_entrypoint.py @@ -460,53 +460,6 @@ def preload_viewport(wolt_name: str, state_dir: Path) -> str: return url -def start_slack_bot(env: dict[str, str]) -> subprocess.Popen | None: - """Slack has no connector yet, so it is still launched by hand. - - Telegram, the wolf scheduler and the TUI pty bridge are all supervised - children of the control plane (ChannelConnector — see - src/woltspace/channels.py). Starting any of them here would give the colony - two of it: two pollers on one bot token, two schedulers firing every cron - twice. Inspect them with: curl -s localhost:7777/health | jq .connectors - (NOT `woltspace status` — inside the container that name resolves to - container/bin/woltspace, a different CLI which knows nothing about - connectors.) - - Slack runs on the installed interpreter, which owns slack-bolt via the - `connectors` extra. - """ - if not (env.get("ENABLE_SLACK_BOT") == "true" - and env.get("SLACK_BOT_TOKEN") and env.get("SLACK_APP_TOKEN")): - return None - - bot_dir = env["SLACK_BOT_DIR"] - module = env["SLACK_BOT_MODULE"] - dev_mode = env.get("DEV_MODE") == "true" - print(f"starting slack bot ({bot_dir}, dev={env.get('DEV_MODE')})...") - - if dev_mode: - command = ["woltspace-python", "-m", "watchfiles", "--filter", "python", - f"python -m {module}", "bot/"] - else: - command = ["woltspace-python", "-m", module] - - child_env = dict(env) - child_env["BOT_ADAPTER"] = "slack" - child_env["PYTHONPATH"] = f"{bot_dir}:{env.get('PYTHONPATH', '')}" - try: - # start_new_session is bash's `disown`: the bot outlives nothing here, - # but it must not take a terminal signal meant for the control plane. - return subprocess.Popen(command, cwd=bot_dir, env=child_env, - start_new_session=True) - except OSError as exc: - # A missing interpreter or an unusable cwd raises here, in the boot - # process. Bash launched this in a backgrounded subshell, where the same - # failure cost one line of stderr and nothing else — a chat adapter that - # cannot start is not a reason to withhold the whole colony. - print(f"slack bot failed to start: {exc}") - return None - - def report_tunnel_url(wolts_dir: Path) -> None: """Report the tunnel URL once it lands, without standing in the way. @@ -592,10 +545,11 @@ def run_node_phase() -> int: parents=True, exist_ok=True) open_tmux_window(wolt_name, wolt_dir, wolts_dir) - start_slack_bot(dict(os.environ)) start_tunnel_report(wolts_dir, dict(os.environ)) # ── The control plane ── + # Every chat adapter is now a supervised ChannelConnector; boot must not + # launch a detached duplicate before handing ownership to `serve`. # The same supervisor a native user runs, from the same installed package, # in this very process: docker's SIGTERM reaches the owner of the connectors # instead of a shell that would leave them orphaned. diff --git a/test/test_channel_connectors.py b/test/test_channel_connectors.py index dd236f3a..043b6f2b 100644 --- a/test/test_channel_connectors.py +++ b/test/test_channel_connectors.py @@ -21,6 +21,7 @@ ) from woltspace.channels import ( # noqa: E402 ConnectorPlan, + SlackConnector, TelegramConnector, connector_secrets, plan_connectors, @@ -73,7 +74,61 @@ def test_disabled_without_config(self, layout): plan = TelegramConnector().plan(layout, {}) assert plan.enabled is False assert plan.command == () - assert "config.json" in plan.remedy + + +class TestSlackPlan: + BASE = { + "ENABLE_SLACK_BOT": "true", + "SLACK_BOT_TOKEN": "xoxb-test", + "SLACK_APP_TOKEN": "xapp-test", + "SLACK_OWNER_USER": "U12345678", + "WOLTSPACE_ENTRYPOINT": "1", + } + + def test_disabled_without_an_explicit_owner(self, layout): + env = dict(self.BASE) + env.pop("SLACK_OWNER_USER") + plan = SlackConnector().plan(layout, env) + assert plan.enabled is False + assert "owner user ID" in plan.detail + + @pytest.mark.parametrize("owner", ["", "jerpint", "C12345678", "U12 345678"]) + def test_malformed_owner_configuration_fails_closed(self, layout, owner): + plan = SlackConnector().plan(layout, dict(self.BASE, SLACK_OWNER_USER=owner)) + assert plan.enabled is False + + def test_enabled_plan_carries_private_credentials_only_in_child_env(self, layout): + plan = SlackConnector().plan(layout, self.BASE) + assert plan.enabled is True + assert plan.command[-2:] == ("-m", "bot.slack_adapter") + assert plan.env["SLACK_OWNER_USER"] == "U12345678" + assert plan.env["SLACK_BOT_TOKEN"] == "xoxb-test" + public = json.dumps(plan.to_record()) + assert "xoxb-test" not in public + assert "xapp-test" not in public + assert "U12345678" not in public + + def test_config_shape_and_all_three_secrets_are_resolved(self, layout): + write_config(layout, {"channels": {"slack": { + "enabled": True, + "bot_token": "xoxb-config", + "app_token": "xapp-config", + "owner_user": "U87654321", + }}}) + plan = SlackConnector().plan(layout, {"WOLTSPACE_ENTRYPOINT": "1"}) + assert plan.enabled is True + assert connector_secrets([plan]) == { + "SLACK_BOT_TOKEN": "xoxb-config", + "SLACK_APP_TOKEN": "xapp-config", + "SLACK_OWNER_USER": "U87654321", + } + + def test_ambient_tokens_never_start_a_guest(self, layout): + env = dict(self.BASE) + env.pop("WOLTSPACE_ENTRYPOINT") + plan = SlackConnector().plan(layout, env) + assert plan.enabled is False + assert "not the platform entrypoint" in plan.detail def test_enabled_from_data_root_config(self, layout): write_config(layout, { diff --git a/test/test_container_entrypoint.py b/test/test_container_entrypoint.py index b405c98b..73cd36c4 100644 --- a/test/test_container_entrypoint.py +++ b/test/test_container_entrypoint.py @@ -284,61 +284,6 @@ def test_preloads_the_viewport_with_the_wolt_site(self, tmp_path): assert isinstance(payload["updated"], int) -# --------------------------------------------------------------------------- -# Slack — the one process boot still starts by hand -# --------------------------------------------------------------------------- - -class TestSlackBot: - BASE = { - "ENABLE_SLACK_BOT": "true", - "SLACK_BOT_TOKEN": "xoxb-token", - "SLACK_APP_TOKEN": "xapp-token", - "SLACK_BOT_DIR": "/bundle/container", - "SLACK_BOT_MODULE": "bot.slack_adapter", - "DEV_MODE": "false", - "PYTHONPATH": "/bundle/container/lib:", - } - - def _launch(self, env): - with patch.object(boot.subprocess, "Popen") as popen: - boot.start_slack_bot(env) - return popen - - def test_not_started_without_both_tokens(self): - for missing in ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"): - env = dict(self.BASE, **{missing: ""}) - assert self._launch(env).call_count == 0 - - def test_not_started_unless_enabled(self): - assert self._launch(dict(self.BASE, ENABLE_SLACK_BOT="false")).call_count == 0 - - def test_runs_on_the_installed_interpreter_detached(self): - popen = self._launch(dict(self.BASE)) - - args, kwargs = popen.call_args - assert args[0] == ["woltspace-python", "-m", "bot.slack_adapter"] - assert kwargs["cwd"] == "/bundle/container" - assert kwargs["start_new_session"] is True - assert kwargs["env"]["BOT_ADAPTER"] == "slack" - assert kwargs["env"]["PYTHONPATH"] == "/bundle/container:/bundle/container/lib:" - - def test_a_bot_that_cannot_start_is_a_warning_not_a_dead_colony(self, capsys): - """Bash backgrounded this; the failure cost one line and nothing else.""" - with patch.object(boot.subprocess, "Popen", - side_effect=FileNotFoundError(2, "no woltspace-python")): - assert boot.start_slack_bot(dict(self.BASE)) is None - - assert "slack bot failed to start:" in capsys.readouterr().out - - def test_dev_mode_wraps_it_in_watchfiles(self): - popen = self._launch(dict(self.BASE, DEV_MODE="true")) - - assert popen.call_args.args[0] == [ - "woltspace-python", "-m", "watchfiles", "--filter", "python", - "python -m bot.slack_adapter", "bot/", - ] - - # --------------------------------------------------------------------------- # Tunnel reporting # --------------------------------------------------------------------------- diff --git a/test/test_native_resilience.py b/test/test_native_resilience.py index 2b701f75..e73cb786 100644 --- a/test/test_native_resilience.py +++ b/test/test_native_resilience.py @@ -871,7 +871,7 @@ def test_no_connector_is_planned_from_inherited_environment(self, tmp_path): layout = _layout(tmp_path, isolation="external") plans = {plan.name: plan for plan in plan_connectors(layout)} - assert [plan.enabled for plan in plans.values()] == [False, False, False] + assert [plan.enabled for plan in plans.values()] == [False, False, False, False] assert "ambient environment" in plans["telegram"].detail assert plans["telegram"].command == () # The pty bridge is a guest here too: the real instance owns that port. diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py new file mode 100644 index 00000000..dc1121a0 --- /dev/null +++ b/test/test_slack_adapter.py @@ -0,0 +1,172 @@ +"""Fail-closed Slack DM admission and response transport.""" + +import sys +from pathlib import Path +from unittest.mock import AsyncMock, Mock + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "container")) + +from bot import slack_adapter as slack # noqa: E402 + + +@pytest.fixture(autouse=True) +def isolated_admission(monkeypatch): + monkeypatch.setenv("SLACK_OWNER_USER", "U12345678") + slack._seen_event_ids.clear() + slack._seen_event_order.clear() + + +def event(**changes): + base = { + "type": "message", + "channel_type": "im", + "channel": "D12345678", + "user": "U12345678", + "ts": "1234.5678", + "event_ts": "1234.5678", + "text": "hello", + } + return {**base, **changes} + + +class TestOwnerDmAdmission: + @pytest.mark.parametrize("owner", ["", "jerpint", "C12345678", "U12 345678"]) + def test_missing_or_malformed_owner_fails_before_admission(self, monkeypatch, owner): + monkeypatch.setenv("SLACK_OWNER_USER", owner) + assert slack._accept_owner_dm(event(), {}) == (False, "owner_not_configured") + + def test_non_owner_is_rejected(self): + assert slack._accept_owner_dm(event(user="U87654321"), {}) == ( + False, "not_owner" + ) + + @pytest.mark.parametrize("channel_type", ["channel", "group", "mpim"]) + def test_channel_and_app_mention_surfaces_are_rejected(self, channel_type): + mention = event(type="app_mention", channel_type=channel_type) + assert slack._accept_owner_dm(mention, {}) == (False, "not_dm") + + @pytest.mark.parametrize("changes", [{"bot_id": "B1"}, {"subtype": "message_changed"}]) + def test_bot_and_subtype_events_are_rejected(self, changes): + assert slack._accept_owner_dm(event(**changes), {}) == ( + False, "non_human_event" + ) + + def test_duplicate_and_retried_events_are_claimed_once(self): + body = {"event_id": "Ev123"} + assert slack._accept_owner_dm(event(), body) == (True, "accepted") + assert slack._accept_owner_dm(event(), body) == (False, "duplicate") + + def test_missing_stable_event_identity_fails_closed(self): + incoming = event(ts=None, event_ts=None) + assert slack._accept_owner_dm(incoming, {}) == (False, "missing_event_id") + + +@pytest.mark.asyncio +async def test_every_rejected_surface_returns_before_history_or_session_work(monkeypatch): + class FakeApp: + def __init__(self): + self.handlers = {} + + def event(self, event_type): + def register(handler): + self.handlers[event_type] = handler + return handler + return register + + fake_app = FakeApp() + monkeypatch.setattr(slack, "AsyncApp", lambda **kwargs: fake_app) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + response = Mock(side_effect=AssertionError("model/session work must not run")) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "_build_thread_context", history) + monkeypatch.setattr(slack, "get_response", response) + slack.create_app() + + assert set(fake_app.handlers) == {"message"} # no app_mention listener + handler = fake_app.handlers["message"] + rejected = [ + (event(user="U87654321"), {"event_id": "EvOther"}), + (event(type="app_mention", channel_type="channel"), {"event_id": "EvMention"}), + (event(bot_id="B1"), {"event_id": "EvBot"}), + (event(subtype="message_changed"), {"event_id": "EvSubtype"}), + ] + for incoming, body in rejected: + await handler(incoming, AsyncMock(), {}, body) + + monkeypatch.setenv("SLACK_OWNER_USER", "not-a-member-id") + await handler(event(), AsyncMock(), {}, {"event_id": "EvNoOwner"}) + monkeypatch.setenv("SLACK_OWNER_USER", "U12345678") + + # A retry is also rejected before downstream work. + duplicate = event(event_ts="2000.1", ts="2000.1") + duplicate_body = {"event_id": "EvDuplicate"} + assert slack._accept_owner_dm(duplicate, duplicate_body) == (True, "accepted") + await handler(duplicate, AsyncMock(), {}, duplicate_body) + + extract.assert_not_called() + history.assert_not_awaited() + response.assert_not_called() + + +@pytest.mark.asyncio +async def test_streaming_success_stops_the_stream_without_posting_fallback(): + client = AsyncMock() + client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} + + await slack._post_text(client, "D1", "1000.1", "U12345678", "answer") + + client.chat_stopStream.assert_awaited_once_with(channel="D1", ts="2000.1") + client.chat_postMessage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_streaming_failure_uses_plain_postmessage_fallback(): + client = AsyncMock() + client.chat_startStream.side_effect = RuntimeError("not an agent app") + + await slack._post_text(client, "D1", "1000.1", "U12345678", "answer") + + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", text="answer" + ) + client.chat_stopStream.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_session_route_reports_successful_revival(monkeypatch, tmp_path): + async def revived(*args): + return {"ok": True, "status": "revived", "url": "https://session.test"} + + monkeypatch.setattr(slack.asyncio, "to_thread", revived) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + client = AsyncMock() + owner = {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + sent = client.chat_postMessage.await_args.kwargs["text"] + assert "revived and delivered" in sent + + +@pytest.mark.asyncio +async def test_dead_session_releases_thread_back_to_dog(monkeypatch, tmp_path): + async def dead(*args): + return {"ok": False, "error": "gone"} + + monkeypatch.setattr(slack.asyncio, "to_thread", dead) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "owners.json") + monkeypatch.setattr(slack, "_thread_sessions", { + "D1:1000.1": {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} + }) + client = AsyncMock() + owner = slack._thread_sessions["D1:1000.1"] + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + assert "D1:1000.1" not in slack._thread_sessions + assert "send again" in client.chat_postMessage.await_args.kwargs["text"] diff --git a/test/test_tui_bridge.py b/test/test_tui_bridge.py index 3bb99032..4c4b6009 100644 --- a/test/test_tui_bridge.py +++ b/test/test_tui_bridge.py @@ -42,8 +42,8 @@ def _wheel_root(tmp_path): return root -def test_the_bridge_is_the_second_connector_and_telegram_stays_first(): - assert [connector.name for connector in CONNECTORS] == ["telegram", "tui", "wolf"] +def test_the_bridge_follows_chat_connectors_and_telegram_stays_first(): + assert [connector.name for connector in CONNECTORS] == ["telegram", "slack", "tui", "wolf"] class TestResolution: @@ -180,7 +180,7 @@ def test_missing_bridge_is_a_named_remedy_not_a_crash_loop(self, tmp_path): def test_plan_connectors_keeps_telegram_at_index_zero(self, tmp_path): plans = plan_connectors(_layout(tmp_path), {}) - assert [plan.name for plan in plans] == ["telegram", "tui", "wolf"] + assert [plan.name for plan in plans] == ["telegram", "slack", "tui", "wolf"] class TestSupervisorWiring: diff --git a/test/test_wolf_connector.py b/test/test_wolf_connector.py index 9c59704a..d932b7e4 100644 --- a/test/test_wolf_connector.py +++ b/test/test_wolf_connector.py @@ -100,7 +100,7 @@ def test_an_install_without_the_runtime_is_a_remedy_not_a_crash_loop(self, tmp_p def test_plan_connectors_carries_the_wolf(self, tmp_path): plans = plan_connectors(_layout(tmp_path), ENTRY) - assert [plan.name for plan in plans] == ["telegram", "tui", "wolf"] + assert [plan.name for plan in plans] == ["telegram", "slack", "tui", "wolf"] class TestSupervision: From 12164c375d0aab9fed3bc545b4e230327a3eb84f Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:28:38 -0400 Subject: [PATCH 02/13] fix: require explicit Slack wolt selection --- container/bot/slack_adapter.py | 215 +++++++++++++++++++++++++++------ docs/slack-dm-agent-mvp.md | 35 ++++-- test/test_slack_adapter.py | 165 ++++++++++++++++++++++++- 3 files changed, 369 insertions(+), 46 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index e9c794b3..877cd6f1 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -14,6 +14,7 @@ import logging import asyncio import random +import tempfile import urllib.request from collections import deque from datetime import datetime, timezone @@ -21,8 +22,11 @@ from slack_bolt.async_app import AsyncApp from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler import sys -from bot.core import get_response, message_session, _bot_log, build_ack_text, _sanitize_history -from wolts import get_active_creature +from bot.core import ( + message_session, start_claude_session, _bot_log, build_ack_text, + _sanitize_history, +) +from wolts import get_active_creature, list_wolts sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "lib")) from env_compat import get_env @@ -51,6 +55,7 @@ ACTIVE_THREADS_FILE = CHAT_DIR / "_active_threads.json" THREAD_SESSIONS_FILE = CHAT_DIR / "_thread_sessions.json" +OWNER_SELECTIONS_FILE = CHAT_DIR / "_owner_selections.json" def _dog_name() -> str: @@ -97,6 +102,103 @@ def _save_thread_sessions(): _active_threads: set[str] = _load_active_threads() _thread_sessions: dict[str, dict] = _load_thread_sessions() +RODENT_WOLT_TYPES = {"raccoon", "beaver", "otter", "rodent"} + + +def _load_owner_selections() -> dict[str, str]: + try: + data = json.loads(OWNER_SELECTIONS_FILE.read_text()) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + return { + str(user): str(wolt) for user, wolt in data.items() + if isinstance(user, str) and isinstance(wolt, str) + } if isinstance(data, dict) else {} + + +def _save_owner_selections(selections: dict[str, str]) -> None: + CHAT_DIR.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + dir=CHAT_DIR, prefix=".owner-selections-", suffix=".tmp", text=True + ) + temporary = Path(temporary_name) + try: + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "w") as handle: + handle.write(json.dumps(selections, sort_keys=True) + "\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, OWNER_SELECTIONS_FILE) + finally: + temporary.unlink(missing_ok=True) + + +_owner_selections: dict[str, str] = _load_owner_selections() + + +def _eligible_wolts() -> dict[str, dict]: + eligible = {} + for wolt in sorted(list_wolts(), key=lambda item: item.get("name", "").casefold()): + name = wolt.get("name", "") + if name and wolt.get("type") in RODENT_WOLT_TYPES: + eligible[name] = wolt + return eligible + + +def _selected_wolt(user: str) -> dict | None: + name = _owner_selections.get(user) + if not name: + return None + selected = _eligible_wolts().get(name) + if selected is not None: + return selected + _owner_selections.pop(user, None) + _save_owner_selections(_owner_selections) + return None + + +def _select_wolt(user: str, name: str) -> dict | None: + selected = _eligible_wolts().get(name) + if selected is None: + return None + _owner_selections[user] = name + _save_owner_selections(_owner_selections) + return selected + + +def _picker_text(wolts: dict[str, dict]) -> str: + if not wolts: + return "No eligible wolts are available. Create a rodent wolt in the lodge first." + lines = ["Choose your wolt:"] + lines.extend(f"{index}. {name}" for index, name in enumerate(wolts, 1)) + lines.append("\nIf the menu is unavailable, send `wolt `." ) + return "\n".join(lines) + + +def _picker_blocks(wolts: dict[str, dict]) -> list[dict]: + # Slack static_select accepts at most 100 options. Text fallback above lists + # the complete roster, so every eligible wolt remains reachable. + options = [ + {"text": {"type": "plain_text", "text": name[:75]}, "value": name} + for name in list(wolts)[:100] + ] + if not options: + return [] + return [{ + "type": "actions", + "elements": [{ + "type": "static_select", + "action_id": "select_wolt", + "placeholder": {"type": "plain_text", "text": "Choose your wolt"}, + "options": options, + }], + }] + + +def _text_selection(text: str) -> str | None: + match = re.fullmatch(r"/?wolt\s+(\S+)\s*", text, flags=re.IGNORECASE) + return match.group(1) if match else None + # --- Dog ack messages --- @@ -445,6 +547,32 @@ def create_app(): """Create the owner-only DM app; channel mentions are intentionally absent.""" app = AsyncApp(token=os.environ["SLACK_BOT_TOKEN"]) + @app.action("select_wolt") + async def handle_select_wolt(ack, body, client): + await ack() + owner = _owner_user_id() + user = (body.get("user") or {}).get("id") + channel = (body.get("channel") or {}).get("id", "") + actions = body.get("actions") + action = actions[0] if isinstance(actions, list) and len(actions) == 1 else {} + option = action.get("selected_option") if isinstance(action, dict) else None + name = option.get("value") if isinstance(option, dict) else None + if user != owner or not channel.startswith("D") or not isinstance(name, str): + logger.info("Ignoring unauthorized or malformed Slack wolt selection") + return + selected = _select_wolt(user, name) + if selected is None: + logger.info("Ignoring stale Slack wolt selection") + await client.chat_postMessage( + channel=channel, + text="That wolt is no longer available. Send any DM to choose again.", + ) + return + await client.chat_postMessage( + channel=channel, + text=f"Selected {name}. Send your message again to start a session.", + ) + @app.event("message") async def handle_message(event, client, context, body): """Start or continue one owner DM thread.""" @@ -460,22 +588,25 @@ async def handle_message(event, client, context, body): bot_user_id = context.get("bot_user_id", "") user_message = _strip_mention(text, bot_user_id) - # Check for image attachment - image_result = await asyncio.get_event_loop().run_in_executor(None, _extract_image, event) - user_content = None - if image_result: - image_bytes, mime_type = image_result - user_content = _image_content(image_bytes, mime_type, user_message) - user_message = f"[image] {user_message}" if user_message else "[image]" + key = _thread_key(channel, thread_ts) - if not user_message and user_content is None: + requested = _text_selection(user_message) + if requested is not None: + selected = _select_wolt(user, requested) + if selected is None: + await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=f"No eligible wolt named `{requested}`. Send any DM to see the picker.", + ) + else: + await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=f"Selected {requested}. Send your message again to start a session.", + ) return - key = _thread_key(channel, thread_ts) - if key not in _active_threads: - _active_threads.add(key) - _save_active_threads() - # --- Session-owned thread: route directly to session --- owner = _get_session_owner(channel, thread_ts) if owner: @@ -483,37 +614,53 @@ async def handle_message(event, client, context, body): await _route_to_session(client, channel, thread_ts, owner, user_message) return - # --- Dog-owned thread: route to Haiku --- - logger.info(f"Thread follow-up from user={user} in channel={channel} thread={thread_ts}") - - # Immediate ack - try: + selected = _selected_wolt(user) + if selected is None: + wolts = _eligible_wolts() await client.chat_postMessage( channel=channel, thread_ts=thread_ts, - text=random.choice(DOG_ACK_MESSAGES), + text=_picker_text(wolts), + blocks=_picker_blocks(wolts), ) - except Exception: - pass + return - history = await _build_thread_context(client, channel, thread_ts, bot_user_id) - if history and history[-1]["role"] == "user": - history = history[:-1] + if not user_message and not event.get("files"): + return + if key not in _active_threads: + _active_threads.add(key) + _save_active_threads() + + image_result = await asyncio.get_event_loop().run_in_executor( + None, _extract_image, event + ) + if image_result: + user_message = f"[image] {user_message}" if user_message else "[image]" routing = {"adapter": "slack", "chat_id": channel, "thread_ts": thread_ts} try: - result = get_response(user_message, conversation_history=list(history), routing=routing, user_content=user_content) + session = await asyncio.to_thread( + start_claude_session, + user_message, + wolt=selected["name"], + creature=selected.get("type", ""), + routing=routing, + ) except Exception as e: - logger.error(f"Error getting response: {e}") + logger.error("Error starting selected Slack wolt: %s", e) await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text="Something broke on my end. Try again in a sec.") return - - _append_history(channel, thread_ts, "user", user_message) - for msg in result["history_messages"]: - _append_message(channel, thread_ts, msg) - - await _post_result(client, channel, thread_ts, user, result) + _set_session_owner( + channel, thread_ts, session["name"], selected["name"], selected.get("type", "") + ) + await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=build_ack_text( + session.get("url"), session["name"], "slack", selected.get("type", "") + ), + ) return app diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md index 27edaf8d..bab70555 100644 --- a/docs/slack-dm-agent-mvp.md +++ b/docs/slack-dm-agent-mvp.md @@ -10,8 +10,14 @@ and duplicate events are rejected before history, attachment, model or session work. Existing persisted Slack thread-to-session ownership remains the routing model. -Final text prefers `chat.startStream`/`chat.stopStream` and independently falls -back to `chat.postMessage`. Status, native Stop and customized per-wolt sender +The first DM with no valid identity-bound selection shows one Block Kit +`static_select` plus an exact-name text fallback. The choice persists for the +owner, is revalidated against the live rodent-wolt roster on every use, and +opens a session for exactly that wolt on the next message. Removed or ineligible +selections reopen the picker. Historical owned threads keep their original +session even after a later selection change. + +Status, native Stop, streaming session output and customized per-wolt sender identity are not enabled by this slice. ## Reusable private-lodge manifest @@ -37,7 +43,7 @@ settings: bot_events: - message.im interactivity: - is_enabled: false + is_enabled: true org_deploy_enabled: false socket_mode_enabled: true token_rotation_enabled: false @@ -60,10 +66,11 @@ member ID (starts with `U` or `W`): ``` The first live acceptance is deliberately separate: confirm the member ID with -the owner, start the lodge, verify Slack is healthy, send one owner DM, observe -one threaded response and session handoff, verify a non-owner DM and a channel -mention cause no work, restart, then confirm the same thread still routes to -its session. Never infer the owner from the first sender. +the owner, start the lodge, verify Slack is healthy, send one owner DM, choose a +wolt, resend the message, and observe the exact chosen-wolt session handoff. +Verify a non-owner DM and a channel mention cause no work, restart, then confirm +the same thread and owner selection recover. Never infer the owner from the +first sender and never replay the pre-selection message. ## Modern Slack UX and command inventory @@ -73,10 +80,11 @@ These are follow-ups, not current behavior: |---|---|---| | Slash commands | Yes | Declare each command and its request URL; Socket Mode can receive interactive payloads. | | Global/message shortcuts | Yes | Enable Interactivity and declare callbacks. | -| Buttons, selects and modals | Yes, via Block Kit | Enable Interactivity; validate action/view payloads and preserve the owner gate. | +| Static select | Yes, via Block Kit | Implemented for owner-scoped wolt selection; Interactivity must be enabled. | +| Buttons, shortcuts and modals | Yes, via Block Kit | Future callbacks must preserve the same owner gate. | | Suggested prompts | Yes, in Slack's agent/assistant UI | Add `assistant:write` and Agent View handlers. | | Processing/active status | Yes | Add `assistant:write`; explicitly return status to `active`. | -| Streaming responses | Yes | Implemented opportunistically with `chat:write`; plain message fallback remains required. | +| Streaming responses | Yes | Deferred; plain session replies remain the acceptance path. | | Native Stop | Yes, through `agent_session_stopped` | Convert the app to Agent View, subscribe to the event, and map it to real session interruption. | | Per-wolt name/icon | Yes | Add `chat:write.customize`; keep sender identity auditable and owner-controlled. | @@ -94,5 +102,14 @@ remains the source of truth for scopes and subscriptions. OAuth must not turn "installer" into ambient multi-user authority: each lodge still pins one owner and rejects every other inbound identity by default. +## Cross-channel follow-up + +Telegram must adopt the same identity-bound selection invariant after this +Slack slice: when the allowed Telegram identity has no valid selected wolt, +show **Choose your wolt**; never silently fall back to `active_dog`. Keep each +channel's selection scoped to its authenticated owner identity, revalidate it +against the live eligible-wolt roster, and reopen the picker if it disappears +or becomes ineligible. This is roadmap scope only and is not implemented here. + Official references: Slack's `chat.startStream`, `assistant.threads.setStatus`, `agent_session_stopped`, app manifests, Socket Mode and OAuth v2 documentation. diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index dc1121a0..0be13b6b 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -13,10 +13,16 @@ @pytest.fixture(autouse=True) -def isolated_admission(monkeypatch): +def isolated_admission(monkeypatch, tmp_path): monkeypatch.setenv("SLACK_OWNER_USER", "U12345678") + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + monkeypatch.setattr(slack, "OWNER_SELECTIONS_FILE", tmp_path / "owners.json") + monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "threads.json") slack._seen_event_ids.clear() slack._seen_event_order.clear() + slack._owner_selections.clear() + slack._thread_sessions.clear() + slack._active_threads.clear() def event(**changes): @@ -32,6 +38,27 @@ def event(**changes): return {**base, **changes} +class FakeApp: + def __init__(self): + self.handlers = {} + + def event(self, event_type): + def register(handler): + self.handlers[event_type] = handler + return handler + return register + + def action(self, action_id): + return self.event(f"action:{action_id}") + + +def install_fake_app(monkeypatch): + app = FakeApp() + monkeypatch.setattr(slack, "AsyncApp", lambda **kwargs: app) + slack.create_app() + return app + + class TestOwnerDmAdmission: @pytest.mark.parametrize("owner", ["", "jerpint", "C12345678", "U12 345678"]) def test_missing_or_malformed_owner_fails_before_admission(self, monkeypatch, owner): @@ -76,6 +103,9 @@ def register(handler): return handler return register + def action(self, action_id): + return self.event(f"action:{action_id}") + fake_app = FakeApp() monkeypatch.setattr(slack, "AsyncApp", lambda **kwargs: fake_app) extract = Mock(side_effect=AssertionError("attachment work must not run")) @@ -83,10 +113,10 @@ def register(handler): response = Mock(side_effect=AssertionError("model/session work must not run")) monkeypatch.setattr(slack, "_extract_image", extract) monkeypatch.setattr(slack, "_build_thread_context", history) - monkeypatch.setattr(slack, "get_response", response) + monkeypatch.setattr(slack, "start_claude_session", response) slack.create_app() - assert set(fake_app.handlers) == {"message"} # no app_mention listener + assert set(fake_app.handlers) == {"message", "action:select_wolt"} handler = fake_app.handlers["message"] rejected = [ (event(user="U87654321"), {"event_id": "EvOther"}), @@ -170,3 +200,132 @@ async def dead(*args): assert "D1:1000.1" not in slack._thread_sessions assert "send again" in client.chat_postMessage.await_args.kwargs["text"] + + +def test_selection_storage_is_atomic_private_and_owner_keyed(monkeypatch, tmp_path): + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + path = tmp_path / "_owner_selections.json" + monkeypatch.setattr(slack, "OWNER_SELECTIONS_FILE", path) + + slack._save_owner_selections({"U12345678": "n00b"}) + + assert path.stat().st_mode & 0o777 == 0o600 + assert path.read_text() == '{"U12345678": "n00b"}\n' + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_picker_caps_static_select_deterministically_but_text_lists_all(): + wolts = {f"wolt-{index:03}": {"name": f"wolt-{index:03}"} for index in range(105)} + blocks = slack._picker_blocks(wolts) + options = blocks[0]["elements"][0]["options"] + + assert len(options) == 100 + assert options[0]["value"] == "wolt-000" + assert options[-1]["value"] == "wolt-099" + assert "105. wolt-104" in slack._picker_text(wolts) + + +@pytest.mark.asyncio +async def test_no_selection_shows_picker_without_replaying_or_agent_work(monkeypatch): + app = install_fake_app(monkeypatch) + eligible = {"n00b": {"name": "n00b", "type": "raccoon"}} + monkeypatch.setattr(slack, "_eligible_wolts", lambda: eligible) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + start = Mock(side_effect=AssertionError("session work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack, "_build_thread_context", history) + client = AsyncMock() + + await app.handlers["message"]( + event(text="private first message"), client, {}, {"event_id": "EvPicker"} + ) + + sent = client.chat_postMessage.await_args.kwargs + assert "Choose your wolt" in sent["text"] + assert "private first message" not in str(sent) + extract.assert_not_called() + start.assert_not_called() + history.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_selected_top_level_dm_starts_exactly_the_chosen_wolt(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "builder" + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "builder": {"name": "builder", "type": "beaver"}, + "other": {"name": "other", "type": "raccoon"}, + }) + monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) + calls = [] + + async def run_in_thread(function, *args, **kwargs): + calls.append((function, args, kwargs)) + return {"name": "builder-busy-dam-123", "url": "https://session.test"} + + monkeypatch.setattr(slack.asyncio, "to_thread", run_in_thread) + monkeypatch.setattr(slack, "_save_active_threads", lambda: None) + monkeypatch.setattr(slack, "_save_thread_sessions", lambda: None) + client = AsyncMock() + + await app.handlers["message"]( + event(text="do the work"), client, {}, {"event_id": "EvStart"} + ) + + assert len(calls) == 1 + assert calls[0][0] is slack.start_claude_session + assert calls[0][1] == ("do the work",) + assert calls[0][2]["wolt"] == "builder" + assert slack._thread_sessions["D12345678:1234.5678"]["wolt"] == "builder" + + +@pytest.mark.asyncio +async def test_historical_owned_thread_is_not_retargeted_after_selection(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "new-wolt" + slack._thread_sessions["D12345678:1000.1"] = { + "session": "old-wolt-session-1", "wolt": "old-wolt", "creature": "otter" + } + routed = AsyncMock() + monkeypatch.setattr(slack, "_route_to_session", routed) + monkeypatch.setattr( + slack, "start_claude_session", Mock(side_effect=AssertionError("must not retarget")) + ) + + await app.handlers["message"]( + event(thread_ts="1000.1", event_ts="2000.1", ts="2000.1"), + AsyncMock(), {}, {"event_id": "EvOldThread"}, + ) + + assert routed.await_args.args[3]["wolt"] == "old-wolt" + + +@pytest.mark.asyncio +async def test_static_select_revalidates_owner_and_live_option(monkeypatch): + app = install_fake_app(monkeypatch) + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "n00b": {"name": "n00b", "type": "raccoon"} + }) + handler = app.handlers["action:select_wolt"] + ack = AsyncMock() + client = AsyncMock() + base = { + "user": {"id": "U12345678"}, + "channel": {"id": "D12345678"}, + "actions": [{"selected_option": {"value": "n00b"}}], + } + + await handler(ack, base, client) + assert slack._owner_selections == {"U12345678": "n00b"} + ack.assert_awaited_once() + + slack._owner_selections.clear() + await handler(AsyncMock(), {**base, "user": {"id": "U87654321"}}, AsyncMock()) + assert slack._owner_selections == {} + + await handler(AsyncMock(), { + **base, "actions": [{"selected_option": {"value": "removed-wolt"}}] + }, AsyncMock()) + assert slack._owner_selections == {} From a78fd84fe2745f844efdbfd0624375e82d34923f Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:36:09 -0400 Subject: [PATCH 03/13] fix: preserve notify transport diagnostics --- container/bin/notify | 32 ++++++++++++++++++-- docs/sandboxed-adapter-callbacks.md | 36 ++++++++++++++++++++++ pyproject.toml | 2 ++ test/test_notify_cli.py | 47 +++++++++++++++++++++++++++++ 4 files changed, 114 insertions(+), 3 deletions(-) create mode 100644 docs/sandboxed-adapter-callbacks.md diff --git a/container/bin/notify b/container/bin/notify index 2deb4705..8d1b7745 100755 --- a/container/bin/notify +++ b/container/bin/notify @@ -75,6 +75,15 @@ def _creature_emoji(session: str, session_reg: Path) -> str: }.get(result.stdout.strip(), "🦫") +def _safe_transport_detail(stderr: str, secrets: tuple[str, ...]) -> str: + """Keep curl's diagnosis while withholding request credentials/content.""" + detail = " ".join(stderr.split()) + for secret in sorted((value for value in secrets if value), key=len, reverse=True): + detail = detail.replace(secret, "") + # Curl diagnostics are short. Bound untrusted output from wrappers/proxies. + return detail[:300] or "no curl diagnostic" + + def main(argv: list[str] | None = None) -> int: argv = list(sys.argv[1:] if argv is None else argv) try: @@ -104,7 +113,7 @@ def main(argv: list[str] | None = None) -> int: payload.update(adapter="telegram", chat_id=route_id) api = os.environ.get("WOLTSPACE_API", "http://localhost:7777") - response = subprocess.run( + request = subprocess.run( [ "curl", "-s", @@ -119,11 +128,28 @@ def main(argv: list[str] | None = None) -> int: capture_output=True, text=True, check=False, - ).stdout + ) + if request.returncode != 0: + detail = _safe_transport_detail( + request.stderr, + ( + message, + full_message, + os.environ.get("SLACK_BOT_TOKEN", ""), + os.environ.get("SLACK_APP_TOKEN", ""), + os.environ.get("TELEGRAM_BOT_TOKEN", ""), + ), + ) + print( + f"notify: transport failed (curl exit {request.returncode}): {detail}", + file=sys.stderr, + ) + return 1 + response = request.stdout try: result = json.loads(response) except json.JSONDecodeError: - print(f"notify: failed: {response or 'unknown error'}", file=sys.stderr) + print("notify: failed: invalid or empty API response", file=sys.stderr) return 1 if result.get("ok"): print(f"[notify] → {result.get('adapter', '?')}: {message}") diff --git a/docs/sandboxed-adapter-callbacks.md b/docs/sandboxed-adapter-callbacks.md new file mode 100644 index 00000000..b2cbbadf --- /dev/null +++ b/docs/sandboxed-adapter-callbacks.md @@ -0,0 +1,36 @@ +# Sandboxed adapter callbacks + +The Slack DM acceptance test exposed a general delivery boundary: an agent can +produce a valid reply while its harness sandbox refuses the localhost HTTP call +used by `notify`. Per-message elevation is not a durable solution, and adapter +tokens or unrestricted channel authority must not move into the agent sandbox. + +## Proposed boundary + +Make `notify` a client of a lodge-owned local callback broker. The connector or +control-plane process runs the broker outside agent sandboxes and retains all +network credentials. A session submits only a bounded delivery intent through a +private Unix socket or atomic spool directory that its sandbox can access. + +The lodge creates a per-session endpoint or capability and binds it to the +session's registry record. The broker derives the adapter, owner, destination, +and thread from that record; a caller cannot select another session's route. +Before delivery it validates that the session is alive, the route is still +owned by that session, and the connector's owner policy permits the callback. +It also enforces message-size and rate limits. + +The durable form should use atomic enqueue, an idempotency key, explicit +accepted/delivered/failed state, bounded retry, a dead-letter state, and a +metadata-only audit trail. A Unix socket is the simplest primary transport; a +mode-0700 lodge-owned spool is a portable fallback where a harness can write +files but cannot open localhost sockets. The spool needs no-follow handling, +atomic rename, private per-session directories, and broker-created capabilities +so one same-lodge session cannot forge another session's callback. + +`notify` can retain HTTP as a compatibility path for trusted contexts, while +sandboxed sessions default to the broker. This keeps the security invariant: +the agent communicates reply data and intent, while all delivery authority, +credentials, routing, and retry policy remain local to the lodge. + +This is follow-up architecture only. The Slack DM MVP does not implement the +broker or change the current control-plane API. diff --git a/pyproject.toml b/pyproject.toml index 848e002f..ec33749a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ packages = ["src/woltspace"] "docs/colony-seeds.md" = "woltspace/_bundle/docs/colony-seeds.md" "docs/digging-v0.md" = "woltspace/_bundle/docs/digging-v0.md" "docs/slack-dm-agent-mvp.md" = "woltspace/_bundle/docs/slack-dm-agent-mvp.md" +"docs/sandboxed-adapter-callbacks.md" = "woltspace/_bundle/docs/sandboxed-adapter-callbacks.md" [tool.hatch.build.targets.sdist] include = [ @@ -75,5 +76,6 @@ include = [ "/docs/colony-seeds.md", "/docs/digging-v0.md", "/docs/slack-dm-agent-mvp.md", + "/docs/sandboxed-adapter-callbacks.md", "/LICENSE", ] diff --git a/test/test_notify_cli.py b/test/test_notify_cli.py index 474fbb9a..db7def21 100644 --- a/test/test_notify_cli.py +++ b/test/test_notify_cli.py @@ -254,6 +254,53 @@ def test_rejects_empty_stdin_and_invalid_chat_id(tmp_path): assert not capture.exists() +def test_transport_failure_reports_curl_diagnosis_without_secrets(tmp_path): + env, capture = _environment(tmp_path) + curl = Path(env["PATH"].split(":", 1)[0]) / "curl" + message = "harmless but private delivery text" + token = "xoxb-test-secret-token" + curl.write_text( + f"""#!/bin/bash +printf '%s\\n' 'curl: (7) could not connect; {message}; {token}' >&2 +exit 7 +""" + ) + env["SLACK_BOT_TOKEN"] = token + + result = subprocess.run( + [NOTIFY, "--slack", "C0123ABC", "123.456"], + input=message, + text=True, + capture_output=True, + env=env, + ) + + assert result.returncode == 1 + assert "transport failed (curl exit 7)" in result.stderr + assert "could not connect" in result.stderr + assert "" in result.stderr + assert message not in result.stderr + assert token not in result.stderr + assert not capture.exists() + + +def test_empty_successful_transport_response_is_diagnosed(tmp_path): + env, _ = _environment(tmp_path) + curl = Path(env["PATH"].split(":", 1)[0]) / "curl" + curl.write_text("#!/bin/bash\nexit 0\n") + + result = subprocess.run( + [NOTIFY, "--telegram", "123"], + input="delivery text", + text=True, + capture_output=True, + env=env, + ) + + assert result.returncode == 1 + assert "invalid or empty API response" in result.stderr + + def test_session_context_inlines_complete_heredocs_for_explicit_routes(): from sessions import _adapter_context From a3637051c3017ec63c243bfca4a84ed89c18d5c2 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:52:20 -0400 Subject: [PATCH 04/13] feat: reopen Slack wolt picker on demand --- container/bot/slack_adapter.py | 22 +++++++++++++++- test/test_slack_adapter.py | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 1 deletion(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 877cd6f1..42a6c043 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -166,10 +166,12 @@ def _select_wolt(user: str, name: str) -> dict | None: return selected -def _picker_text(wolts: dict[str, dict]) -> str: +def _picker_text(wolts: dict[str, dict], current: str = "") -> str: if not wolts: return "No eligible wolts are available. Create a rodent wolt in the lodge first." lines = ["Choose your wolt:"] + if current: + lines.append(f"Current selection: `{current}`") lines.extend(f"{index}. {name}" for index, name in enumerate(wolts, 1)) lines.append("\nIf the menu is unavailable, send `wolt `." ) return "\n".join(lines) @@ -200,6 +202,10 @@ def _text_selection(text: str) -> str | None: return match.group(1) if match else None +def _picker_request(text: str) -> bool: + return re.fullmatch(r"/?wolt\s*", text, flags=re.IGNORECASE) is not None + + # --- Dog ack messages --- DOG_ACK_MESSAGES = [ @@ -607,6 +613,20 @@ async def handle_message(event, client, context, body): ) return + # A bare top-level `wolt` is a navigation command, never session input. + # Inside an existing thread it remains ordinary conversation so the + # historical thread owner cannot be bypassed or retargeted. + if not event.get("thread_ts") and _picker_request(user_message): + wolts = _eligible_wolts() + current = _selected_wolt(user) + await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=_picker_text(wolts, current.get("name", "") if current else ""), + blocks=_picker_blocks(wolts), + ) + return + # --- Session-owned thread: route directly to session --- owner = _get_session_owner(channel, thread_ts) if owner: diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index 0be13b6b..f50cbe0f 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -250,6 +250,53 @@ async def test_no_selection_shows_picker_without_replaying_or_agent_work(monkeyp history.assert_not_awaited() +@pytest.mark.asyncio +async def test_bare_top_level_wolt_reopens_picker_without_spawning_or_replaying(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "n00b" + eligible = {"n00b": {"name": "n00b", "type": "raccoon"}} + monkeypatch.setattr(slack, "_eligible_wolts", lambda: eligible) + start = Mock(side_effect=AssertionError("session work must not run")) + extract = Mock(side_effect=AssertionError("attachment work must not run")) + history = AsyncMock(side_effect=AssertionError("history must not run")) + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack, "_extract_image", extract) + monkeypatch.setattr(slack, "_build_thread_context", history) + client = AsyncMock() + + await app.handlers["message"]( + event(text="wolt"), client, {}, {"event_id": "EvReopenPicker"} + ) + + sent = client.chat_postMessage.await_args.kwargs + assert "Choose your wolt" in sent["text"] + assert "Current selection: `n00b`" in sent["text"] + assert sent["blocks"] == slack._picker_blocks(eligible) + start.assert_not_called() + extract.assert_not_called() + history.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_bare_wolt_inside_owned_thread_stays_with_historical_session(monkeypatch): + app = install_fake_app(monkeypatch) + slack._thread_sessions["D12345678:1000.1"] = { + "session": "old-session-1", "wolt": "old-wolt", "creature": "otter" + } + routed = AsyncMock() + monkeypatch.setattr(slack, "_route_to_session", routed) + client = AsyncMock() + + await app.handlers["message"]( + event(text="wolt", thread_ts="1000.1", event_ts="2000.1", ts="2000.1"), + client, {}, {"event_id": "EvThreadWolt"}, + ) + + routed.assert_awaited_once() + assert routed.await_args.args[3]["session"] == "old-session-1" + client.chat_postMessage.assert_not_awaited() + + @pytest.mark.asyncio async def test_selected_top_level_dm_starts_exactly_the_chosen_wolt(monkeypatch): app = install_fake_app(monkeypatch) From f3e7ce1c6ce6a2cdb3292fdd9b19366db0e74dcf Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:00:11 -0400 Subject: [PATCH 05/13] feat: replace Slack progress with final reply --- container/bot/slack_adapter.py | 77 +++++++++++++++++--- server/notify.py | 91 ++++++++++++++++++++++-- test/test_notify_progress.py | 124 +++++++++++++++++++++++++++++++++ test/test_slack_adapter.py | 91 +++++++++++++++++++++++- 4 files changed, 369 insertions(+), 14 deletions(-) create mode 100644 test/test_notify_progress.py diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 42a6c043..41d2778f 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -23,7 +23,7 @@ from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler import sys from bot.core import ( - message_session, start_claude_session, _bot_log, build_ack_text, + message_session, start_claude_session, _bot_log, build_ack_text, registry, _sanitize_history, ) from wolts import get_active_creature, list_wolts @@ -225,6 +225,7 @@ def _picker_request(text: str) -> bool: _SEEN_EVENT_LIMIT = 2048 _seen_event_ids: set[str] = set() _seen_event_order: deque[str] = deque() +_progress_watchers: set[asyncio.Task] = set() # --- Helpers --- @@ -452,6 +453,52 @@ async def _post_text(client, channel: str, thread_ts: str, user: str, text: str) await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text) +async def _start_progress(client, channel: str, thread_ts: str, user: str) -> dict: + """Open one temporary native stream, or one updatable fallback message.""" + text = "🦫 gnawing…" + try: + started = await client.chat_startStream( + channel=channel, + thread_ts=thread_ts, + recipient_user_id=user, + markdown_text=text, + ) + if started.get("ts"): + return {"mode": "stream", "ts": started["ts"]} + raise RuntimeError("chat.startStream returned no message timestamp") + except Exception as exc: + logger.info("Slack progress streaming unavailable; using message: %s", exc) + posted = await client.chat_postMessage( + channel=channel, thread_ts=thread_ts, text=text + ) + if not posted.get("ts"): + raise RuntimeError("Slack progress message returned no timestamp") + return {"mode": "message", "ts": posted["ts"]} + + +async def _watch_progress_failure(client, session_name: str, wolt: str, channel: str): + """Remove a temporary indicator if the agent exits before `/notify`.""" + while True: + await asyncio.sleep(2) + record = await asyncio.to_thread(registry.get, session_name, check_alive=False) + if not record or not record.get("slack_progress_ts"): + return + if record.get("status") == "running": + continue + try: + await client.chat_delete(channel=channel, ts=record["slack_progress_ts"]) + except Exception as exc: + logger.info("Could not remove failed Slack progress message: %s", exc) + await asyncio.to_thread( + registry.update, + session_name, + wolt=wolt, + slack_progress_mode="", + slack_progress_ts="", + ) + return + + async def _post_result(client, channel: str, thread_ts: str, user: str, result: dict): """Post a result to Slack — handles text, session, and image types. Sends tool call logs before the final response.""" @@ -674,13 +721,27 @@ async def handle_message(event, client, context, body): _set_session_owner( channel, thread_ts, session["name"], selected["name"], selected.get("type", "") ) - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=build_ack_text( - session.get("url"), session["name"], "slack", selected.get("type", "") - ), - ) + try: + progress = await _start_progress(client, channel, thread_ts, user) + except Exception as exc: + # Progress is enhancement-only. The agent's eventual notify still + # posts normally when no progress record exists. + logger.info("Could not open Slack progress surface: %s", exc) + else: + await asyncio.to_thread( + registry.update, + session["name"], + wolt=selected["name"], + slack_progress_mode=progress["mode"], + slack_progress_ts=progress["ts"], + ) + watcher = asyncio.create_task( + _watch_progress_failure( + client, session["name"], selected["name"], channel + ) + ) + _progress_watchers.add(watcher) + watcher.add_done_callback(_progress_watchers.discard) return app diff --git a/server/notify.py b/server/notify.py index 319043a2..38e40809 100644 --- a/server/notify.py +++ b/server/notify.py @@ -8,6 +8,7 @@ from pathlib import Path import httpx +from sessions import SessionRegistry from .config import ( STATE_DIR, @@ -93,6 +94,43 @@ async def slack_send(token: str, channel: str, thread_ts: str | None, text: str) return data +async def slack_finish_progress( + token: str, channel: str, message_ts: str, mode: str, text: str +) -> dict: + async with httpx.AsyncClient() as client: + headers = {"Authorization": f"Bearer {token}"} + if mode == "stream": + stopped = await client.post( + "https://slack.com/api/chat.stopStream", + json={"channel": channel, "ts": message_ts}, + headers=headers, + ) + stopped_data = stopped.json() + if not stopped_data.get("ok"): + raise RuntimeError(stopped_data.get("error", "chat.stopStream error")) + # stopStream's markdown_text is an additional final chunk. Always use + # chat.update after stopping so the temporary `gnawing…` bytes are + # replaced, not retained above the persisted final answer. + updated = await client.post( + "https://slack.com/api/chat.update", + json={"channel": channel, "ts": message_ts, "text": text}, + headers=headers, + ) + updated_data = updated.json() + if not updated_data.get("ok"): + raise RuntimeError(updated_data.get("error", "chat.update error")) + return updated_data + + +async def slack_delete(token: str, channel: str, message_ts: str) -> None: + async with httpx.AsyncClient() as client: + await client.post( + "https://slack.com/api/chat.delete", + json={"channel": channel, "ts": message_ts}, + headers={"Authorization": f"Bearer {token}"}, + ) + + async def _send_telegram(session: str, message: str, chat_id: str) -> dict: """Send a notification via Telegram with den-reply footer.""" token = dotenv_env("TELEGRAM_BOT_TOKEN") @@ -117,7 +155,14 @@ async def _send_telegram(session: str, message: str, chat_id: str) -> dict: return {"adapter": "telegram", "chat_id": chat_id} -async def _send_slack(message: str, channel: str, thread_ts: str | None = None) -> dict: +async def _send_slack( + message: str, + channel: str, + thread_ts: str | None = None, + *, + session: str = "", + routing: dict | None = None, +) -> dict: """Send a notification via Slack to a specific channel/thread.""" token = dotenv_env("SLACK_BOT_TOKEN") if not token: @@ -126,7 +171,26 @@ async def _send_slack(message: str, channel: str, thread_ts: str | None = None) channel = dotenv_env("SLACK_NOTIFY_CHANNEL") if not channel: raise RuntimeError("no slack channel provided and SLACK_NOTIFY_CHANNEL not set") - await slack_send(token, channel, thread_ts, message) + progress_ts = (routing or {}).get("slack_progress_ts", "") + progress_mode = (routing or {}).get("slack_progress_mode", "") + if progress_ts and progress_mode in {"stream", "message"}: + try: + await slack_finish_progress( + token, channel, progress_ts, progress_mode, message + ) + except Exception: + await slack_delete(token, channel, progress_ts) + raise + finally: + if session and routing: + SessionRegistry(WOLTS_DIR).update( + session, + wolt=routing.get("wolt", ""), + slack_progress_mode="", + slack_progress_ts="", + ) + else: + await slack_send(token, channel, thread_ts, message) append_chat_history("slack", channel, message) return {"adapter": "slack", "channel": channel} @@ -139,11 +203,27 @@ async def send_notification(session: str, message: str, explicit: dict | None = {"adapter": "telegram", "chat_id": "98765"} """ - # 1. Explicit routing — caller knows exactly where to send + routing = read_session_registry(session) if session else None + + # 1. Explicit routing — caller knows exactly where to send. A temporary + # progress surface is usable only when the explicit route exactly matches + # the authoritative session record. if explicit and explicit.get("adapter"): adapter = explicit["adapter"] if adapter == "slack": - return await _send_slack(message, explicit.get("channel", ""), explicit.get("thread_ts")) + channel = explicit.get("channel", "") + thread_ts = explicit.get("thread_ts") + exact = bool( + routing + and routing.get("adapter") == "slack" + and routing.get("chat_id") == channel + and routing.get("thread_ts") == thread_ts + ) + return await _send_slack( + message, channel, thread_ts, + session=session if exact else "", + routing=routing if exact else None, + ) if adapter == "telegram": chat_id = explicit.get("chat_id", "") if chat_id: @@ -151,7 +231,6 @@ async def send_notification(session: str, message: str, explicit: dict | None = # 2. Session registry lookup — find routing from session metadata if session: - routing = read_session_registry(session) if routing: adapter = routing.get("adapter") if adapter == "slack": @@ -159,6 +238,8 @@ async def send_notification(session: str, message: str, explicit: dict | None = message, routing.get("chat_id", ""), routing.get("thread_ts"), + session=session, + routing=routing, ) if adapter == "telegram": chat_id = routing.get("chat_id") diff --git a/test/test_notify_progress.py b/test/test_notify_progress.py new file mode 100644 index 00000000..1d62dfa6 --- /dev/null +++ b/test/test_notify_progress.py @@ -0,0 +1,124 @@ +"""Slack callback replaces one exact session-bound progress surface.""" + +from unittest.mock import AsyncMock, Mock + +import pytest + +from server import notify + + +def route(**changes): + return { + "name": "n00b-session-1", + "wolt": "n00b", + "adapter": "slack", + "chat_id": "D123", + "thread_ts": "1000.1", + "slack_progress_mode": "stream", + "slack_progress_ts": "2000.1", + **changes, + } + + +class Response: + def __init__(self, payload): + self.payload = payload + + def json(self): + return self.payload + + +@pytest.mark.asyncio +async def test_stream_stop_is_followed_by_exact_final_replacement(monkeypatch): + post = AsyncMock(side_effect=[Response({"ok": True}), Response({"ok": True})]) + client = AsyncMock() + client.post = post + context = AsyncMock() + context.__aenter__.return_value = client + monkeypatch.setattr(notify.httpx, "AsyncClient", lambda: context) + + await notify.slack_finish_progress( + "xoxb-test", "D123", "2000.1", "stream", "🦝 n00b: final" + ) + + assert post.await_args_list[0].args[0].endswith("/chat.stopStream") + assert "text" not in post.await_args_list[0].kwargs["json"] + assert post.await_args_list[1].args[0].endswith("/chat.update") + assert post.await_args_list[1].kwargs["json"]["text"] == "🦝 n00b: final" + assert "gnawing" not in str(post.await_args_list[1]) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("mode", ["stream", "message"]) +async def test_exact_session_route_replaces_progress_once_and_clears( + monkeypatch, mode +): + routing = route(slack_progress_mode=mode) + monkeypatch.setattr(notify, "read_session_registry", lambda session: routing) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + finish = AsyncMock(return_value={"ok": True}) + send = AsyncMock(side_effect=AssertionError("must replace, not post")) + monkeypatch.setattr(notify, "slack_finish_progress", finish) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + update = Mock() + monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) + + result = await notify.send_notification( + "n00b-session-1", "🦝 n00b: final answer", + explicit={"adapter": "slack", "channel": "D123", "thread_ts": "1000.1"}, + ) + + assert result["adapter"] == "slack" + finish.assert_awaited_once_with( + "xoxb-test", "D123", "2000.1", mode, "🦝 n00b: final answer" + ) + update.assert_called_once_with( + "n00b-session-1", wolt="n00b", + slack_progress_mode="", slack_progress_ts="", + ) + assert "final answer" not in str(update.call_args) + + +@pytest.mark.asyncio +async def test_route_mismatch_cannot_claim_progress(monkeypatch): + monkeypatch.setattr(notify, "read_session_registry", lambda session: route()) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + send = AsyncMock(return_value={"ok": True}) + finish = AsyncMock(side_effect=AssertionError("mismatched route must not replace")) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "slack_finish_progress", finish) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + + await notify.send_notification( + "n00b-session-1", "final", + explicit={"adapter": "slack", "channel": "DOTHER", "thread_ts": "1000.1"}, + ) + + send.assert_awaited_once_with("xoxb-test", "DOTHER", "1000.1", "final") + + +@pytest.mark.asyncio +async def test_failed_replacement_deletes_progress_and_clears_idempotently(monkeypatch): + routing = route() + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + monkeypatch.setattr( + notify, "slack_finish_progress", AsyncMock(side_effect=RuntimeError("failed")) + ) + delete = AsyncMock() + monkeypatch.setattr(notify, "slack_delete", delete) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + update = Mock() + monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) + + with pytest.raises(RuntimeError, match="failed"): + await notify._send_slack( + "final", "D123", "1000.1", + session="n00b-session-1", routing=routing, + ) + + delete.assert_awaited_once_with("xoxb-test", "D123", "2000.1") + update.assert_called_once_with( + "n00b-session-1", wolt="n00b", + slack_progress_mode="", slack_progress_ts="", + ) diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index f50cbe0f..c7132854 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -166,6 +166,40 @@ async def test_streaming_failure_uses_plain_postmessage_fallback(): client.chat_stopStream.assert_not_awaited() +@pytest.mark.asyncio +async def test_progress_prefers_one_native_stream(): + client = AsyncMock() + client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} + + progress = await slack._start_progress( + client, "D1", "1000.1", "U12345678" + ) + + assert progress == {"mode": "stream", "ts": "2000.1"} + client.chat_startStream.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", recipient_user_id="U12345678", + markdown_text="🦫 gnawing…", + ) + client.chat_postMessage.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_progress_uses_one_static_message_when_streaming_is_unavailable(): + client = AsyncMock() + client.chat_startStream.side_effect = RuntimeError("not an agent app") + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.2"} + + progress = await slack._start_progress( + client, "D1", "1000.1", "U12345678" + ) + + assert progress == {"mode": "message", "ts": "2000.2"} + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", text="🦫 gnawing…" + ) + assert client.chat_update.await_count == 0 + + @pytest.mark.asyncio async def test_session_route_reports_successful_revival(monkeypatch, tmp_path): async def revived(*args): @@ -316,18 +350,73 @@ async def run_in_thread(function, *args, **kwargs): monkeypatch.setattr(slack, "_save_active_threads", lambda: None) monkeypatch.setattr(slack, "_save_thread_sessions", lambda: None) client = AsyncMock() + client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} + monkeypatch.setattr(slack, "_watch_progress_failure", AsyncMock()) await app.handlers["message"]( event(text="do the work"), client, {}, {"event_id": "EvStart"} ) - assert len(calls) == 1 + assert len(calls) == 2 assert calls[0][0] is slack.start_claude_session assert calls[0][1] == ("do the work",) assert calls[0][2]["wolt"] == "builder" + assert calls[1][0] == slack.registry.update + assert calls[1][1] == ("builder-busy-dam-123",) + assert calls[1][2] == { + "wolt": "builder", + "slack_progress_mode": "stream", + "slack_progress_ts": "2000.1", + } + assert "gnawing" not in str(calls[1]) assert slack._thread_sessions["D12345678:1234.5678"]["wolt"] == "builder" +@pytest.mark.asyncio +async def test_selected_wolt_spawn_failure_never_opens_progress(monkeypatch): + app = install_fake_app(monkeypatch) + slack._owner_selections["U12345678"] = "builder" + monkeypatch.setattr(slack, "_eligible_wolts", lambda: { + "builder": {"name": "builder", "type": "beaver"} + }) + monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) + + async def fail_spawn(function, *args, **kwargs): + raise RuntimeError("spawn failed") + + monkeypatch.setattr(slack.asyncio, "to_thread", fail_spawn) + client = AsyncMock() + + await app.handlers["message"]( + event(text="do the work"), client, {}, {"event_id": "EvFailedStart"} + ) + + client.chat_startStream.assert_not_awaited() + assert client.chat_postMessage.await_count == 1 + assert "broke" in client.chat_postMessage.await_args.kwargs["text"] + + +@pytest.mark.asyncio +async def test_failed_session_watcher_deletes_and_clears_progress(monkeypatch): + monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) + monkeypatch.setattr(slack.registry, "get", Mock(return_value={ + "status": "failed", "slack_progress_ts": "2000.1" + })) + update = Mock() + monkeypatch.setattr(slack.registry, "update", update) + client = AsyncMock() + + await slack._watch_progress_failure( + client, "builder-session-1", "builder", "D123" + ) + + client.chat_delete.assert_awaited_once_with(channel="D123", ts="2000.1") + update.assert_called_once_with( + "builder-session-1", wolt="builder", + slack_progress_mode="", slack_progress_ts="", + ) + + @pytest.mark.asyncio async def test_historical_owned_thread_is_not_retargeted_after_selection(monkeypatch): app = install_fake_app(monkeypatch) From 26549d8cfb9a8486442962ab6f8aaeb22b8eee25 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:20:29 -0400 Subject: [PATCH 06/13] feat: start Slack sessions from pending picker --- container/bot/slack_adapter.py | 309 ++++++++++++++++++++------------- docs/slack-dm-agent-mvp.md | 32 ++-- test/test_slack_adapter.py | 212 ++++++++++++++-------- 3 files changed, 354 insertions(+), 199 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 41d2778f..65dd5d17 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -15,6 +15,8 @@ import asyncio import random import tempfile +import time +import fcntl import urllib.request from collections import deque from datetime import datetime, timezone @@ -56,6 +58,11 @@ ACTIVE_THREADS_FILE = CHAT_DIR / "_active_threads.json" THREAD_SESSIONS_FILE = CHAT_DIR / "_thread_sessions.json" OWNER_SELECTIONS_FILE = CHAT_DIR / "_owner_selections.json" +PENDING_MESSAGES_FILE = CHAT_DIR / "_pending_messages.json" +PENDING_LOCK_FILE = CHAT_DIR / "_pending_messages.lock" +PENDING_TTL_SECONDS = 600 +PENDING_MAX_PER_OWNER = 5 +PENDING_MAX_BYTES = 32 * 1024 def _dog_name() -> str: @@ -173,7 +180,7 @@ def _picker_text(wolts: dict[str, dict], current: str = "") -> str: if current: lines.append(f"Current selection: `{current}`") lines.extend(f"{index}. {name}" for index, name in enumerate(wolts, 1)) - lines.append("\nIf the menu is unavailable, send `wolt `." ) + lines.append("\nIf the menu is unavailable, send `wolt `.") return "\n".join(lines) @@ -206,6 +213,97 @@ def _picker_request(text: str) -> bool: return re.fullmatch(r"/?wolt\s*", text, flags=re.IGNORECASE) is not None +def _write_private_json(path: Path, data: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, raw = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}-", suffix=".tmp") + temporary = Path(raw) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w") as handle: + json.dump(data, handle, sort_keys=True) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def _pending_mutate(mutator): + CHAT_DIR.mkdir(parents=True, exist_ok=True) + with PENDING_LOCK_FILE.open("a+") as lock: + os.chmod(PENDING_LOCK_FILE, 0o600) + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + try: + data = json.loads(PENDING_MESSAGES_FILE.read_text()) + if not isinstance(data, dict): + data = {} + except (FileNotFoundError, json.JSONDecodeError, OSError): + data = {} + result = mutator(data) + _write_private_json(PENDING_MESSAGES_FILE, data) + return result + + +def _pending_create(user: str, channel: str, root_ts: str, event_id: str, text: str, + *, now: float | None = None) -> dict: + now = time.time() if now is None else now + if not text or len(text.encode("utf-8")) > PENDING_MAX_BYTES: + raise ValueError("message must be 1..32768 UTF-8 bytes") + key = _thread_key(channel, root_ts) + def mutate(data): + active = [r for r in data.values() if r.get("user") == user and + r.get("state") == "pending" and r.get("expires_at", 0) > now] + if len(active) >= PENDING_MAX_PER_OWNER: + raise ValueError("too many pending conversations") + record = {"user": user, "channel": channel, "root_ts": root_ts, + "event_id": event_id, "text": text, "picker_ts": "", + "state": "pending", "created_at": now, + "expires_at": now + PENDING_TTL_SECONDS} + data[key] = record + return dict(record) + return _pending_mutate(mutate) + + +def _pending_set_picker(channel: str, root_ts: str, picker_ts: str) -> None: + key = _thread_key(channel, root_ts) + def mutate(data): + if key in data and data[key].get("state") == "pending": + data[key]["picker_ts"] = picker_ts + _pending_mutate(mutate) + + +def _pending_claim(user: str, channel: str, picker_ts: str, wolt: str, + *, now: float | None = None) -> dict | None: + now = time.time() if now is None else now + def mutate(data): + for record in data.values(): + if (record.get("user") == user and record.get("channel") == channel and + picker_ts in {record.get("picker_ts"), record.get("root_ts")} and + record.get("state") == "pending"): + if record.get("expires_at", 0) <= now: + record["state"] = "expired" + record.pop("text", None) + return None + claimed = dict(record) + record["state"] = "claimed" + record["wolt"] = wolt + record.pop("text", None) # durable at-most-once boundary + return claimed + return None + return _pending_mutate(mutate) + + +def _pending_finish(channel: str, root_ts: str, state: str, session: str = "") -> None: + key = _thread_key(channel, root_ts) + def mutate(data): + if key in data: + data[key]["state"] = state + data[key]["session"] = session + data[key].pop("text", None) + _pending_mutate(mutate) + + # --- Dog ack messages --- DOG_ACK_MESSAGES = [ @@ -453,37 +551,23 @@ async def _post_text(client, channel: str, thread_ts: str, user: str, text: str) await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text) -async def _start_progress(client, channel: str, thread_ts: str, user: str) -> dict: - """Open one temporary native stream, or one updatable fallback message.""" - text = "🦫 gnawing…" - try: - started = await client.chat_startStream( - channel=channel, - thread_ts=thread_ts, - recipient_user_id=user, - markdown_text=text, - ) - if started.get("ts"): - return {"mode": "stream", "ts": started["ts"]} - raise RuntimeError("chat.startStream returned no message timestamp") - except Exception as exc: - logger.info("Slack progress streaming unavailable; using message: %s", exc) - posted = await client.chat_postMessage( - channel=channel, thread_ts=thread_ts, text=text - ) - if not posted.get("ts"): - raise RuntimeError("Slack progress message returned no timestamp") - return {"mode": "message", "ts": posted["ts"]} - - -async def _watch_progress_failure(client, session_name: str, wolt: str, channel: str): - """Remove a temporary indicator if the agent exits before `/notify`.""" - while True: - await asyncio.sleep(2) +async def _watch_progress(client, session_name: str, wolt: str, channel: str): + """Bounded honest liveness updates; final `/notify` owns completion.""" + for count in range(1, 11): + await asyncio.sleep(30) record = await asyncio.to_thread(registry.get, session_name, check_alive=False) if not record or not record.get("slack_progress_ts"): return if record.get("status") == "running": + try: + await client.chat_update( + channel=channel, + ts=record["slack_progress_ts"], + text=f"🦫 Still working… {count * 30}s", + ) + except Exception as exc: + logger.info("Stopping Slack progress heartbeat: %s", exc) + return continue try: await client.chat_delete(channel=channel, ts=record["slack_progress_ts"]) @@ -499,6 +583,43 @@ async def _watch_progress_failure(client, session_name: str, wolt: str, channel: return +async def _spawn_pending(client, selected: dict, claimed: dict) -> None: + channel, root_ts = claimed["channel"], claimed["root_ts"] + picker_ts, user = claimed["picker_ts"], claimed["user"] + await client.chat_update( + channel=channel, ts=picker_ts, + text=f"✅ Accepted. 🌱 Starting {selected['name']}…", blocks=[], + ) + routing = {"adapter": "slack", "chat_id": channel, "thread_ts": root_ts} + try: + session = await asyncio.to_thread( + start_claude_session, claimed["text"], wolt=selected["name"], + creature=selected.get("type", ""), routing=routing, + ) + except Exception: + _pending_finish(channel, root_ts, "failed") + await client.chat_delete(channel=channel, ts=picker_ts) + logger.exception("Error starting selected Slack wolt") + return + _set_session_owner( + channel, root_ts, session["name"], selected["name"], selected.get("type", "") + ) + await asyncio.to_thread( + registry.update, session["name"], wolt=selected["name"], + slack_progress_mode="message", slack_progress_ts=picker_ts, + ) + _pending_finish(channel, root_ts, "consumed", session["name"]) + await client.chat_update( + channel=channel, ts=picker_ts, + text=f"🌱 {selected['name']} session ready. 🦫 Working…", blocks=[], + ) + watcher = asyncio.create_task( + _watch_progress(client, session["name"], selected["name"], channel) + ) + _progress_watchers.add(watcher) + watcher.add_done_callback(_progress_watchers.discard) + + async def _post_result(client, channel: str, thread_ts: str, user: str, result: dict): """Post a result to Slack — handles text, session, and image types. Sends tool call logs before the final response.""" @@ -610,10 +731,11 @@ async def handle_select_wolt(ack, body, client): action = actions[0] if isinstance(actions, list) and len(actions) == 1 else {} option = action.get("selected_option") if isinstance(action, dict) else None name = option.get("value") if isinstance(option, dict) else None + picker_ts = (body.get("message") or {}).get("ts", "") if user != owner or not channel.startswith("D") or not isinstance(name, str): logger.info("Ignoring unauthorized or malformed Slack wolt selection") return - selected = _select_wolt(user, name) + selected = _eligible_wolts().get(name) if selected is None: logger.info("Ignoring stale Slack wolt selection") await client.chat_postMessage( @@ -621,10 +743,13 @@ async def handle_select_wolt(ack, body, client): text="That wolt is no longer available. Send any DM to choose again.", ) return - await client.chat_postMessage( - channel=channel, - text=f"Selected {name}. Send your message again to start a session.", - ) + claimed = _pending_claim(user, channel, picker_ts, name) + if claimed is None: + logger.info("Ignoring expired, duplicate, or unbound Slack selection") + return + _owner_selections[user] = name + _save_owner_selections(_owner_selections) + await _spawn_pending(client, selected, claimed) @app.event("message") async def handle_message(event, client, context, body): @@ -641,39 +766,6 @@ async def handle_message(event, client, context, body): bot_user_id = context.get("bot_user_id", "") user_message = _strip_mention(text, bot_user_id) - key = _thread_key(channel, thread_ts) - - requested = _text_selection(user_message) - if requested is not None: - selected = _select_wolt(user, requested) - if selected is None: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"No eligible wolt named `{requested}`. Send any DM to see the picker.", - ) - else: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"Selected {requested}. Send your message again to start a session.", - ) - return - - # A bare top-level `wolt` is a navigation command, never session input. - # Inside an existing thread it remains ordinary conversation so the - # historical thread owner cannot be bypassed or retargeted. - if not event.get("thread_ts") and _picker_request(user_message): - wolts = _eligible_wolts() - current = _selected_wolt(user) - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=_picker_text(wolts, current.get("name", "") if current else ""), - blocks=_picker_blocks(wolts), - ) - return - # --- Session-owned thread: route directly to session --- owner = _get_session_owner(channel, thread_ts) if owner: @@ -681,67 +773,50 @@ async def handle_message(event, client, context, body): await _route_to_session(client, channel, thread_ts, owner, user_message) return - selected = _selected_wolt(user) - if selected is None: - wolts = _eligible_wolts() + requested = _text_selection(user_message) + if event.get("thread_ts") and requested is not None: + selected = _eligible_wolts().get(requested) + if selected: + claimed = _pending_claim(user, channel, thread_ts, requested) + if claimed: + await _spawn_pending(client, selected, claimed) + return + + if event.get("thread_ts"): + return # unowned/stale thread never becomes a fresh conversation + + if event.get("files"): await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=_picker_text(wolts), - blocks=_picker_blocks(wolts), + channel=channel, thread_ts=thread_ts, + text="Attachments are not supported by the wolt picker yet. Send a text-only message.", ) return - if not user_message and not event.get("files"): + wolts = _eligible_wolts() + current = _selected_wolt(user) + if _picker_request(user_message): + await client.chat_postMessage( + channel=channel, thread_ts=thread_ts, + text=_picker_text(wolts, current.get("name", "") if current else ""), + blocks=_picker_blocks(wolts), + ) return - if key not in _active_threads: - _active_threads.add(key) - _save_active_threads() - - image_result = await asyncio.get_event_loop().run_in_executor( - None, _extract_image, event - ) - if image_result: - user_message = f"[image] {user_message}" if user_message else "[image]" - - routing = {"adapter": "slack", "chat_id": channel, "thread_ts": thread_ts} try: - session = await asyncio.to_thread( - start_claude_session, + _pending_create( + user, channel, thread_ts, + str(body.get("event_id") or event.get("event_ts") or event["ts"]), user_message, - wolt=selected["name"], - creature=selected.get("type", ""), - routing=routing, ) - except Exception as e: - logger.error("Error starting selected Slack wolt: %s", e) - await client.chat_postMessage(channel=channel, thread_ts=thread_ts, - text="Something broke on my end. Try again in a sec.") + except ValueError as exc: + await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=str(exc)) return - _set_session_owner( - channel, thread_ts, session["name"], selected["name"], selected.get("type", "") + posted = await client.chat_postMessage( + channel=channel, thread_ts=thread_ts, + text=_picker_text(wolts, current.get("name", "") if current else ""), + blocks=_picker_blocks(wolts), ) - try: - progress = await _start_progress(client, channel, thread_ts, user) - except Exception as exc: - # Progress is enhancement-only. The agent's eventual notify still - # posts normally when no progress record exists. - logger.info("Could not open Slack progress surface: %s", exc) - else: - await asyncio.to_thread( - registry.update, - session["name"], - wolt=selected["name"], - slack_progress_mode=progress["mode"], - slack_progress_ts=progress["ts"], - ) - watcher = asyncio.create_task( - _watch_progress_failure( - client, session["name"], selected["name"], channel - ) - ) - _progress_watchers.add(watcher) - watcher.add_done_callback(_progress_watchers.discard) + if posted.get("ts"): + _pending_set_picker(channel, thread_ts, posted["ts"]) return app diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md index bab70555..4010c11e 100644 --- a/docs/slack-dm-agent-mvp.md +++ b/docs/slack-dm-agent-mvp.md @@ -10,15 +10,24 @@ and duplicate events are rejected before history, attachment, model or session work. Existing persisted Slack thread-to-session ownership remains the routing model. -The first DM with no valid identity-bound selection shows one Block Kit -`static_select` plus an exact-name text fallback. The choice persists for the -owner, is revalidated against the live rodent-wolt roster on every use, and -opens a session for exactly that wolt on the next message. Removed or ineligible -selections reopen the picker. Historical owned threads keep their original -session even after a later selection change. - -Status, native Stop, streaming session output and customized per-wolt sender -identity are not enabled by this slice. +Every fresh top-level text DM starts a new conversation by showing one Block Kit +`static_select` plus an exact-name text fallback. The original text is held in a +private, size-capped, ten-minute pending record. A valid owner selection claims +it atomically, removes the stored plaintext, and delivers it exactly once to a +new session for that wolt. Duplicate, stale, expired, forged or ambiguously +recovered claims never replay it. A prior selection is displayed only as a +convenience; it does not bypass the picker. Historical owned threads remain +pinned to their original session. Attachments receive an explicit deferred +response rather than being silently dropped. + +The picker message becomes the single temporary progress surface: accepted, +starting, session ready/working, then at most one honest liveness update every +30 seconds. The final `/notify` replaces that same message with the existing +formatted response and session footer. Failures clean it up idempotently. The +surface uses stock Unicode emoji only; richer animation is future polish. + +Native Agent status, Stop, streaming session output and customized per-wolt +sender identity are not enabled by this slice. ## Reusable private-lodge manifest @@ -67,10 +76,9 @@ member ID (starts with `U` or `W`): The first live acceptance is deliberately separate: confirm the member ID with the owner, start the lodge, verify Slack is healthy, send one owner DM, choose a -wolt, resend the message, and observe the exact chosen-wolt session handoff. +wolt, and observe the original message start the exact chosen-wolt session once. Verify a non-owner DM and a channel mention cause no work, restart, then confirm -the same thread and owner selection recover. Never infer the owner from the -first sender and never replay the pre-selection message. +the same owned thread recovers. Never infer the owner from the first sender. ## Modern Slack UX and command inventory diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index c7132854..1dfa2c8f 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -1,8 +1,10 @@ """Fail-closed Slack DM admission and response transport.""" +import json import sys +from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from unittest.mock import AsyncMock, Mock +from unittest.mock import AsyncMock, Mock, patch import pytest @@ -18,6 +20,8 @@ def isolated_admission(monkeypatch, tmp_path): monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) monkeypatch.setattr(slack, "OWNER_SELECTIONS_FILE", tmp_path / "owners.json") monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "threads.json") + monkeypatch.setattr(slack, "PENDING_MESSAGES_FILE", tmp_path / "pending.json") + monkeypatch.setattr(slack, "PENDING_LOCK_FILE", tmp_path / "pending.lock") slack._seen_event_ids.clear() slack._seen_event_order.clear() slack._owner_selections.clear() @@ -145,6 +149,7 @@ def action(self, action_id): @pytest.mark.asyncio async def test_streaming_success_stops_the_stream_without_posting_fallback(): client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} await slack._post_text(client, "D1", "1000.1", "U12345678", "answer") @@ -166,40 +171,6 @@ async def test_streaming_failure_uses_plain_postmessage_fallback(): client.chat_stopStream.assert_not_awaited() -@pytest.mark.asyncio -async def test_progress_prefers_one_native_stream(): - client = AsyncMock() - client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} - - progress = await slack._start_progress( - client, "D1", "1000.1", "U12345678" - ) - - assert progress == {"mode": "stream", "ts": "2000.1"} - client.chat_startStream.assert_awaited_once_with( - channel="D1", thread_ts="1000.1", recipient_user_id="U12345678", - markdown_text="🦫 gnawing…", - ) - client.chat_postMessage.assert_not_awaited() - - -@pytest.mark.asyncio -async def test_progress_uses_one_static_message_when_streaming_is_unavailable(): - client = AsyncMock() - client.chat_startStream.side_effect = RuntimeError("not an agent app") - client.chat_postMessage.return_value = {"ok": True, "ts": "2000.2"} - - progress = await slack._start_progress( - client, "D1", "1000.1", "U12345678" - ) - - assert progress == {"mode": "message", "ts": "2000.2"} - client.chat_postMessage.assert_awaited_once_with( - channel="D1", thread_ts="1000.1", text="🦫 gnawing…" - ) - assert client.chat_update.await_count == 0 - - @pytest.mark.asyncio async def test_session_route_reports_successful_revival(monkeypatch, tmp_path): async def revived(*args): @@ -208,6 +179,7 @@ async def revived(*args): monkeypatch.setattr(slack.asyncio, "to_thread", revived) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} await slack._route_to_session(client, "D1", "1000.1", owner, "continue") @@ -259,6 +231,66 @@ def test_picker_caps_static_select_deterministically_but_text_lists_all(): assert "105. wolt-104" in slack._picker_text(wolts) +def test_pending_storage_is_private_atomic_and_claim_removes_plaintext(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "private task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + claimed = slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) + stored = json.loads(slack.PENDING_MESSAGES_FILE.read_text())["D1:1000.1"] + + assert claimed["text"] == "private task" + assert stored["state"] == "claimed" + assert "text" not in stored + assert slack.PENDING_MESSAGES_FILE.stat().st_mode & 0o777 == 0o600 + assert slack.PENDING_LOCK_FILE.stat().st_mode & 0o777 == 0o600 + assert list(slack.CHAT_DIR.glob("*.tmp")) == [] + + +def test_pending_claim_expires_and_rejects_wrong_binding(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + assert slack._pending_claim("U-other", "D1", "2000.1", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D-other", "2000.1", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D1", "wrong", "n00b", now=2) is None + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=602) is None + stored = json.loads(slack.PENDING_MESSAGES_FILE.read_text())["D1:1000.1"] + assert stored["state"] == "expired" + assert "text" not in stored + + +def test_pending_claim_race_has_one_winner_and_never_replays(): + slack._pending_create("U12345678", "D1", "1000.1", "Ev1", "task", now=1) + slack._pending_set_picker("D1", "1000.1", "2000.1") + + def claim(_): + return slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) + + with ThreadPoolExecutor(max_workers=12) as pool: + results = list(pool.map(claim, range(12))) + + assert sum(result is not None for result in results) == 1 + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=3) is None + + +def test_ambiguous_pending_recovery_never_replays(): + slack.PENDING_MESSAGES_FILE.write_text('{"D1:1000.1":') + + assert slack._pending_claim("U12345678", "D1", "2000.1", "n00b", now=2) is None + assert json.loads(slack.PENDING_MESSAGES_FILE.read_text()) == {} + + +def test_pending_caps_and_message_size_are_bounded(): + for index in range(slack.PENDING_MAX_PER_OWNER): + slack._pending_create( + "U12345678", "D1", f"1000.{index}", f"Ev{index}", "task", now=1 + ) + with pytest.raises(ValueError, match="too many pending"): + slack._pending_create("U12345678", "D1", "2000.1", "EvX", "task", now=2) + with pytest.raises(ValueError, match="1..32768"): + slack._pending_create("U-other", "D1", "3000.1", "EvY", "x" * 32769, now=2) + + @pytest.mark.asyncio async def test_no_selection_shows_picker_without_replaying_or_agent_work(monkeypatch): app = install_fake_app(monkeypatch) @@ -271,6 +303,7 @@ async def test_no_selection_shows_picker_without_replaying_or_agent_work(monkeyp monkeypatch.setattr(slack, "start_claude_session", start) monkeypatch.setattr(slack, "_build_thread_context", history) client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} await app.handlers["message"]( event(text="private first message"), client, {}, {"event_id": "EvPicker"} @@ -332,7 +365,7 @@ async def test_bare_wolt_inside_owned_thread_stays_with_historical_session(monke @pytest.mark.asyncio -async def test_selected_top_level_dm_starts_exactly_the_chosen_wolt(monkeypatch): +async def test_prior_selection_still_opens_picker_without_starting(monkeypatch): app = install_fake_app(monkeypatch) slack._owner_selections["U12345678"] = "builder" monkeypatch.setattr(slack, "_eligible_wolts", lambda: { @@ -340,40 +373,23 @@ async def test_selected_top_level_dm_starts_exactly_the_chosen_wolt(monkeypatch) "other": {"name": "other", "type": "raccoon"}, }) monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) - calls = [] - - async def run_in_thread(function, *args, **kwargs): - calls.append((function, args, kwargs)) - return {"name": "builder-busy-dam-123", "url": "https://session.test"} - - monkeypatch.setattr(slack.asyncio, "to_thread", run_in_thread) - monkeypatch.setattr(slack, "_save_active_threads", lambda: None) - monkeypatch.setattr(slack, "_save_thread_sessions", lambda: None) + start = Mock(side_effect=AssertionError("must wait for selection")) + monkeypatch.setattr(slack, "start_claude_session", start) client = AsyncMock() - client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} - monkeypatch.setattr(slack, "_watch_progress_failure", AsyncMock()) + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} await app.handlers["message"]( event(text="do the work"), client, {}, {"event_id": "EvStart"} ) - assert len(calls) == 2 - assert calls[0][0] is slack.start_claude_session - assert calls[0][1] == ("do the work",) - assert calls[0][2]["wolt"] == "builder" - assert calls[1][0] == slack.registry.update - assert calls[1][1] == ("builder-busy-dam-123",) - assert calls[1][2] == { - "wolt": "builder", - "slack_progress_mode": "stream", - "slack_progress_ts": "2000.1", - } - assert "gnawing" not in str(calls[1]) - assert slack._thread_sessions["D12345678:1234.5678"]["wolt"] == "builder" + start.assert_not_called() + assert "Choose your wolt" in client.chat_postMessage.await_args.kwargs["text"] + pending = json.loads(slack.PENDING_MESSAGES_FILE.read_text()) + assert pending["D12345678:1234.5678"]["text"] == "do the work" @pytest.mark.asyncio -async def test_selected_wolt_spawn_failure_never_opens_progress(monkeypatch): +async def test_attachment_is_explicitly_deferred(monkeypatch): app = install_fake_app(monkeypatch) slack._owner_selections["U12345678"] = "builder" monkeypatch.setattr(slack, "_eligible_wolts", lambda: { @@ -381,19 +397,15 @@ async def test_selected_wolt_spawn_failure_never_opens_progress(monkeypatch): }) monkeypatch.setattr(slack, "_extract_image", lambda incoming: None) - async def fail_spawn(function, *args, **kwargs): - raise RuntimeError("spawn failed") - - monkeypatch.setattr(slack.asyncio, "to_thread", fail_spawn) client = AsyncMock() await app.handlers["message"]( - event(text="do the work"), client, {}, {"event_id": "EvFailedStart"} + event(text="see file", files=[{"id": "F1"}]), client, {}, {"event_id": "EvFile"} ) - client.chat_startStream.assert_not_awaited() assert client.chat_postMessage.await_count == 1 - assert "broke" in client.chat_postMessage.await_args.kwargs["text"] + assert "Attachments are not supported" in client.chat_postMessage.await_args.kwargs["text"] + assert not slack.PENDING_MESSAGES_FILE.exists() @pytest.mark.asyncio @@ -406,7 +418,7 @@ async def test_failed_session_watcher_deletes_and_clears_progress(monkeypatch): monkeypatch.setattr(slack.registry, "update", update) client = AsyncMock() - await slack._watch_progress_failure( + await slack._watch_progress( client, "builder-session-1", "builder", "D123" ) @@ -417,6 +429,54 @@ async def test_failed_session_watcher_deletes_and_clears_progress(monkeypatch): ) +@pytest.mark.asyncio +async def test_progress_heartbeat_is_bounded_to_thirty_second_cadence(monkeypatch): + sleep = AsyncMock() + monkeypatch.setattr(slack.asyncio, "sleep", sleep) + monkeypatch.setattr(slack.registry, "get", Mock(return_value={ + "status": "running", "slack_progress_ts": "2000.1" + })) + client = AsyncMock() + client.chat_update.side_effect = RuntimeError("rate limited") + + await slack._watch_progress(client, "builder-session-1", "builder", "D123") + + sleep.assert_awaited_once_with(30) + client.chat_update.assert_awaited_once_with( + channel="D123", ts="2000.1", text="🦫 Still working… 30s" + ) + + +@pytest.mark.asyncio +async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): + session = {"name": "n00b-session-1"} + start = Mock(return_value=session) + update = Mock() + watcher = AsyncMock() + monkeypatch.setattr(slack, "start_claude_session", start) + monkeypatch.setattr(slack.registry, "update", update) + monkeypatch.setattr(slack, "_watch_progress", watcher) + client = AsyncMock() + selected = {"name": "n00b", "type": "raccoon"} + claimed = { + "user": "U12345678", "channel": "D1", "root_ts": "1000.1", + "picker_ts": "2000.1", "text": "original task", + } + + await slack._spawn_pending(client, selected, claimed) + + start.assert_called_once() + first = start.call_args + assert first.args == ("original task",) + assert first.kwargs["wolt"] == "n00b" + assert first.kwargs["routing"] == { + "adapter": "slack", "chat_id": "D1", "thread_ts": "1000.1" + } + assert slack._thread_sessions["D1:1000.1"]["session"] == "n00b-session-1" + assert client.chat_update.await_args_list[0].kwargs["text"].startswith("✅ Accepted") + assert "🦫 Working" in client.chat_update.await_args_list[1].kwargs["text"] + + @pytest.mark.asyncio async def test_historical_owned_thread_is_not_retargeted_after_selection(monkeypatch): app = install_fake_app(monkeypatch) @@ -447,16 +507,28 @@ async def test_static_select_revalidates_owner_and_live_option(monkeypatch): handler = app.handlers["action:select_wolt"] ack = AsyncMock() client = AsyncMock() + spawn = AsyncMock() + monkeypatch.setattr(slack, "_spawn_pending", spawn) + slack._pending_create( + "U12345678", "D12345678", "1000.1", "EvPending", "original task", now=1 + ) + slack._pending_set_picker("D12345678", "1000.1", "2000.1") base = { "user": {"id": "U12345678"}, "channel": {"id": "D12345678"}, + "message": {"ts": "2000.1"}, "actions": [{"selected_option": {"value": "n00b"}}], } - await handler(ack, base, client) + with patch.object(slack.time, "time", return_value=2): + await handler(ack, base, client) assert slack._owner_selections == {"U12345678": "n00b"} + assert spawn.await_args.args[2]["text"] == "original task" ack.assert_awaited_once() + await handler(AsyncMock(), base, client) + assert spawn.await_count == 1 + slack._owner_selections.clear() await handler(AsyncMock(), {**base, "user": {"id": "U87654321"}}, AsyncMock()) assert slack._owner_selections == {} From b911afc9acfee11560fbe5bb91be0c1e907836f7 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 00:30:43 -0400 Subject: [PATCH 07/13] feat: pulse Slack progress with session link --- container/bot/slack_adapter.py | 91 +++++++++++++++++++++++++++------- docs/slack-dm-agent-mvp.md | 11 ++-- test/test_slack_adapter.py | 59 +++++++++++++++++++--- 3 files changed, 132 insertions(+), 29 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 65dd5d17..bb65279b 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -18,6 +18,7 @@ import time import fcntl import urllib.request +import urllib.parse from collections import deque from datetime import datetime, timezone from pathlib import Path @@ -63,6 +64,13 @@ PENDING_TTL_SECONDS = 600 PENDING_MAX_PER_OWNER = 5 PENDING_MAX_BYTES = 32 * 1024 +PROGRESS_PULSE_INTERVAL_SECONDS = 4 +PROGRESS_PULSE_FRAMES = ( + "🦫 Working ·", + "🦫 Working ··", + "🦫 Working ···", + "🦫 Working ··", +) def _dog_name() -> str: @@ -551,35 +559,78 @@ async def _post_text(client, channel: str, thread_ts: str, user: str, text: str) await client.chat_postMessage(channel=channel, thread_ts=thread_ts, text=text) -async def _watch_progress(client, session_name: str, wolt: str, channel: str): +def _session_link(session: dict) -> str: + """Return only an exact platform-created HTTPS link for this session.""" + url = session.get("url") or "" + name = session.get("name") or "" + try: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except (TypeError, ValueError): + return "" + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or + parsed.password or parsed.path != "/tui" or parsed.fragment or + query != {"session": [name]}): + return "" + return url + + +async def _progress_record(session_name: str) -> dict | None: + record = await asyncio.to_thread(registry.get, session_name, check_alive=False) + if not record or not record.get("slack_progress_ts"): + return None + return record + + +async def _clear_failed_progress(client, session_name: str, wolt: str, + channel: str, record: dict) -> None: + try: + await client.chat_delete(channel=channel, ts=record["slack_progress_ts"]) + except Exception as exc: + logger.info("Could not remove failed Slack progress message: %s", exc) + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="", slack_progress_ts="", + ) + + +async def _watch_progress(client, session_name: str, wolt: str, channel: str, + session_link: str = ""): """Bounded honest liveness updates; final `/notify` owns completion.""" - for count in range(1, 11): + suffix = f" <{session_link}|Open session>" if session_link else "" + for frame in PROGRESS_PULSE_FRAMES: + await asyncio.sleep(PROGRESS_PULSE_INTERVAL_SECONDS) + record = await _progress_record(session_name) + if not record: + return + if record.get("status") != "running": + await _clear_failed_progress(client, session_name, wolt, channel, record) + return + try: + await client.chat_update( + channel=channel, ts=record["slack_progress_ts"], + text=f"{frame}{suffix}", + ) + except Exception as exc: + logger.info("Stopping Slack progress pulse: %s", exc) + return + for _ in range(10): await asyncio.sleep(30) - record = await asyncio.to_thread(registry.get, session_name, check_alive=False) - if not record or not record.get("slack_progress_ts"): + record = await _progress_record(session_name) + if not record: return if record.get("status") == "running": try: await client.chat_update( channel=channel, ts=record["slack_progress_ts"], - text=f"🦫 Still working… {count * 30}s", + text=f"🦫 Still working…{suffix}", ) except Exception as exc: logger.info("Stopping Slack progress heartbeat: %s", exc) return continue - try: - await client.chat_delete(channel=channel, ts=record["slack_progress_ts"]) - except Exception as exc: - logger.info("Could not remove failed Slack progress message: %s", exc) - await asyncio.to_thread( - registry.update, - session_name, - wolt=wolt, - slack_progress_mode="", - slack_progress_ts="", - ) + await _clear_failed_progress(client, session_name, wolt, channel, record) return @@ -609,12 +660,16 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: slack_progress_mode="message", slack_progress_ts=picker_ts, ) _pending_finish(channel, root_ts, "consumed", session["name"]) + session_link = _session_link(session) + link_suffix = f" <{session_link}|Open session>" if session_link else "" await client.chat_update( channel=channel, ts=picker_ts, - text=f"🌱 {selected['name']} session ready. 🦫 Working…", blocks=[], + text=f"🌱 {selected['name']} session ready. 🦫 Working…{link_suffix}", blocks=[], ) watcher = asyncio.create_task( - _watch_progress(client, session["name"], selected["name"], channel) + _watch_progress( + client, session["name"], selected["name"], channel, session_link + ) ) _progress_watchers.add(watcher) watcher.add_done_callback(_progress_watchers.discard) diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md index 4010c11e..c89b0a2e 100644 --- a/docs/slack-dm-agent-mvp.md +++ b/docs/slack-dm-agent-mvp.md @@ -21,10 +21,13 @@ pinned to their original session. Attachments receive an explicit deferred response rather than being silently dropped. The picker message becomes the single temporary progress surface: accepted, -starting, session ready/working, then at most one honest liveness update every -30 seconds. The final `/notify` replaces that same message with the existing -formatted response and session footer. Failures clean it up idempotently. The -surface uses stock Unicode emoji only; richer animation is future polish. +starting, then session ready/working with a validated link to the exact spawned +session when the platform supplies its HTTPS lodge URL. Four conservative +four-second stock-Unicode pulse frames provide a brief sign of life before the +surface returns to at most one honest liveness update every 30 seconds. The +final `/notify` replaces that same message with the existing formatted response +and session footer. Final/error clearing stops further updates and failures +clean up idempotently. No custom emoji, assets, scopes or hostnames are added. Native Agent status, Stop, streaming session output and customized per-wolt sender identity are not enabled by this slice. diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index 1dfa2c8f..c2a6ccf0 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -430,26 +430,68 @@ async def test_failed_session_watcher_deletes_and_clears_progress(monkeypatch): @pytest.mark.asyncio -async def test_progress_heartbeat_is_bounded_to_thirty_second_cadence(monkeypatch): +async def test_progress_pulses_four_times_then_uses_thirty_second_heartbeat(monkeypatch): sleep = AsyncMock() monkeypatch.setattr(slack.asyncio, "sleep", sleep) monkeypatch.setattr(slack.registry, "get", Mock(return_value={ "status": "running", "slack_progress_ts": "2000.1" })) client = AsyncMock() - client.chat_update.side_effect = RuntimeError("rate limited") + client.chat_update.side_effect = [None, None, None, None, RuntimeError("rate limited")] - await slack._watch_progress(client, "builder-session-1", "builder", "D123") + await slack._watch_progress( + client, "builder-session-1", "builder", "D123", + "https://lodge.test/tui?session=builder-session-1", + ) - sleep.assert_awaited_once_with(30) - client.chat_update.assert_awaited_once_with( - channel="D123", ts="2000.1", text="🦫 Still working… 30s" + assert [call.args[0] for call in sleep.await_args_list] == [4, 4, 4, 4, 30] + assert client.chat_update.await_count == 5 + texts = [call.kwargs["text"] for call in client.chat_update.await_args_list] + assert texts[:4] == [ + "🦫 Working · ", + "🦫 Working ·· ", + "🦫 Working ··· ", + "🦫 Working ·· ", + ] + assert texts[4] == ( + "🦫 Still working… " + "" ) +@pytest.mark.parametrize("session, expected", [ + ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1"}, + "https://lodge.test/tui?session=n00b-1"), + ({"name": "n00b-1", "url": "http://lodge.test/tui?session=n00b-1"}, ""), + ({"name": "n00b-1", "url": "https://evil.test/tui?session=other"}, ""), + ({"name": "n00b-1", "url": "https://u:p@evil.test/tui?session=n00b-1"}, ""), + ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1#x"}, ""), + ({"name": "n00b-1", "url": None}, ""), +]) +def test_session_link_accepts_only_exact_platform_https_url(session, expected): + assert slack._session_link(session) == expected + + +@pytest.mark.asyncio +async def test_progress_pulse_stops_when_final_callback_clears_binding(monkeypatch): + monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) + monkeypatch.setattr(slack.registry, "get", Mock(return_value={ + "status": "running", "slack_progress_ts": "" + })) + client = AsyncMock() + + await slack._watch_progress(client, "n00b-session-1", "n00b", "D1") + + client.chat_update.assert_not_awaited() + client.chat_delete.assert_not_awaited() + + @pytest.mark.asyncio async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): - session = {"name": "n00b-session-1"} + session = { + "name": "n00b-session-1", + "url": "https://lodge.test/tui?session=n00b-session-1", + } start = Mock(return_value=session) update = Mock() watcher = AsyncMock() @@ -475,6 +517,9 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): assert slack._thread_sessions["D1:1000.1"]["session"] == "n00b-session-1" assert client.chat_update.await_args_list[0].kwargs["text"].startswith("✅ Accepted") assert "🦫 Working" in client.chat_update.await_args_list[1].kwargs["text"] + assert "" in ( + client.chat_update.await_args_list[1].kwargs["text"] + ) @pytest.mark.asyncio From c48394250c5f5c9bb26079ab9d805f817cbda5de Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:33:08 -0400 Subject: [PATCH 08/13] refine Slack thread progress UX --- container/bot/slack_adapter.py | 72 ++++++++++++++++++++++++++-------- server/notify.py | 24 +++++++++++- test/test_notify_progress.py | 29 +++++++++++++- test/test_slack_adapter.py | 64 ++++++++++++++++++++++++++---- 4 files changed, 163 insertions(+), 26 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index bb65279b..3ed3e409 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -514,13 +514,15 @@ def _get_session_owner(channel: str, thread_ts: str) -> dict | None: return _thread_sessions.get(key) -def _set_session_owner(channel: str, thread_ts: str, session_name: str, wolt_name: str, creature: str): +def _set_session_owner(channel: str, thread_ts: str, session_name: str, wolt_name: str, + creature: str, session_link: str = ""): """Mark a thread as owned by a session.""" key = _thread_key(channel, thread_ts) _thread_sessions[key] = { "session": session_name, "wolt": wolt_name, "creature": creature, + "session_link": session_link, } _save_thread_sessions() @@ -652,15 +654,17 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: await client.chat_delete(channel=channel, ts=picker_ts) logger.exception("Error starting selected Slack wolt") return + session_link = _session_link(session) _set_session_owner( - channel, root_ts, session["name"], selected["name"], selected.get("type", "") + channel, root_ts, session["name"], selected["name"], selected.get("type", ""), + session_link, ) await asyncio.to_thread( registry.update, session["name"], wolt=selected["name"], slack_progress_mode="message", slack_progress_ts=picker_ts, + slack_session_link=session_link, ) _pending_finish(channel, root_ts, "consumed", session["name"]) - session_link = _session_link(session) link_suffix = f" <{session_link}|Open session>" if session_link else "" await client.chat_update( channel=channel, ts=picker_ts, @@ -723,7 +727,29 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t session_name = owner["session"] wolt = owner["wolt"] creature = owner["creature"] - emoji = CREATURE_EMOJIS.get(creature, "🐾") + session_link = owner.get("session_link", "") + link_suffix = f" <{session_link}|Open session>" if session_link else "" + + # Every human turn gets a fresh bottom-of-thread surface. The session's + # final /notify replaces this exact message, so liveness never drifts away + # from the newest turn and the thread does not accumulate status chatter. + progress = await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=f"🦫 Working…{link_suffix}", + ) + progress_ts = progress.get("ts", "") + if progress_ts: + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="message", slack_progress_ts=progress_ts, + slack_session_link=session_link, + ) + watcher = asyncio.create_task( + _watch_progress(client, session_name, wolt, channel, session_link) + ) + _progress_watchers.add(watcher) + watcher.add_done_callback(_progress_watchers.discard) session_msg = ( f"[slack message from human, channel={channel}, thread={thread_ts}]: {text}\n" @@ -737,25 +763,39 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t _append_history(channel, thread_ts, "user", text) if result.get("ok"): - session_link = result.get("url") or session_name - if result.get("status") == "revived": - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"{emoji} {wolt}: → session had exited — revived and delivered\n{session_link}", - ) - else: - await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"🪵 sent\n{session_link}", + refreshed_link = _session_link({ + "name": session_name, + "url": result.get("url") or "", + }) + if refreshed_link and refreshed_link != session_link: + session_link = refreshed_link + _set_session_owner( + channel, thread_ts, session_name, wolt, creature, session_link ) + if progress_ts: + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_session_link=session_link, + ) + await client.chat_update( + channel=channel, ts=progress_ts, + text=f"🦫 Working… <{session_link}|Open session>", + ) _append_message(channel, thread_ts, { "role": "assistant", "content": f"[delivered to session {session_name}]", }) else: error = result.get("error", "unknown error") + if progress_ts: + try: + await client.chat_delete(channel=channel, ts=progress_ts) + except Exception as exc: + logger.info("Could not remove failed Slack progress message: %s", exc) + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="", slack_progress_ts="", + ) # Return the thread to the dog so the next owner message can recover. _thread_sessions.pop(_thread_key(channel, thread_ts), None) _save_thread_sessions() diff --git a/server/notify.py b/server/notify.py index 38e40809..cb786c2d 100644 --- a/server/notify.py +++ b/server/notify.py @@ -5,6 +5,7 @@ """ import json +import urllib.parse from pathlib import Path import httpx @@ -29,6 +30,21 @@ class NoNotificationTarget(RuntimeError): """ +def _slack_session_link(session: str, routing: dict | None) -> str: + """Return only the adapter-validated link bound to this exact session.""" + url = (routing or {}).get("slack_session_link", "") + try: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qs(parsed.query, strict_parsing=True) + except (TypeError, ValueError): + return "" + if (parsed.scheme != "https" or not parsed.hostname or parsed.username or + parsed.password or parsed.path != "/tui" or parsed.fragment or + query != {"session": [session]}): + return "" + return url + + def read_session_registry(session: str) -> dict | None: """Find a session file by scanning all per-wolt .state/sessions/ dirs.""" @@ -173,10 +189,14 @@ async def _send_slack( raise RuntimeError("no slack channel provided and SLACK_NOTIFY_CHANNEL not set") progress_ts = (routing or {}).get("slack_progress_ts", "") progress_mode = (routing or {}).get("slack_progress_mode", "") + session_link = _slack_session_link(session, routing) + final_message = message + if session_link: + final_message += f"\n\n<{session_link}|Open session>" if progress_ts and progress_mode in {"stream", "message"}: try: await slack_finish_progress( - token, channel, progress_ts, progress_mode, message + token, channel, progress_ts, progress_mode, final_message ) except Exception: await slack_delete(token, channel, progress_ts) @@ -190,7 +210,7 @@ async def _send_slack( slack_progress_ts="", ) else: - await slack_send(token, channel, thread_ts, message) + await slack_send(token, channel, thread_ts, final_message) append_chat_history("slack", channel, message) return {"adapter": "slack", "channel": channel} diff --git a/test/test_notify_progress.py b/test/test_notify_progress.py index 1d62dfa6..02118d79 100644 --- a/test/test_notify_progress.py +++ b/test/test_notify_progress.py @@ -16,6 +16,7 @@ def route(**changes): "thread_ts": "1000.1", "slack_progress_mode": "stream", "slack_progress_ts": "2000.1", + "slack_session_link": "https://lodge.test/tui?session=n00b-session-1", **changes, } @@ -71,7 +72,9 @@ async def test_exact_session_route_replaces_progress_once_and_clears( assert result["adapter"] == "slack" finish.assert_awaited_once_with( - "xoxb-test", "D123", "2000.1", mode, "🦝 n00b: final answer" + "xoxb-test", "D123", "2000.1", mode, + "🦝 n00b: final answer\n\n" + "" ) update.assert_called_once_with( "n00b-session-1", wolt="n00b", @@ -80,6 +83,30 @@ async def test_exact_session_route_replaces_progress_once_and_clears( assert "final answer" not in str(update.call_args) +@pytest.mark.asyncio +async def test_invalid_or_cross_session_link_is_not_attached(monkeypatch): + routing = route( + slack_progress_mode="message", + slack_session_link="https://evil.test/tui?session=other-session", + ) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + finish = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_finish_progress", finish) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + monkeypatch.setattr( + notify, "SessionRegistry", lambda root: Mock(update=Mock()) + ) + + await notify._send_slack( + "final", "D123", "1000.1", + session="n00b-session-1", routing=routing, + ) + + finish.assert_awaited_once_with( + "xoxb-test", "D123", "2000.1", "message", "final" + ) + + @pytest.mark.asyncio async def test_route_mismatch_cannot_claim_progress(monkeypatch): monkeypatch.setattr(notify, "read_session_registry", lambda session: route()) diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index c2a6ccf0..b2b043a4 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -172,11 +172,17 @@ async def test_streaming_failure_uses_plain_postmessage_fallback(): @pytest.mark.asyncio -async def test_session_route_reports_successful_revival(monkeypatch, tmp_path): +async def test_session_route_posts_fresh_progress_without_sent_receipt(monkeypatch, tmp_path): async def revived(*args): - return {"ok": True, "status": "revived", "url": "https://session.test"} - - monkeypatch.setattr(slack.asyncio, "to_thread", revived) + return { + "ok": True, + "status": "revived", + "url": "https://lodge.test/tui?session=n00b-old-1", + } + + monkeypatch.setattr(slack, "message_session", Mock(return_value=await revived())) + monkeypatch.setattr(slack.registry, "update", Mock()) + monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) client = AsyncMock() client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} @@ -184,8 +190,49 @@ async def revived(*args): await slack._route_to_session(client, "D1", "1000.1", owner, "continue") - sent = client.chat_postMessage.await_args.kwargs["text"] - assert "revived and delivered" in sent + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", text="🦫 Working…" + ) + assert all("sent" not in str(call) for call in client.method_calls) + client.chat_update.assert_awaited_once_with( + channel="D1", ts="2000.1", + text=( + "🦫 Working… " + "" + ), + ) + + +@pytest.mark.asyncio +async def test_session_route_keeps_known_link_on_fresh_bottom_progress(monkeypatch, tmp_path): + async def delivered(*args): + return { + "ok": True, + "status": "delivered", + "url": "https://lodge.test/tui?session=n00b-old-1", + } + + monkeypatch.setattr(slack, "message_session", Mock(return_value=await delivered())) + monkeypatch.setattr(slack.registry, "update", Mock()) + monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} + owner = { + "session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon", + "session_link": "https://lodge.test/tui?session=n00b-old-1", + } + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + client.chat_postMessage.assert_awaited_once_with( + channel="D1", thread_ts="1000.1", + text=( + "🦫 Working… " + "" + ), + ) + client.chat_update.assert_not_awaited() @pytest.mark.asyncio @@ -193,13 +240,16 @@ async def test_dead_session_releases_thread_back_to_dog(monkeypatch, tmp_path): async def dead(*args): return {"ok": False, "error": "gone"} - monkeypatch.setattr(slack.asyncio, "to_thread", dead) + monkeypatch.setattr(slack, "message_session", Mock(return_value=await dead())) + monkeypatch.setattr(slack.registry, "update", Mock()) + monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "owners.json") monkeypatch.setattr(slack, "_thread_sessions", { "D1:1000.1": {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} }) client = AsyncMock() + client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = slack._thread_sessions["D1:1000.1"] await slack._route_to_session(client, "D1", "1000.1", owner, "continue") From a1e7225cc1211ae01c1aebbad87a04ac8f1bc14e Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 00:47:51 -0400 Subject: [PATCH 09/13] theme Slack progress as gnawing --- container/bot/slack_adapter.py | 40 +++++++++++++---------- test/test_slack_adapter.py | 58 +++++++++++++++++++++++++--------- 2 files changed, 67 insertions(+), 31 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 3ed3e409..9663a208 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -64,12 +64,12 @@ PENDING_TTL_SECONDS = 600 PENDING_MAX_PER_OWNER = 5 PENDING_MAX_BYTES = 32 * 1024 -PROGRESS_PULSE_INTERVAL_SECONDS = 4 +PROGRESS_PULSE_INTERVAL_SECONDS = 2 PROGRESS_PULSE_FRAMES = ( - "🦫 Working ·", - "🦫 Working ··", - "🦫 Working ···", - "🦫 Working ··", + "🦫 Gnawing ·", + "🦫 Gnawing ··", + "🦫 Gnawing ···", + "🦫 Gnawing ··", ) @@ -596,10 +596,16 @@ async def _clear_failed_progress(client, session_name: str, wolt: str, ) -async def _watch_progress(client, session_name: str, wolt: str, channel: str, - session_link: str = ""): +def _progress_link(session_name: str, record: dict) -> str: + """Revalidate the latest link so a pulse never restores stale link state.""" + return _session_link({ + "name": session_name, + "url": record.get("slack_session_link", ""), + }) + + +async def _watch_progress(client, session_name: str, wolt: str, channel: str): """Bounded honest liveness updates; final `/notify` owns completion.""" - suffix = f" <{session_link}|Open session>" if session_link else "" for frame in PROGRESS_PULSE_FRAMES: await asyncio.sleep(PROGRESS_PULSE_INTERVAL_SECONDS) record = await _progress_record(session_name) @@ -608,6 +614,8 @@ async def _watch_progress(client, session_name: str, wolt: str, channel: str, if record.get("status") != "running": await _clear_failed_progress(client, session_name, wolt, channel, record) return + session_link = _progress_link(session_name, record) + suffix = f" <{session_link}|Open session>" if session_link else "" try: await client.chat_update( channel=channel, ts=record["slack_progress_ts"], @@ -622,11 +630,13 @@ async def _watch_progress(client, session_name: str, wolt: str, channel: str, if not record: return if record.get("status") == "running": + session_link = _progress_link(session_name, record) + suffix = f" <{session_link}|Open session>" if session_link else "" try: await client.chat_update( channel=channel, ts=record["slack_progress_ts"], - text=f"🦫 Still working…{suffix}", + text=f"🦫 Still gnawing…{suffix}", ) except Exception as exc: logger.info("Stopping Slack progress heartbeat: %s", exc) @@ -668,12 +678,10 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: link_suffix = f" <{session_link}|Open session>" if session_link else "" await client.chat_update( channel=channel, ts=picker_ts, - text=f"🌱 {selected['name']} session ready. 🦫 Working…{link_suffix}", blocks=[], + text=f"🌱 {selected['name']} session ready. 🦫 Gnawing…{link_suffix}", blocks=[], ) watcher = asyncio.create_task( - _watch_progress( - client, session["name"], selected["name"], channel, session_link - ) + _watch_progress(client, session["name"], selected["name"], channel) ) _progress_watchers.add(watcher) watcher.add_done_callback(_progress_watchers.discard) @@ -736,7 +744,7 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t progress = await client.chat_postMessage( channel=channel, thread_ts=thread_ts, - text=f"🦫 Working…{link_suffix}", + text=f"🦫 Gnawing…{link_suffix}", ) progress_ts = progress.get("ts", "") if progress_ts: @@ -746,7 +754,7 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t slack_session_link=session_link, ) watcher = asyncio.create_task( - _watch_progress(client, session_name, wolt, channel, session_link) + _watch_progress(client, session_name, wolt, channel) ) _progress_watchers.add(watcher) watcher.add_done_callback(_progress_watchers.discard) @@ -779,7 +787,7 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t ) await client.chat_update( channel=channel, ts=progress_ts, - text=f"🦫 Working… <{session_link}|Open session>", + text=f"🦫 Gnawing… <{session_link}|Open session>", ) _append_message(channel, thread_ts, { "role": "assistant", diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index b2b043a4..705ea70c 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -191,13 +191,13 @@ async def revived(*args): await slack._route_to_session(client, "D1", "1000.1", owner, "continue") client.chat_postMessage.assert_awaited_once_with( - channel="D1", thread_ts="1000.1", text="🦫 Working…" + channel="D1", thread_ts="1000.1", text="🦫 Gnawing…" ) assert all("sent" not in str(call) for call in client.method_calls) client.chat_update.assert_awaited_once_with( channel="D1", ts="2000.1", text=( - "🦫 Working… " + "🦫 Gnawing… " "" ), ) @@ -228,7 +228,7 @@ async def delivered(*args): client.chat_postMessage.assert_awaited_once_with( channel="D1", thread_ts="1000.1", text=( - "🦫 Working… " + "🦫 Gnawing… " "" ), ) @@ -484,31 +484,59 @@ async def test_progress_pulses_four_times_then_uses_thirty_second_heartbeat(monk sleep = AsyncMock() monkeypatch.setattr(slack.asyncio, "sleep", sleep) monkeypatch.setattr(slack.registry, "get", Mock(return_value={ - "status": "running", "slack_progress_ts": "2000.1" + "status": "running", "slack_progress_ts": "2000.1", + "slack_session_link": "https://lodge.test/tui?session=builder-session-1", })) client = AsyncMock() client.chat_update.side_effect = [None, None, None, None, RuntimeError("rate limited")] - await slack._watch_progress( - client, "builder-session-1", "builder", "D123", - "https://lodge.test/tui?session=builder-session-1", - ) + await slack._watch_progress(client, "builder-session-1", "builder", "D123") - assert [call.args[0] for call in sleep.await_args_list] == [4, 4, 4, 4, 30] + assert [call.args[0] for call in sleep.await_args_list] == [2, 2, 2, 2, 30] assert client.chat_update.await_count == 5 texts = [call.kwargs["text"] for call in client.chat_update.await_args_list] assert texts[:4] == [ - "🦫 Working · ", - "🦫 Working ·· ", - "🦫 Working ··· ", - "🦫 Working ·· ", + "🦫 Gnawing · ", + "🦫 Gnawing ·· ", + "🦫 Gnawing ··· ", + "🦫 Gnawing ·· ", ] assert texts[4] == ( - "🦫 Still working… " + "🦫 Still gnawing… " "" ) +@pytest.mark.asyncio +async def test_progress_reads_newly_discovered_link_on_every_frame(monkeypatch): + monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) + records = [ + { + "status": "running", "slack_progress_ts": "2000.1", + "slack_session_link": "", + }, + { + "status": "running", "slack_progress_ts": "2000.1", + "slack_session_link": ( + "https://lodge.test/tui?session=builder-session-1" + ), + }, + ] + monkeypatch.setattr( + slack.registry, "get", + Mock(side_effect=[records[0], records[1], records[1]]), + ) + client = AsyncMock() + client.chat_update.side_effect = [None, None, RuntimeError("stop")] + + await slack._watch_progress(client, "builder-session-1", "builder", "D123") + + texts = [call.kwargs["text"] for call in client.chat_update.await_args_list] + assert "Open session" not in texts[0] + assert "" in texts[1] + assert "" in texts[2] + + @pytest.mark.parametrize("session, expected", [ ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1"}, "https://lodge.test/tui?session=n00b-1"), @@ -566,7 +594,7 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): } assert slack._thread_sessions["D1:1000.1"]["session"] == "n00b-session-1" assert client.chat_update.await_args_list[0].kwargs["text"].startswith("✅ Accepted") - assert "🦫 Working" in client.chat_update.await_args_list[1].kwargs["text"] + assert "🦫 Gnawing" in client.chat_update.await_args_list[1].kwargs["text"] assert "" in ( client.chat_update.await_args_list[1].kwargs["text"] ) From 0937dbb1c5ecb2c500af568a9ec092c308016372 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:01:01 -0400 Subject: [PATCH 10/13] add native Slack agent status --- container/bot/slack_adapter.py | 88 +++++++++++++++++++++++++++------- server/notify.py | 41 +++++++++++++++- test/test_notify_progress.py | 33 +++++++++++++ test/test_slack_adapter.py | 37 ++++++++++++++ 4 files changed, 180 insertions(+), 19 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 9663a208..04dbac08 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -584,6 +584,26 @@ async def _progress_record(session_name: str) -> dict | None: return record +async def _set_agent_status(client, channel: str, thread_ts: str, + status: str) -> bool: + """Use Slack's native Agent Session status when the app is configured. + + Agent View is an external app setting. Fail soft so the same candidate + keeps working before the owner enables/reinstalls it, or if Slack is + temporarily unable to create the native session. + """ + try: + response = await client.agents_sessions_setStatus( + channel_id=channel, + thread_ts=thread_ts, + status=status, + ) + return bool(response.get("ok", True)) + except Exception as exc: + logger.info("Slack native agent status unavailable; using message progress: %s", exc) + return False + + async def _clear_failed_progress(client, session_name: str, wolt: str, channel: str, record: dict) -> None: try: @@ -669,13 +689,24 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: channel, root_ts, session["name"], selected["name"], selected.get("type", ""), session_link, ) + _pending_finish(channel, root_ts, "consumed", session["name"]) + link_suffix = f" <{session_link}|Open session>" if session_link else "" + if await _set_agent_status(client, channel, root_ts, "processing"): + await asyncio.to_thread( + registry.update, session["name"], wolt=selected["name"], + slack_progress_mode="agent", slack_progress_ts="", + slack_session_link=session_link, + ) + await client.chat_update( + channel=channel, ts=picker_ts, + text=f"🌱 {selected['name']} session ready.{link_suffix}", blocks=[], + ) + return await asyncio.to_thread( registry.update, session["name"], wolt=selected["name"], slack_progress_mode="message", slack_progress_ts=picker_ts, slack_session_link=session_link, ) - _pending_finish(channel, root_ts, "consumed", session["name"]) - link_suffix = f" <{session_link}|Open session>" if session_link else "" await client.chat_update( channel=channel, ts=picker_ts, text=f"🌱 {selected['name']} session ready. 🦫 Gnawing…{link_suffix}", blocks=[], @@ -738,26 +769,36 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t session_link = owner.get("session_link", "") link_suffix = f" <{session_link}|Open session>" if session_link else "" - # Every human turn gets a fresh bottom-of-thread surface. The session's - # final /notify replaces this exact message, so liveness never drifts away - # from the newest turn and the thread does not accumulate status chatter. - progress = await client.chat_postMessage( - channel=channel, - thread_ts=thread_ts, - text=f"🦫 Gnawing…{link_suffix}", + native_agent_status = await _set_agent_status( + client, channel, thread_ts, "processing" ) - progress_ts = progress.get("ts", "") - if progress_ts: + progress_ts = "" + if native_agent_status: await asyncio.to_thread( registry.update, session_name, wolt=wolt, - slack_progress_mode="message", slack_progress_ts=progress_ts, + slack_progress_mode="agent", slack_progress_ts="", slack_session_link=session_link, ) - watcher = asyncio.create_task( - _watch_progress(client, session_name, wolt, channel) + else: + # Every human turn gets a fresh bottom-of-thread fallback. The final + # /notify replaces this exact message when Agent View is unavailable. + progress = await client.chat_postMessage( + channel=channel, + thread_ts=thread_ts, + text=f"🦫 Gnawing…{link_suffix}", ) - _progress_watchers.add(watcher) - watcher.add_done_callback(_progress_watchers.discard) + progress_ts = progress.get("ts", "") + if progress_ts: + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="message", slack_progress_ts=progress_ts, + slack_session_link=session_link, + ) + watcher = asyncio.create_task( + _watch_progress(client, session_name, wolt, channel) + ) + _progress_watchers.add(watcher) + watcher.add_done_callback(_progress_watchers.discard) session_msg = ( f"[slack message from human, channel={channel}, thread={thread_ts}]: {text}\n" @@ -780,7 +821,12 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t _set_session_owner( channel, thread_ts, session_name, wolt, creature, session_link ) - if progress_ts: + if native_agent_status: + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_session_link=session_link, + ) + elif progress_ts: await asyncio.to_thread( registry.update, session_name, wolt=wolt, slack_session_link=session_link, @@ -795,7 +841,13 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t }) else: error = result.get("error", "unknown error") - if progress_ts: + if native_agent_status: + await _set_agent_status(client, channel, thread_ts, "active") + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="", slack_progress_ts="", + ) + elif progress_ts: try: await client.chat_delete(channel=channel, ts=progress_ts) except Exception as exc: diff --git a/server/notify.py b/server/notify.py index cb786c2d..6b126ca2 100644 --- a/server/notify.py +++ b/server/notify.py @@ -5,6 +5,7 @@ """ import json +import logging import urllib.parse from pathlib import Path @@ -20,6 +21,8 @@ ) from .state import sanitize_session +logger = logging.getLogger(__name__) + class NoNotificationTarget(RuntimeError): """Nowhere to deliver to — a configuration gap, not a platform failure. @@ -147,6 +150,25 @@ async def slack_delete(token: str, channel: str, message_ts: str) -> None: ) +async def slack_set_agent_status( + token: str, channel: str, thread_ts: str, status: str +) -> dict: + async with httpx.AsyncClient() as client: + response = await client.post( + "https://slack.com/api/agents.sessions.setStatus", + json={ + "channel_id": channel, + "thread_ts": thread_ts, + "status": status, + }, + headers={"Authorization": f"Bearer {token}"}, + ) + data = response.json() + if not data.get("ok"): + raise RuntimeError(data.get("error", "agents.sessions.setStatus error")) + return data + + async def _send_telegram(session: str, message: str, chat_id: str) -> dict: """Send a notification via Telegram with den-reply footer.""" token = dotenv_env("TELEGRAM_BOT_TOKEN") @@ -193,7 +215,24 @@ async def _send_slack( final_message = message if session_link: final_message += f"\n\n<{session_link}|Open session>" - if progress_ts and progress_mode in {"stream", "message"}: + if progress_mode == "agent" and thread_ts: + try: + await slack_send(token, channel, thread_ts, final_message) + finally: + try: + await slack_set_agent_status( + token, channel, thread_ts, "active" + ) + except Exception as exc: + logger.warning("Could not clear Slack native agent status: %s", exc) + if session and routing: + SessionRegistry(WOLTS_DIR).update( + session, + wolt=routing.get("wolt", ""), + slack_progress_mode="", + slack_progress_ts="", + ) + elif progress_ts and progress_mode in {"stream", "message"}: try: await slack_finish_progress( token, channel, progress_ts, progress_mode, final_message diff --git a/test/test_notify_progress.py b/test/test_notify_progress.py index 02118d79..b78ec552 100644 --- a/test/test_notify_progress.py +++ b/test/test_notify_progress.py @@ -107,6 +107,39 @@ async def test_invalid_or_cross_session_link_is_not_attached(monkeypatch): ) +@pytest.mark.asyncio +async def test_native_agent_progress_posts_final_then_returns_active(monkeypatch): + routing = route( + slack_progress_mode="agent", + slack_progress_ts="", + ) + monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") + send = AsyncMock(return_value={"ok": True}) + status = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "slack_set_agent_status", status) + monkeypatch.setattr(notify, "append_chat_history", Mock()) + update = Mock() + monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) + + await notify._send_slack( + "final", "D123", "1000.1", + session="n00b-session-1", routing=routing, + ) + + send.assert_awaited_once_with( + "xoxb-test", "D123", "1000.1", + "final\n\n", + ) + status.assert_awaited_once_with( + "xoxb-test", "D123", "1000.1", "active" + ) + update.assert_called_once_with( + "n00b-session-1", wolt="n00b", + slack_progress_mode="", slack_progress_ts="", + ) + + @pytest.mark.asyncio async def test_route_mismatch_cannot_claim_progress(monkeypatch): monkeypatch.setattr(notify, "read_session_registry", lambda session: route()) diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index 705ea70c..f4e86da6 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -149,6 +149,7 @@ def action(self, action_id): @pytest.mark.asyncio async def test_streaming_success_stops_the_stream_without_posting_fallback(): client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} client.chat_startStream.return_value = {"ok": True, "ts": "2000.1"} @@ -185,6 +186,7 @@ async def revived(*args): monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} @@ -217,6 +219,7 @@ async def delivered(*args): monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = { "session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon", @@ -249,6 +252,7 @@ async def dead(*args): "D1:1000.1": {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} }) client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = slack._thread_sessions["D1:1000.1"] @@ -258,6 +262,38 @@ async def dead(*args): assert "send again" in client.chat_postMessage.await_args.kwargs["text"] +@pytest.mark.asyncio +async def test_native_agent_status_replaces_custom_progress(monkeypatch, tmp_path): + result = { + "ok": True, + "status": "delivered", + "url": "https://lodge.test/tui?session=n00b-old-1", + } + monkeypatch.setattr(slack, "message_session", Mock(return_value=result)) + update = Mock() + monkeypatch.setattr(slack.registry, "update", update) + monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) + client = AsyncMock() + client.agents_sessions_setStatus.return_value = { + "ok": True, "status": "processing", "agent_status": "processing" + } + owner = { + "session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon", + "session_link": "https://lodge.test/tui?session=n00b-old-1", + } + + await slack._route_to_session(client, "D1", "1000.1", owner, "continue") + + client.agents_sessions_setStatus.assert_awaited_once_with( + channel_id="D1", thread_ts="1000.1", status="processing" + ) + client.chat_postMessage.assert_not_awaited() + assert any( + call.kwargs.get("slack_progress_mode") == "agent" + for call in update.call_args_list + ) + + def test_selection_storage_is_atomic_private_and_owner_keyed(monkeypatch, tmp_path): monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) path = tmp_path / "_owner_selections.json" @@ -577,6 +613,7 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): monkeypatch.setattr(slack.registry, "update", update) monkeypatch.setattr(slack, "_watch_progress", watcher) client = AsyncMock() + client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") selected = {"name": "n00b", "type": "raccoon"} claimed = { "user": "U12345678", "channel": "D1", "root_ts": "1000.1", From 05727c6d7b99840232b6c3303bf5f945fe413ba2 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:14:29 -0400 Subject: [PATCH 11/13] title Slack sessions with Woltspace slugs --- container/bot/slack_adapter.py | 17 ++++++++++++----- test/test_slack_adapter.py | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 04dbac08..2d67c7b7 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -585,7 +585,7 @@ async def _progress_record(session_name: str) -> dict | None: async def _set_agent_status(client, channel: str, thread_ts: str, - status: str) -> bool: + status: str, *, title: str = "") -> bool: """Use Slack's native Agent Session status when the app is configured. Agent View is an external app setting. Fail soft so the same candidate @@ -593,10 +593,15 @@ async def _set_agent_status(client, channel: str, thread_ts: str, temporarily unable to create the native session. """ try: + arguments = { + "channel_id": channel, + "thread_ts": thread_ts, + "status": status, + } + if title: + arguments["title"] = title response = await client.agents_sessions_setStatus( - channel_id=channel, - thread_ts=thread_ts, - status=status, + **arguments, ) return bool(response.get("ok", True)) except Exception as exc: @@ -691,7 +696,9 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: ) _pending_finish(channel, root_ts, "consumed", session["name"]) link_suffix = f" <{session_link}|Open session>" if session_link else "" - if await _set_agent_status(client, channel, root_ts, "processing"): + if await _set_agent_status( + client, channel, root_ts, "processing", title=session["name"] + ): await asyncio.to_thread( registry.update, session["name"], wolt=selected["name"], slack_progress_mode="agent", slack_progress_ts="", diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index f4e86da6..53197e67 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -637,6 +637,33 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): ) +@pytest.mark.asyncio +async def test_native_session_creation_uses_exact_woltspace_slug_as_title(monkeypatch): + session = { + "name": "n00b-muddy-pine-211c27", + "url": "https://lodge.test/tui?session=n00b-muddy-pine-211c27", + } + monkeypatch.setattr(slack, "start_claude_session", Mock(return_value=session)) + monkeypatch.setattr(slack.registry, "update", Mock()) + client = AsyncMock() + client.agents_sessions_setStatus.return_value = {"ok": True} + selected = {"name": "n00b", "type": "raccoon"} + claimed = { + "user": "U12345678", "channel": "D1", "root_ts": "1000.1", + "picker_ts": "2000.1", "text": "original task", + } + + await slack._spawn_pending(client, selected, claimed) + + client.agents_sessions_setStatus.assert_awaited_once_with( + channel_id="D1", + thread_ts="1000.1", + status="processing", + title="n00b-muddy-pine-211c27", + ) + assert "Gnawing" not in client.chat_update.await_args_list[1].kwargs["text"] + + @pytest.mark.asyncio async def test_historical_owned_thread_is_not_retargeted_after_selection(monkeypatch): app = install_fake_app(monkeypatch) From f4264430715f5a9b6d6fcec1a5b265b10b7eca05 Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:23:03 -0400 Subject: [PATCH 12/13] make Agent View the canonical Slack setup --- container/bot/slack_adapter.py | 156 ++--------------- container/skills/setup-slack/SKILL.md | 131 ++++++++++++++ .../setup-slack/references/manifest.json | 41 +++++ docs/slack-dm-agent-mvp.md | 103 ++++++----- server/notify.py | 54 ------ test/test_notify_progress.py | 107 +----------- test/test_slack_adapter.py | 162 ++---------------- 7 files changed, 271 insertions(+), 483 deletions(-) create mode 100644 container/skills/setup-slack/SKILL.md create mode 100644 container/skills/setup-slack/references/manifest.json diff --git a/container/bot/slack_adapter.py b/container/bot/slack_adapter.py index 2d67c7b7..5d416a21 100644 --- a/container/bot/slack_adapter.py +++ b/container/bot/slack_adapter.py @@ -64,13 +64,6 @@ PENDING_TTL_SECONDS = 600 PENDING_MAX_PER_OWNER = 5 PENDING_MAX_BYTES = 32 * 1024 -PROGRESS_PULSE_INTERVAL_SECONDS = 2 -PROGRESS_PULSE_FRAMES = ( - "🦫 Gnawing ·", - "🦫 Gnawing ··", - "🦫 Gnawing ···", - "🦫 Gnawing ··", -) def _dog_name() -> str: @@ -331,7 +324,6 @@ def mutate(data): _SEEN_EVENT_LIMIT = 2048 _seen_event_ids: set[str] = set() _seen_event_order: deque[str] = deque() -_progress_watchers: set[asyncio.Task] = set() # --- Helpers --- @@ -577,21 +569,9 @@ def _session_link(session: dict) -> str: return url -async def _progress_record(session_name: str) -> dict | None: - record = await asyncio.to_thread(registry.get, session_name, check_alive=False) - if not record or not record.get("slack_progress_ts"): - return None - return record - - async def _set_agent_status(client, channel: str, thread_ts: str, status: str, *, title: str = "") -> bool: - """Use Slack's native Agent Session status when the app is configured. - - Agent View is an external app setting. Fail soft so the same candidate - keeps working before the owner enables/reinstalls it, or if Slack is - temporarily unable to create the native session. - """ + """Set the supported native Slack Agent Session status.""" try: arguments = { "channel_id": channel, @@ -605,72 +585,10 @@ async def _set_agent_status(client, channel: str, thread_ts: str, ) return bool(response.get("ok", True)) except Exception as exc: - logger.info("Slack native agent status unavailable; using message progress: %s", exc) + logger.error("Slack native agent status unavailable: %s", exc) return False -async def _clear_failed_progress(client, session_name: str, wolt: str, - channel: str, record: dict) -> None: - try: - await client.chat_delete(channel=channel, ts=record["slack_progress_ts"]) - except Exception as exc: - logger.info("Could not remove failed Slack progress message: %s", exc) - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_progress_mode="", slack_progress_ts="", - ) - - -def _progress_link(session_name: str, record: dict) -> str: - """Revalidate the latest link so a pulse never restores stale link state.""" - return _session_link({ - "name": session_name, - "url": record.get("slack_session_link", ""), - }) - - -async def _watch_progress(client, session_name: str, wolt: str, channel: str): - """Bounded honest liveness updates; final `/notify` owns completion.""" - for frame in PROGRESS_PULSE_FRAMES: - await asyncio.sleep(PROGRESS_PULSE_INTERVAL_SECONDS) - record = await _progress_record(session_name) - if not record: - return - if record.get("status") != "running": - await _clear_failed_progress(client, session_name, wolt, channel, record) - return - session_link = _progress_link(session_name, record) - suffix = f" <{session_link}|Open session>" if session_link else "" - try: - await client.chat_update( - channel=channel, ts=record["slack_progress_ts"], - text=f"{frame}{suffix}", - ) - except Exception as exc: - logger.info("Stopping Slack progress pulse: %s", exc) - return - for _ in range(10): - await asyncio.sleep(30) - record = await _progress_record(session_name) - if not record: - return - if record.get("status") == "running": - session_link = _progress_link(session_name, record) - suffix = f" <{session_link}|Open session>" if session_link else "" - try: - await client.chat_update( - channel=channel, - ts=record["slack_progress_ts"], - text=f"🦫 Still gnawing…{suffix}", - ) - except Exception as exc: - logger.info("Stopping Slack progress heartbeat: %s", exc) - return - continue - await _clear_failed_progress(client, session_name, wolt, channel, record) - return - - async def _spawn_pending(client, selected: dict, claimed: dict) -> None: channel, root_ts = claimed["channel"], claimed["root_ts"] picker_ts, user = claimed["picker_ts"], claimed["user"] @@ -711,18 +629,15 @@ async def _spawn_pending(client, selected: dict, claimed: dict) -> None: return await asyncio.to_thread( registry.update, session["name"], wolt=selected["name"], - slack_progress_mode="message", slack_progress_ts=picker_ts, + slack_progress_mode="", slack_progress_ts="", slack_session_link=session_link, ) await client.chat_update( channel=channel, ts=picker_ts, - text=f"🌱 {selected['name']} session ready. 🦫 Gnawing…{link_suffix}", blocks=[], - ) - watcher = asyncio.create_task( - _watch_progress(client, session["name"], selected["name"], channel) + text=(f"🌱 {selected['name']} session ready, but Slack Agent View is not " + f"authorized. Reinstall with `assistant:write`.{link_suffix}"), + blocks=[], ) - _progress_watchers.add(watcher) - watcher.add_done_callback(_progress_watchers.discard) async def _post_result(client, channel: str, thread_ts: str, user: str, result: dict): @@ -779,7 +694,6 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t native_agent_status = await _set_agent_status( client, channel, thread_ts, "processing" ) - progress_ts = "" if native_agent_status: await asyncio.to_thread( registry.update, session_name, wolt=wolt, @@ -787,25 +701,13 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t slack_session_link=session_link, ) else: - # Every human turn gets a fresh bottom-of-thread fallback. The final - # /notify replaces this exact message when Agent View is unavailable. - progress = await client.chat_postMessage( + await client.chat_postMessage( channel=channel, thread_ts=thread_ts, - text=f"🦫 Gnawing…{link_suffix}", + text=("Slack Agent View is not authorized. Reinstall with " + f"`assistant:write` before retrying.{link_suffix}"), ) - progress_ts = progress.get("ts", "") - if progress_ts: - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_progress_mode="message", slack_progress_ts=progress_ts, - slack_session_link=session_link, - ) - watcher = asyncio.create_task( - _watch_progress(client, session_name, wolt, channel) - ) - _progress_watchers.add(watcher) - watcher.add_done_callback(_progress_watchers.discard) + return session_msg = ( f"[slack message from human, channel={channel}, thread={thread_ts}]: {text}\n" @@ -828,41 +730,21 @@ async def _route_to_session(client, channel: str, thread_ts: str, owner: dict, t _set_session_owner( channel, thread_ts, session_name, wolt, creature, session_link ) - if native_agent_status: - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_session_link=session_link, - ) - elif progress_ts: - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_session_link=session_link, - ) - await client.chat_update( - channel=channel, ts=progress_ts, - text=f"🦫 Gnawing… <{session_link}|Open session>", - ) + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_session_link=session_link, + ) _append_message(channel, thread_ts, { "role": "assistant", "content": f"[delivered to session {session_name}]", }) else: error = result.get("error", "unknown error") - if native_agent_status: - await _set_agent_status(client, channel, thread_ts, "active") - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_progress_mode="", slack_progress_ts="", - ) - elif progress_ts: - try: - await client.chat_delete(channel=channel, ts=progress_ts) - except Exception as exc: - logger.info("Could not remove failed Slack progress message: %s", exc) - await asyncio.to_thread( - registry.update, session_name, wolt=wolt, - slack_progress_mode="", slack_progress_ts="", - ) + await _set_agent_status(client, channel, thread_ts, "active") + await asyncio.to_thread( + registry.update, session_name, wolt=wolt, + slack_progress_mode="", slack_progress_ts="", + ) # Return the thread to the dog so the next owner message can recover. _thread_sessions.pop(_thread_key(channel, thread_ts), None) _save_thread_sessions() diff --git a/container/skills/setup-slack/SKILL.md b/container/skills/setup-slack/SKILL.md new file mode 100644 index 00000000..78c1011a --- /dev/null +++ b/container/skills/setup-slack/SKILL.md @@ -0,0 +1,131 @@ +--- +name: setup-slack +description: Connect a private Woltspace lodge to its owner's Slack with the supported owner-DM Agent View app and Socket Mode flow. +--- + +# Slack Setup + +Guide the owner through one supported setup: one owner-controlled Slack app per +lodge, Agent View, Socket Mode, and one immutable owner member ID. Work one +step at a time and stop for the owner at Slack UI boundaries. + +This skill is idempotent. Inspect existing configuration without printing +tokens; preserve unrelated app settings and lodge configuration. + +## Boundaries + +- This connector is for direct messages from one configured owner. Do not add + channel events, mentions, other users, or a first-sender fallback. +- Do not resurrect the legacy channel/mention bot or custom progress-message + setup. Native Slack Agent Sessions are the supported experience. +- Never ask the owner to paste `xoxb-` or `xapp-` secrets into Slack. Have them + enter secrets on their lodge machine, or use an already-approved private + configuration surface. +- Creating or changing the Slack app, reinstalling it, writing secrets, and + restarting the lodge are separate external/local mutations. Explain the + next mutation and obtain approval when the current request did not already + authorize it. +- Never expose token values in commands, logs, validation output, or summaries. + +## 1. Inspect without secrets + +Find the active Woltspace data root and its +`.space/platform/config.json`. Report only whether `channels.slack` exists, +whether it is enabled, and which required fields are present. Check +`woltspace status` for the Slack connector. Do not print the configuration +object or environment. + +If Slack is already healthy, ask the owner to send a DM and validate the +acceptance flow instead of rebuilding the app. + +## 2. Create or migrate the Slack app + +Read [references/manifest.json](references/manifest.json) and give that exact +manifest to the owner as a formatted code block. Existing apps should be +updated to this manifest rather than creating a second app. + +The intentional surface is: + +- Agent View with the description `gnaw. build. repeat` +- bot scopes `assistant:write`, `chat:write`, and `im:history` +- bot events `app_home_opened` and `message.im` +- Interactivity and Socket Mode enabled + +Do not add `app_mentions:read`, channel/group history, channel/group message +events, file scopes, `chat:write.customize`, or `agent_session_stopped`. +Those belong to separately implemented features. + +After the owner saves the manifest, have them reinstall the app to the +workspace so the new bot scopes take effect. + +## 3. Create the Socket Mode token + +In the Slack app's **Basic Information → App-Level Tokens**, create a token +with scope `connections:write`. This produces the private `xapp-` token. +The workspace installation produces the private `xoxb-` bot token. + +Have the owner copy their immutable Slack member ID from **Profile → More → +Copy member ID**. It must begin with `U` or `W`; never infer it from the first +message. + +## 4. Configure the lodge + +Merge this object into `.space/platform/config.json`, preserving every +unrelated key: + +```json +{ + "channels": { + "slack": { + "enabled": true, + "bot_token": "", + "app_token": "", + "owner_user": "" + } + } +} +``` + +Keep the file private. Validate only token prefixes, owner-ID shape, and field +presence; never echo the values. The legacy `ENABLE_SLACK_BOT` and +`SLACK_*` environment path is migration input, not the canonical setup. With +owner approval, move those values into `channels.slack`, verify the connector, +then remove the redundant legacy entries. + +## 5. Activate and prove + +With restart approval, record `woltspace status` and the current tmux session +count, restart through the normal Woltspace lifecycle, then verify: + +- lodge health and the Slack connector are `running`; +- all pre-existing tmux sessions survived; +- the owner can DM the app, choose a wolt, and have the original message + delivered exactly once; +- Slack shows native processing state and the Agent Session title becomes the + exact Woltspace session slug; +- the final reply retains **Open session** and status returns to active; +- a follow-up in the same thread stays pinned to the same Woltspace session. + +Do not probe with a non-owner account or post into channels unless the owner +explicitly supplies and authorizes that test. Report that those inputs fail +closed by design. + +## Troubleshooting + +- `not_authorized` from `agents.sessions.setStatus`: confirm Agent View, + `assistant:write`, Save, and workspace reinstall. +- connector disabled: check all four `channels.slack` fields and that the owner + ID begins with `U` or `W`. +- Socket Mode does not connect: confirm the app-level token has + `connections:write` and Socket Mode is enabled. +- replies work but no native processing state: the app is still on the legacy + surface; migrate it instead of enabling message-progress fallback. + +Slack images, native Stop, custom per-wolt identity, shared OAuth installation, +and channel participation are not part of setup. Treat each as a separate +product change with its own scopes and acceptance test. + +The exact session slug is the current stable title, not the final naming UX. +Woltspace should later own a human-friendly session name for browsing and sync +that name into Slack. Slack remains a presentation surface, not the source of +truth for session identity or naming. diff --git a/container/skills/setup-slack/references/manifest.json b/container/skills/setup-slack/references/manifest.json new file mode 100644 index 00000000..3116891b --- /dev/null +++ b/container/skills/setup-slack/references/manifest.json @@ -0,0 +1,41 @@ +{ + "display_information": { + "name": "Woltspace", + "description": "Woltspace", + "background_color": "#3A6644" + }, + "features": { + "agent_view": { + "agent_description": "gnaw. build. repeat" + }, + "bot_user": { + "display_name": "Woltspace", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "assistant:write", + "chat:write", + "im:history" + ] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": [ + "app_home_opened", + "message.im" + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "app_level_token_rotation_enabled": false, + "is_mcp_enabled": false + } +} diff --git a/docs/slack-dm-agent-mvp.md b/docs/slack-dm-agent-mvp.md index c89b0a2e..39800726 100644 --- a/docs/slack-dm-agent-mvp.md +++ b/docs/slack-dm-agent-mvp.md @@ -20,45 +20,56 @@ convenience; it does not bypass the picker. Historical owned threads remain pinned to their original session. Attachments receive an explicit deferred response rather than being silently dropped. -The picker message becomes the single temporary progress surface: accepted, -starting, then session ready/working with a validated link to the exact spawned -session when the platform supplies its HTTPS lodge URL. Four conservative -four-second stock-Unicode pulse frames provide a brief sign of life before the -surface returns to at most one honest liveness update every 30 seconds. The -final `/notify` replaces that same message with the existing formatted response -and session footer. Final/error clearing stops further updates and failures -clean up idempotently. No custom emoji, assets, scopes or hostnames are added. - -Native Agent status, Stop, streaming session output and customized per-wolt -sender identity are not enabled by this slice. +The picker is replaced by a ready acknowledgement after selection. Slack's +native Agent Session becomes `processing`, receives the exact Woltspace session +slug as its title, and returns to `active` after the final reply. Each final +reply includes a validated **Open session** link to the exact spawned session +when the platform supplies its HTTPS lodge URL. Follow-ups stay pinned to the +same Slack thread and Woltspace session. + +The earlier custom `Gnawing` progress-message surface is deprecated. Agent View +plus `assistant:write` is the only supported setup. Native Stop and customized +per-wolt sender identity remain future work. ## Reusable private-lodge manifest -Create one Slack app per lodge from a manifest like this, then create an -app-level token with `connections:write` and install the app to the workspace. -Do not add `app_mentions:read` or channel message events for this DM-only MVP. - -```yaml -display_information: - name: Woltspace -features: - bot_user: - display_name: Woltspace - always_online: false -oauth_config: - scopes: - bot: - - chat:write - - im:history -settings: - event_subscriptions: - bot_events: - - message.im - interactivity: - is_enabled: true - org_deploy_enabled: false - socket_mode_enabled: true - token_rotation_enabled: false +Create one Slack app per lodge from this manifest, then create an app-level +token with `connections:write` and install the app to the workspace. Do not add +`app_mentions:read`, channel/group scopes, or channel/group message events. + +```json +{ + "display_information": { + "name": "Woltspace", + "description": "Woltspace", + "background_color": "#3A6644" + }, + "features": { + "agent_view": { + "agent_description": "gnaw. build. repeat" + }, + "bot_user": { + "display_name": "Woltspace", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": ["assistant:write", "chat:write", "im:history"] + } + }, + "settings": { + "event_subscriptions": { + "bot_events": ["app_home_opened", "message.im"] + }, + "interactivity": {"is_enabled": true}, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false, + "app_level_token_rotation_enabled": false, + "is_mcp_enabled": false + } +} ``` Configure the lodge only after copying the intended human's immutable Slack @@ -83,9 +94,7 @@ wolt, and observe the original message start the exact chosen-wolt session once. Verify a non-owner DM and a channel mention cause no work, restart, then confirm the same owned thread recovers. Never infer the owner from the first sender. -## Modern Slack UX and command inventory - -These are follow-ups, not current behavior: +## Current and future Slack surfaces | Surface | Slack support | Additional app work | |---|---|---| @@ -93,14 +102,17 @@ These are follow-ups, not current behavior: | Global/message shortcuts | Yes | Enable Interactivity and declare callbacks. | | Static select | Yes, via Block Kit | Implemented for owner-scoped wolt selection; Interactivity must be enabled. | | Buttons, shortcuts and modals | Yes, via Block Kit | Future callbacks must preserve the same owner gate. | -| Suggested prompts | Yes, in Slack's agent/assistant UI | Add `assistant:write` and Agent View handlers. | -| Processing/active status | Yes | Add `assistant:write`; explicitly return status to `active`. | -| Streaming responses | Yes | Deferred; plain session replies remain the acceptance path. | +| Suggested prompts | Yes, in Slack's Agent View | Future handler and product work. | +| Processing/active status | Implemented | Agent View plus `assistant:write`; final delivery returns status to `active`. | +| Agent Session title | Implemented | Exact Woltspace slug today; human-friendly Woltspace-owned names later. | +| Streaming responses | Partially | Uses Slack's stream API when available, with a plain final-message fallback. | | Native Stop | Yes, through `agent_session_stopped` | Convert the app to Agent View, subscribe to the event, and map it to real session interruption. | | Per-wolt name/icon | Yes | Add `chat:write.customize`; keep sender identity auditable and owner-controlled. | Switching an existing Slack app to Agent View and adding scopes/events are external app mutations and require explicit owner approval plus reinstall. +Do not retain the old channel/mention manifest or custom progress-message setup +as an alternate supported mode. ## From private setup to OAuth installation @@ -124,3 +136,10 @@ or becomes ineligible. This is roadmap scope only and is not implemented here. Official references: Slack's `chat.startStream`, `assistant.threads.setStatus`, `agent_session_stopped`, app manifests, Socket Mode and OAuth v2 documentation. + +## Session naming follow-up + +The exact Woltspace session slug is the stable Agent Session title for this +MVP. The intended browsing UX is a later Woltspace-owned human-friendly session +name that is synchronized into Slack. Slack must not become the source of truth +for session identity or naming. diff --git a/server/notify.py b/server/notify.py index 6b126ca2..48eeb3af 100644 --- a/server/notify.py +++ b/server/notify.py @@ -113,43 +113,6 @@ async def slack_send(token: str, channel: str, thread_ts: str | None, text: str) return data -async def slack_finish_progress( - token: str, channel: str, message_ts: str, mode: str, text: str -) -> dict: - async with httpx.AsyncClient() as client: - headers = {"Authorization": f"Bearer {token}"} - if mode == "stream": - stopped = await client.post( - "https://slack.com/api/chat.stopStream", - json={"channel": channel, "ts": message_ts}, - headers=headers, - ) - stopped_data = stopped.json() - if not stopped_data.get("ok"): - raise RuntimeError(stopped_data.get("error", "chat.stopStream error")) - # stopStream's markdown_text is an additional final chunk. Always use - # chat.update after stopping so the temporary `gnawing…` bytes are - # replaced, not retained above the persisted final answer. - updated = await client.post( - "https://slack.com/api/chat.update", - json={"channel": channel, "ts": message_ts, "text": text}, - headers=headers, - ) - updated_data = updated.json() - if not updated_data.get("ok"): - raise RuntimeError(updated_data.get("error", "chat.update error")) - return updated_data - - -async def slack_delete(token: str, channel: str, message_ts: str) -> None: - async with httpx.AsyncClient() as client: - await client.post( - "https://slack.com/api/chat.delete", - json={"channel": channel, "ts": message_ts}, - headers={"Authorization": f"Bearer {token}"}, - ) - - async def slack_set_agent_status( token: str, channel: str, thread_ts: str, status: str ) -> dict: @@ -209,7 +172,6 @@ async def _send_slack( channel = dotenv_env("SLACK_NOTIFY_CHANNEL") if not channel: raise RuntimeError("no slack channel provided and SLACK_NOTIFY_CHANNEL not set") - progress_ts = (routing or {}).get("slack_progress_ts", "") progress_mode = (routing or {}).get("slack_progress_mode", "") session_link = _slack_session_link(session, routing) final_message = message @@ -232,22 +194,6 @@ async def _send_slack( slack_progress_mode="", slack_progress_ts="", ) - elif progress_ts and progress_mode in {"stream", "message"}: - try: - await slack_finish_progress( - token, channel, progress_ts, progress_mode, final_message - ) - except Exception: - await slack_delete(token, channel, progress_ts) - raise - finally: - if session and routing: - SessionRegistry(WOLTS_DIR).update( - session, - wolt=routing.get("wolt", ""), - slack_progress_mode="", - slack_progress_ts="", - ) else: await slack_send(token, channel, thread_ts, final_message) append_chat_history("slack", channel, message) diff --git a/test/test_notify_progress.py b/test/test_notify_progress.py index b78ec552..8fe573e1 100644 --- a/test/test_notify_progress.py +++ b/test/test_notify_progress.py @@ -1,4 +1,4 @@ -"""Slack callback replaces one exact session-bound progress surface.""" +"""Slack final delivery clears one exact native Agent Session status.""" from unittest.mock import AsyncMock, Mock @@ -14,84 +14,23 @@ def route(**changes): "adapter": "slack", "chat_id": "D123", "thread_ts": "1000.1", - "slack_progress_mode": "stream", - "slack_progress_ts": "2000.1", + "slack_progress_mode": "agent", + "slack_progress_ts": "", "slack_session_link": "https://lodge.test/tui?session=n00b-session-1", **changes, } -class Response: - def __init__(self, payload): - self.payload = payload - - def json(self): - return self.payload - - -@pytest.mark.asyncio -async def test_stream_stop_is_followed_by_exact_final_replacement(monkeypatch): - post = AsyncMock(side_effect=[Response({"ok": True}), Response({"ok": True})]) - client = AsyncMock() - client.post = post - context = AsyncMock() - context.__aenter__.return_value = client - monkeypatch.setattr(notify.httpx, "AsyncClient", lambda: context) - - await notify.slack_finish_progress( - "xoxb-test", "D123", "2000.1", "stream", "🦝 n00b: final" - ) - - assert post.await_args_list[0].args[0].endswith("/chat.stopStream") - assert "text" not in post.await_args_list[0].kwargs["json"] - assert post.await_args_list[1].args[0].endswith("/chat.update") - assert post.await_args_list[1].kwargs["json"]["text"] == "🦝 n00b: final" - assert "gnawing" not in str(post.await_args_list[1]) - - -@pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["stream", "message"]) -async def test_exact_session_route_replaces_progress_once_and_clears( - monkeypatch, mode -): - routing = route(slack_progress_mode=mode) - monkeypatch.setattr(notify, "read_session_registry", lambda session: routing) - monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") - finish = AsyncMock(return_value={"ok": True}) - send = AsyncMock(side_effect=AssertionError("must replace, not post")) - monkeypatch.setattr(notify, "slack_finish_progress", finish) - monkeypatch.setattr(notify, "slack_send", send) - monkeypatch.setattr(notify, "append_chat_history", Mock()) - update = Mock() - monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) - - result = await notify.send_notification( - "n00b-session-1", "🦝 n00b: final answer", - explicit={"adapter": "slack", "channel": "D123", "thread_ts": "1000.1"}, - ) - - assert result["adapter"] == "slack" - finish.assert_awaited_once_with( - "xoxb-test", "D123", "2000.1", mode, - "🦝 n00b: final answer\n\n" - "" - ) - update.assert_called_once_with( - "n00b-session-1", wolt="n00b", - slack_progress_mode="", slack_progress_ts="", - ) - assert "final answer" not in str(update.call_args) - - @pytest.mark.asyncio async def test_invalid_or_cross_session_link_is_not_attached(monkeypatch): routing = route( - slack_progress_mode="message", slack_session_link="https://evil.test/tui?session=other-session", ) monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") - finish = AsyncMock(return_value={"ok": True}) - monkeypatch.setattr(notify, "slack_finish_progress", finish) + send = AsyncMock(return_value={"ok": True}) + status = AsyncMock(return_value={"ok": True}) + monkeypatch.setattr(notify, "slack_send", send) + monkeypatch.setattr(notify, "slack_set_agent_status", status) monkeypatch.setattr(notify, "append_chat_history", Mock()) monkeypatch.setattr( notify, "SessionRegistry", lambda root: Mock(update=Mock()) @@ -102,9 +41,7 @@ async def test_invalid_or_cross_session_link_is_not_attached(monkeypatch): session="n00b-session-1", routing=routing, ) - finish.assert_awaited_once_with( - "xoxb-test", "D123", "2000.1", "message", "final" - ) + send.assert_awaited_once_with("xoxb-test", "D123", "1000.1", "final") @pytest.mark.asyncio @@ -145,9 +82,7 @@ async def test_route_mismatch_cannot_claim_progress(monkeypatch): monkeypatch.setattr(notify, "read_session_registry", lambda session: route()) monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") send = AsyncMock(return_value={"ok": True}) - finish = AsyncMock(side_effect=AssertionError("mismatched route must not replace")) monkeypatch.setattr(notify, "slack_send", send) - monkeypatch.setattr(notify, "slack_finish_progress", finish) monkeypatch.setattr(notify, "append_chat_history", Mock()) await notify.send_notification( @@ -156,29 +91,3 @@ async def test_route_mismatch_cannot_claim_progress(monkeypatch): ) send.assert_awaited_once_with("xoxb-test", "DOTHER", "1000.1", "final") - - -@pytest.mark.asyncio -async def test_failed_replacement_deletes_progress_and_clears_idempotently(monkeypatch): - routing = route() - monkeypatch.setattr(notify, "dotenv_env", lambda key: "xoxb-test") - monkeypatch.setattr( - notify, "slack_finish_progress", AsyncMock(side_effect=RuntimeError("failed")) - ) - delete = AsyncMock() - monkeypatch.setattr(notify, "slack_delete", delete) - monkeypatch.setattr(notify, "append_chat_history", Mock()) - update = Mock() - monkeypatch.setattr(notify, "SessionRegistry", lambda root: Mock(update=update)) - - with pytest.raises(RuntimeError, match="failed"): - await notify._send_slack( - "final", "D123", "1000.1", - session="n00b-session-1", routing=routing, - ) - - delete.assert_awaited_once_with("xoxb-test", "D123", "2000.1") - update.assert_called_once_with( - "n00b-session-1", wolt="n00b", - slack_progress_mode="", slack_progress_ts="", - ) diff --git a/test/test_slack_adapter.py b/test/test_slack_adapter.py index 53197e67..75249699 100644 --- a/test/test_slack_adapter.py +++ b/test/test_slack_adapter.py @@ -173,69 +173,22 @@ async def test_streaming_failure_uses_plain_postmessage_fallback(): @pytest.mark.asyncio -async def test_session_route_posts_fresh_progress_without_sent_receipt(monkeypatch, tmp_path): - async def revived(*args): - return { - "ok": True, - "status": "revived", - "url": "https://lodge.test/tui?session=n00b-old-1", - } - - monkeypatch.setattr(slack, "message_session", Mock(return_value=await revived())) - monkeypatch.setattr(slack.registry, "update", Mock()) - monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) +async def test_session_route_requires_native_agent_view(monkeypatch, tmp_path): + deliver = Mock(side_effect=AssertionError("must not deliver without Agent View")) + monkeypatch.setattr(slack, "message_session", deliver) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) client = AsyncMock() client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") - client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} await slack._route_to_session(client, "D1", "1000.1", owner, "continue") - client.chat_postMessage.assert_awaited_once_with( - channel="D1", thread_ts="1000.1", text="🦫 Gnawing…" - ) - assert all("sent" not in str(call) for call in client.method_calls) - client.chat_update.assert_awaited_once_with( - channel="D1", ts="2000.1", - text=( - "🦫 Gnawing… " - "" - ), - ) - - -@pytest.mark.asyncio -async def test_session_route_keeps_known_link_on_fresh_bottom_progress(monkeypatch, tmp_path): - async def delivered(*args): - return { - "ok": True, - "status": "delivered", - "url": "https://lodge.test/tui?session=n00b-old-1", - } - - monkeypatch.setattr(slack, "message_session", Mock(return_value=await delivered())) - monkeypatch.setattr(slack.registry, "update", Mock()) - monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) - monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) - client = AsyncMock() - client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") - client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} - owner = { - "session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon", - "session_link": "https://lodge.test/tui?session=n00b-old-1", - } - - await slack._route_to_session(client, "D1", "1000.1", owner, "continue") - client.chat_postMessage.assert_awaited_once_with( channel="D1", thread_ts="1000.1", - text=( - "🦫 Gnawing… " - "" - ), + text=("Slack Agent View is not authorized. Reinstall with " + "`assistant:write` before retrying."), ) - client.chat_update.assert_not_awaited() + deliver.assert_not_called() @pytest.mark.asyncio @@ -245,14 +198,13 @@ async def dead(*args): monkeypatch.setattr(slack, "message_session", Mock(return_value=await dead())) monkeypatch.setattr(slack.registry, "update", Mock()) - monkeypatch.setattr(slack, "_watch_progress", AsyncMock()) monkeypatch.setattr(slack, "CHAT_DIR", tmp_path) monkeypatch.setattr(slack, "THREAD_SESSIONS_FILE", tmp_path / "owners.json") monkeypatch.setattr(slack, "_thread_sessions", { "D1:1000.1": {"session": "n00b-old-1", "wolt": "n00b", "creature": "raccoon"} }) client = AsyncMock() - client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") + client.agents_sessions_setStatus.return_value = {"ok": True} client.chat_postMessage.return_value = {"ok": True, "ts": "2000.1"} owner = slack._thread_sessions["D1:1000.1"] @@ -494,85 +446,6 @@ async def test_attachment_is_explicitly_deferred(monkeypatch): assert not slack.PENDING_MESSAGES_FILE.exists() -@pytest.mark.asyncio -async def test_failed_session_watcher_deletes_and_clears_progress(monkeypatch): - monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr(slack.registry, "get", Mock(return_value={ - "status": "failed", "slack_progress_ts": "2000.1" - })) - update = Mock() - monkeypatch.setattr(slack.registry, "update", update) - client = AsyncMock() - - await slack._watch_progress( - client, "builder-session-1", "builder", "D123" - ) - - client.chat_delete.assert_awaited_once_with(channel="D123", ts="2000.1") - update.assert_called_once_with( - "builder-session-1", wolt="builder", - slack_progress_mode="", slack_progress_ts="", - ) - - -@pytest.mark.asyncio -async def test_progress_pulses_four_times_then_uses_thirty_second_heartbeat(monkeypatch): - sleep = AsyncMock() - monkeypatch.setattr(slack.asyncio, "sleep", sleep) - monkeypatch.setattr(slack.registry, "get", Mock(return_value={ - "status": "running", "slack_progress_ts": "2000.1", - "slack_session_link": "https://lodge.test/tui?session=builder-session-1", - })) - client = AsyncMock() - client.chat_update.side_effect = [None, None, None, None, RuntimeError("rate limited")] - - await slack._watch_progress(client, "builder-session-1", "builder", "D123") - - assert [call.args[0] for call in sleep.await_args_list] == [2, 2, 2, 2, 30] - assert client.chat_update.await_count == 5 - texts = [call.kwargs["text"] for call in client.chat_update.await_args_list] - assert texts[:4] == [ - "🦫 Gnawing · ", - "🦫 Gnawing ·· ", - "🦫 Gnawing ··· ", - "🦫 Gnawing ·· ", - ] - assert texts[4] == ( - "🦫 Still gnawing… " - "" - ) - - -@pytest.mark.asyncio -async def test_progress_reads_newly_discovered_link_on_every_frame(monkeypatch): - monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) - records = [ - { - "status": "running", "slack_progress_ts": "2000.1", - "slack_session_link": "", - }, - { - "status": "running", "slack_progress_ts": "2000.1", - "slack_session_link": ( - "https://lodge.test/tui?session=builder-session-1" - ), - }, - ] - monkeypatch.setattr( - slack.registry, "get", - Mock(side_effect=[records[0], records[1], records[1]]), - ) - client = AsyncMock() - client.chat_update.side_effect = [None, None, RuntimeError("stop")] - - await slack._watch_progress(client, "builder-session-1", "builder", "D123") - - texts = [call.kwargs["text"] for call in client.chat_update.await_args_list] - assert "Open session" not in texts[0] - assert "" in texts[1] - assert "" in texts[2] - - @pytest.mark.parametrize("session, expected", [ ({"name": "n00b-1", "url": "https://lodge.test/tui?session=n00b-1"}, "https://lodge.test/tui?session=n00b-1"), @@ -586,20 +459,6 @@ def test_session_link_accepts_only_exact_platform_https_url(session, expected): assert slack._session_link(session) == expected -@pytest.mark.asyncio -async def test_progress_pulse_stops_when_final_callback_clears_binding(monkeypatch): - monkeypatch.setattr(slack.asyncio, "sleep", AsyncMock()) - monkeypatch.setattr(slack.registry, "get", Mock(return_value={ - "status": "running", "slack_progress_ts": "" - })) - client = AsyncMock() - - await slack._watch_progress(client, "n00b-session-1", "n00b", "D1") - - client.chat_update.assert_not_awaited() - client.chat_delete.assert_not_awaited() - - @pytest.mark.asyncio async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): session = { @@ -608,10 +467,8 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): } start = Mock(return_value=session) update = Mock() - watcher = AsyncMock() monkeypatch.setattr(slack, "start_claude_session", start) monkeypatch.setattr(slack.registry, "update", update) - monkeypatch.setattr(slack, "_watch_progress", watcher) client = AsyncMock() client.agents_sessions_setStatus.side_effect = RuntimeError("not an agent app") selected = {"name": "n00b", "type": "raccoon"} @@ -631,7 +488,10 @@ async def test_spawn_pending_delivers_original_and_pins_root(monkeypatch): } assert slack._thread_sessions["D1:1000.1"]["session"] == "n00b-session-1" assert client.chat_update.await_args_list[0].kwargs["text"].startswith("✅ Accepted") - assert "🦫 Gnawing" in client.chat_update.await_args_list[1].kwargs["text"] + assert "Slack Agent View is not authorized" in ( + client.chat_update.await_args_list[1].kwargs["text"] + ) + assert "Gnawing" not in client.chat_update.await_args_list[1].kwargs["text"] assert "" in ( client.chat_update.await_args_list[1].kwargs["text"] ) From c83040ada4bbfdc1f8894eaee378d8a8290d86dd Mon Sep 17 00:00:00 2001 From: "woltspace-jerpint[bot]" <268897999+woltspace-jerpint[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 01:29:17 -0400 Subject: [PATCH 13/13] retire custom Slack adapter overrides --- docs/environment.md | 9 +++++---- docs/native-and-container.md | 2 +- src/woltspace/channels.py | 4 ++-- src/woltspace/container_entrypoint.py | 3 --- test/test_channel_connectors.py | 17 ++++++++++++++--- test/test_container_entrypoint.py | 8 +++----- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/docs/environment.md b/docs/environment.md index 9ad36c04..3cc9f945 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -172,11 +172,12 @@ These keep the names their own platform gave them, and are configured in **Chat platforms** — `ENABLE_TELEGRAM_BOT`, `TELEGRAM_BOT_TOKEN`, `TELEGRAM_ALLOWED_USERS`, `TELEGRAM_CHAT_ID`, `ENABLE_SLACK_BOT`, -`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_NOTIFY_CHANNEL`. Read by +`SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_OWNER_USER`, +`SLACK_NOTIFY_CHANNEL`. Read by `src/woltspace/channels.py` when planning connectors and by the adapters in -`container/bot/`. `TELEGRAM_BOT_DIR` / `TELEGRAM_BOT_MODULE` and the `SLACK_*` -equivalents point a connector at the adapter to run, and are derived by -`container_entrypoint.build_environment` rather than set by hand. +`container/bot/`. `TELEGRAM_BOT_DIR` / `TELEGRAM_BOT_MODULE` point its +connector at an owner-customized Telegram adapter. Slack always uses the +platform's owner-DM Agent View adapter; there is no custom Slack module path. `BOT_ADAPTER` names which adapter a bot process is. **Models** — `LLM_MODEL` picks the bot brain's model; diff --git a/docs/native-and-container.md b/docs/native-and-container.md index 1ca5814b..5c0be063 100644 --- a/docs/native-and-container.md +++ b/docs/native-and-container.md @@ -366,7 +366,7 @@ live with it. | What | Why | Workaround for now | Intended fix | |---|---|---|---| -| Slack is silent | There is no Slack connector; only Telegram is behind the seam. | Nothing — Slack stays on the container. | A `SlackConnector` beside `TelegramConnector`. | +| Slack is silent | Slack is disabled, missing one of its three credentials, or the owner member ID is malformed. | Run `woltspace status` and follow the Slack connector remedy. | Use the bundled `setup-slack` skill for the owner-DM Agent View setup. | | No public URL | Native defaults the tunnel off (deliberately). `.env`'s named-tunnel token is ignored unless `WOLTSPACE_PUBLIC_TUNNEL=true`. | `WOLTSPACE_PUBLIC_TUNNEL=true woltspace start` — only with the container stopped, or two connectors load-balance the same hostname. | Document; possibly a `tunnel` block in `config.json`. | | The digest cron and the vulture reaper do not run | The wolf is a supervised connector in both runtimes now, so schedules fire natively. The vulture is gone from the container entrypoint too — adoption on start does the reconciling it existed for. | None; adoption covers the reaping. | The digest is a wolf schedule, not a creature. | | A session whose workdir sits outside the data root shows its agent's workspace trust prompt | Preparing a session pre-accepts the trust dialog — claude's in `~/.claude.json`, codex's in `$CODEX_HOME/config.toml` — but only for directories inside the data root: pointing woltspace at a colony is the trust decision, and it does not extend past it. Everything else still asks, and a headless spawn there parks with nobody to answer. | Trust the directory once by hand (open the agent in it), or keep session workdirs inside the data root. | Nothing — the data root is the boundary on purpose. | diff --git a/src/woltspace/channels.py b/src/woltspace/channels.py index 581ef907..afe2de09 100644 --- a/src/woltspace/channels.py +++ b/src/woltspace/channels.py @@ -315,8 +315,8 @@ def plan( self.name, False, "enabled with a malformed owner user ID", remedy=remedy ) - bot_dir = values.get("SLACK_BOT_DIR") or str(layout.install_root / "container") - module = values.get("SLACK_BOT_MODULE") or "bot.slack_adapter" + bot_dir = str(layout.install_root / "container") + module = "bot.slack_adapter" child_env = export_both({ "SLACK_BOT_TOKEN": bot_token, "SLACK_APP_TOKEN": app_token, diff --git a/src/woltspace/container_entrypoint.py b/src/woltspace/container_entrypoint.py index 16f7f620..8920755a 100644 --- a/src/woltspace/container_entrypoint.py +++ b/src/woltspace/container_entrypoint.py @@ -330,7 +330,6 @@ def build_environment( """ source = os.environ if env is None else env tg_dir, tg_mod = resolve_bot_module(wolt_dir, woltspace_dir, "telegram") - slack_dir, slack_mod = resolve_bot_module(wolt_dir, woltspace_dir, "slack") return export_both({ "WOLTSPACE_WOLT_NAME": wolt_name, "WOLTSPACE_WOLT_DIR": str(wolt_dir), @@ -338,8 +337,6 @@ def build_environment( "DEV_MODE": "true" if dev_mode else "false", "TELEGRAM_BOT_DIR": tg_dir, "TELEGRAM_BOT_MODULE": tg_mod, - "SLACK_BOT_DIR": slack_dir, - "SLACK_BOT_MODULE": slack_mod, "PYTHONPATH": f"{woltspace_dir}/container/lib:{source.get('PYTHONPATH', '')}", "PATH": f"{woltspace_dir}/container/bin:{source.get('PATH', '')}", "CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1", diff --git a/test/test_channel_connectors.py b/test/test_channel_connectors.py index 043b6f2b..4d29f118 100644 --- a/test/test_channel_connectors.py +++ b/test/test_channel_connectors.py @@ -108,6 +108,17 @@ def test_enabled_plan_carries_private_credentials_only_in_child_env(self, layout assert "xapp-test" not in public assert "U12345678" not in public + def test_custom_adapter_environment_cannot_replace_owner_dm_runtime(self, layout): + plan = SlackConnector().plan(layout, { + **self.BASE, + "SLACK_BOT_DIR": "/tmp/legacy-slack", + "SLACK_BOT_MODULE": "legacy.slack_adapter", + }) + + assert plan.cwd == str(layout.install_root / "container") + assert plan.command[-2:] == ("-m", "bot.slack_adapter") + assert "legacy" not in plan.detail + def test_config_shape_and_all_three_secrets_are_resolved(self, layout): write_config(layout, {"channels": {"slack": { "enabled": True, @@ -648,10 +659,10 @@ def test_boot_no_longer_launches_a_second_telegram_bot(self): dev_mode=False, env={}, ) assert env["TELEGRAM_BOT_MODULE"] == "bot.telegram_adapter" - # ...and the only process boot starts by hand is slack, which has no - # connector yet. A second telegram poller on one token is the bug. + # No chat adapter is launched by hand; the connector supervisor owns + # both Telegram and Slack. A second poller on one token is the bug. launchers = [name for name in vars(container_entrypoint) - if name.startswith("start_") and "slack" not in name] + if name.startswith("start_")] assert launchers == ["start_tunnel_report"] def test_boot_runs_the_installed_control_plane_in_its_own_process(self): diff --git a/test/test_container_entrypoint.py b/test/test_container_entrypoint.py index 73cd36c4..6954e90b 100644 --- a/test/test_container_entrypoint.py +++ b/test/test_container_entrypoint.py @@ -80,14 +80,12 @@ def test_dev_mode_is_declared_not_inferred(self, tmp_path): assert not boot.is_truthy("") and not boot.is_truthy(None) assert not boot.is_truthy("false") - def test_bot_modules_fall_back_to_the_platform_adapters(self, tmp_path): + def test_telegram_module_falls_back_to_the_platform_adapter(self, tmp_path): env = self._env(tmp_path) bundle = tmp_path / "bundle" assert env["TELEGRAM_BOT_MODULE"] == "bot.telegram_adapter" assert env["TELEGRAM_BOT_DIR"] == str(bundle / "container") - assert env["SLACK_BOT_MODULE"] == "bot.slack_adapter" - assert env["SLACK_BOT_DIR"] == str(bundle / "container") def test_a_wolt_owned_adapter_wins(self, tmp_path): wolt_dir = tmp_path / "wolts" / "mywolt" @@ -98,8 +96,8 @@ def test_a_wolt_owned_adapter_wins(self, tmp_path): assert env["TELEGRAM_BOT_DIR"] == str(wolt_dir) assert env["TELEGRAM_BOT_MODULE"] == "wolt.bot.telegram_adapter" - # Slack has no override, so it still points at the platform's copy - assert env["SLACK_BOT_MODULE"] == "bot.slack_adapter" + assert "SLACK_BOT_MODULE" not in env + assert "SLACK_BOT_DIR" not in env def test_nothing_is_written_to_the_real_environment(self, tmp_path): import os