From 9b1dfe3e8fc16684fa587644fd9e267378a9112d Mon Sep 17 00:00:00 2001 From: tachibanashuuta Date: Sat, 23 May 2026 13:41:28 +0900 Subject: [PATCH] Add Kanban Slack intake support --- gateway/platforms/slack.py | 72 ++++++ hermes_cli/commands.py | 3 + hermes_cli/kanban_slack_intake.py | 212 ++++++++++++++++++ plugins/kanban/dashboard/dist/index.js | 79 +++++-- tests/gateway/test_slack_kanban_intake.py | 176 +++++++++++++++ tests/plugins/test_kanban_dashboard_plugin.py | 79 +++++++ 6 files changed, 607 insertions(+), 14 deletions(-) create mode 100644 hermes_cli/kanban_slack_intake.py create mode 100644 tests/gateway/test_slack_kanban_intake.py diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index 5accfdb41089..20d391cab624 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -503,6 +503,68 @@ async def _send_slash_ephemeral( # Non-fatal — the user saw the initial ack already. return SendResult(success=True, message_id=None) + async def _reply_to_slack_kanban_intake( + self, + command: dict, + content: str, + ) -> SendResult: + """Reply to the Slack Kanban intake slash command. + + Prefer the slash command response_url so the result stays ephemeral. + Fall back to a normal channel send when tests or non-Slack callers omit + response_url. + """ + response_url = command.get("response_url", "") + if response_url: + return await self._send_slash_ephemeral({"response_url": response_url}, content) + channel_id = command.get("channel_id", "") + if channel_id: + return await self.send(channel_id, content) + logger.warning("[Slack] Cannot reply to kanban intake: missing channel_id/response_url") + return SendResult(success=False, error="missing channel_id/response_url") + + async def _handle_slack_kanban_intake(self, command: dict) -> None: + """Create a Kanban card directly from Slack without starting an agent turn.""" + text = command.get("text", "") or "" + user_id = command.get("user_id", "") or "unknown" + try: + from hermes_cli.kanban_slack_intake import ( + create_slack_kanban_task, + parse_slack_kanban_intake, + ) + + request = parse_slack_kanban_intake(text) + result = create_slack_kanban_task( + request, + created_by=f"slack:{user_id}", + ) + await self._reply_to_slack_kanban_intake( + command, + ( + "Kanban task created.\n" + f"column: {result.column}\n" + f"status: {result.status}\n" + f"task_id: {result.task_id}\n" + f"board: {result.board}\n" + f"title: {result.title}" + ), + ) + except ValueError as exc: + await self._reply_to_slack_kanban_intake( + command, + ( + f"Kanban task was not created: {exc}\n" + "Usage: /kanban-add [column=triage|todo] title=\"Task title\" " + "[body=\"details\"] [assignee=profile] [board=slug]" + ), + ) + except Exception as exc: # pragma: no cover - defensive gateway boundary + logger.error("[Slack] Kanban intake failed: %s", exc, exc_info=True) + await self._reply_to_slack_kanban_intake( + command, + f"Kanban task was not created: {exc}", + ) + async def connect(self) -> bool: """Connect to Slack via Socket Mode.""" if not SLACK_AVAILABLE: @@ -2778,6 +2840,10 @@ async def _handle_slash_command(self, command: dict) -> None: if team_id and channel_id: self._channel_team[channel_id] = team_id + if slash_name in {"kanban-add", "kanban_add", "add-kanban"}: + await self._handle_slack_kanban_intake(command) + return + if slash_name in {"hermes", ""}: # Legacy /hermes [args] routing + free-form questions. # Empty slash_name falls into this branch for backward compat @@ -2789,6 +2855,12 @@ async def _handle_slash_command(self, command: dict) -> None: # ``text.split()`` returns ``[]`` (e.g. user sends ``/hermes ``). parts = text.split() if text else [] first_word = parts[0] if parts else "" + if first_word in {"kanban-add", "kanban_add", "add-kanban"}: + rest = text[len(first_word):].strip() + intake_command = dict(command) + intake_command["text"] = rest + await self._handle_slack_kanban_intake(intake_command) + return if first_word in subcommand_map: rest = text[len(first_word):].strip() text = f"{subcommand_map[first_word]} {rest}".strip() if rest else subcommand_map[first_word] diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 815fb3caa007..dafe599492b4 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -181,6 +181,9 @@ class CommandDef: "archive", "tail", "dispatch", "stats", "notify-subscribe", "notify-list", "notify-unsubscribe", "log", "runs", "heartbeat", "assignees", "context", "specify", "gc")), + CommandDef("kanban-add", "Create a Kanban task from Slack (/kanban-add title=... column=triage|todo)", + "Tools & Skills", gateway_only=True, + args_hint="[column=triage|todo] [title=...] [body=...] [assignee=...] [board=...]"), CommandDef("reload", "Reload .env variables into the running session", "Tools & Skills", cli_only=True), CommandDef("reload-mcp", "Reload MCP servers from config", "Tools & Skills", diff --git a/hermes_cli/kanban_slack_intake.py b/hermes_cli/kanban_slack_intake.py new file mode 100644 index 000000000000..9566cf63b57e --- /dev/null +++ b/hermes_cli/kanban_slack_intake.py @@ -0,0 +1,212 @@ +"""Slack-to-Kanban intake helpers. + +This module is intentionally small and dependency-light so the Slack gateway +adapter can create Kanban cards directly without starting an agent turn or +exposing terminal/code execution from Slack. +""" + +from __future__ import annotations + +import re +import shlex +from dataclasses import dataclass +from typing import Optional + + +_ALLOWED_COLUMNS = {"triage", "todo"} +_COLUMN_ALIASES = { + "triage": "triage", + "inbox": "triage", + "spec": "triage", + "specify": "triage", + "todo": "todo", + "to-do": "todo", + "to_do": "todo", +} + + +@dataclass(frozen=True) +class SlackKanbanCreateRequest: + title: str + body: str = "" + column: str = "triage" + assignee: Optional[str] = None + board: Optional[str] = None + tenant: Optional[str] = None + priority: int = 0 + + +@dataclass(frozen=True) +class SlackKanbanCreateResult: + task_id: str + title: str + column: str + status: str + board: str + + +def _strip_surrounding_quotes(value: str) -> str: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}: + return value[1:-1] + return value + + +def _normalize_column(raw: str) -> str: + value = (raw or "").strip().lower() + value = value.removeprefix("#") + column = _COLUMN_ALIASES.get(value) + if column not in _ALLOWED_COLUMNS: + allowed = ", ".join(sorted(_ALLOWED_COLUMNS)) + raise ValueError(f"column must be one of: {allowed}") + return column + + +def _parse_tokenized(text: str) -> tuple[dict[str, str], list[str]]: + try: + tokens = shlex.split(text, posix=True) + except ValueError as exc: + raise ValueError(f"could not parse quoted input: {exc}") from exc + + fields: dict[str, str] = {} + title_parts: list[str] = [] + i = 0 + while i < len(tokens): + token = tokens[i] + lowered = token.lower() + if lowered in {"--todo", "todo"}: + fields["column"] = "todo" + elif lowered in {"--triage", "triage"}: + fields["column"] = "triage" + elif lowered.startswith("--") and "=" in lowered: + key, value = token[2:].split("=", 1) + fields[key.lower().replace("-", "_")] = value + elif "=" in token and not token.startswith("="): + key, value = token.split("=", 1) + fields[key.lower().replace("-", "_")] = value + elif lowered.startswith("--"): + key = lowered[2:].replace("-", "_") + if key in {"title", "body", "column", "assignee", "board", "tenant", "priority"}: + if i + 1 >= len(tokens): + raise ValueError(f"{token} requires a value") + fields[key] = tokens[i + 1] + i += 1 + else: + title_parts.append(token) + else: + title_parts.append(token) + i += 1 + return fields, title_parts + + +def parse_slack_kanban_intake(text: str) -> SlackKanbanCreateRequest: + """Parse `/kanban-add` text into a bounded Kanban create request. + + Supported examples: + - `/kanban-add Fix login bug` + - `/kanban-add column=todo title="Fix login" body="Steps..." assignee=default` + - `/kanban-add --todo "Fix login"` + - multi-line: first line title, remaining lines body + """ + raw = (text or "").strip() + if not raw: + raise ValueError("title is required. Example: /kanban-add Fix login bug") + + fields, title_parts = _parse_tokenized(raw) + + # If no explicit body was provided and the raw input is multi-line, keep + # the first non-empty line as title and remaining lines as body. This is + # useful when users paste a brief spec into Slack. + body_from_lines = "" + if "title" not in fields and "body" not in fields and "\n" in raw: + lines = [line.rstrip() for line in raw.splitlines()] + nonempty = [line for line in lines if line.strip()] + if nonempty: + fields["title"] = nonempty[0].strip() + body_from_lines = "\n".join(nonempty[1:]).strip() + title_parts = [] + + title = _strip_surrounding_quotes(fields.get("title", "") or " ".join(title_parts)) + body = _strip_surrounding_quotes(fields.get("body", "") or body_from_lines) + column = _normalize_column(fields.get("column", "triage")) + + if not title.strip(): + raise ValueError("title is required. Example: /kanban-add Fix login bug") + if len(title) > 200: + raise ValueError("title must be 200 characters or fewer") + if len(body) > 8000: + raise ValueError("body must be 8000 characters or fewer") + + priority = 0 + if fields.get("priority") not in (None, ""): + try: + priority = int(fields["priority"]) + except ValueError as exc: + raise ValueError("priority must be an integer") from exc + + def optional(name: str) -> Optional[str]: + value = _strip_surrounding_quotes(fields.get(name, "")) + return value.strip() or None + + return SlackKanbanCreateRequest( + title=title.strip(), + body=body, + column=column, + assignee=optional("assignee"), + board=optional("board"), + tenant=optional("tenant"), + priority=priority, + ) + + +def create_slack_kanban_task( + request: SlackKanbanCreateRequest, + *, + created_by: str = "slack", +) -> SlackKanbanCreateResult: + """Create the requested Kanban task and return the created id/status.""" + from hermes_cli import kanban_db + + board = request.board or kanban_db.get_current_board() + conn = kanban_db.connect(board=board) + try: + task_id = kanban_db.create_task( + conn, + title=request.title, + body=request.body or None, + assignee=request.assignee, + created_by=created_by, + tenant=request.tenant, + priority=request.priority, + triage=request.column == "triage", + board=board, + ) + + if request.column == "todo": + with kanban_db.write_txn(conn): + conn.execute( + "UPDATE tasks SET status = ? WHERE id = ?", + ("todo", task_id), + ) + kanban_db._append_event( # internal helper; keeps event log truthful + conn, + task_id, + "status_changed", + {"status": "todo", "source": "slack_kanban_intake"}, + ) + + task = kanban_db.get_task(conn, task_id) + status = task.status if task else request.column + return SlackKanbanCreateResult( + task_id=task_id, + title=request.title, + column=request.column, + status=status, + board=board, + ) + finally: + conn.close() + + +def is_kanban_add_text(text: str) -> bool: + return bool(re.match(r"^\s*(?:kanban-add|kanban_add|add-kanban)\b", text or "", re.I)) diff --git a/plugins/kanban/dashboard/dist/index.js b/plugins/kanban/dashboard/dist/index.js index 9a04b6a649e4..5da0b9d67d27 100644 --- a/plugins/kanban/dashboard/dist/index.js +++ b/plugins/kanban/dashboard/dist/index.js @@ -2584,9 +2584,42 @@ } // ------------------------------------------------------------------------- - // Inline create (with parent selector) + // Inline create form // ------------------------------------------------------------------------- + function ProfileSelector(props) { + const { t } = useI18n(); + const profiles = props.profiles || []; + const disabled = !!props.loading || !!props.error || profiles.length === 0; + const placeholder = props.loading + ? tx(t, "loadingProfiles", "Loading profiles…") + : props.error + ? tx(t, "profilesUnavailable", "Profiles unavailable") + : profiles.length === 0 + ? tx(t, "noProfilesAvailable", "No profiles available") + : tx(t, "selectProfile", "Select a profile"); + return h("div", { className: "flex-1 min-w-0" }, + h(Select, Object.assign({ + value: props.value || "", + disabled: disabled, + className: "h-7 text-xs w-full", + title: props.title, + }, selectChangeHandler(props.onChange)), + h(SelectOption, { value: "" }, placeholder), + profiles.map(function (p) { + const label = p.description + ? `${p.name} — ${p.description}` + : p.name; + return h(SelectOption, { key: p.name, value: p.name }, label); + }), + ), + props.error + ? h("div", { className: "text-[10px] text-destructive mt-1", title: props.error }, + tx(t, "profileLoadError", "Failed to load profiles: ") + props.error) + : null, + ); + } + function InlineCreate(props) { const { t } = useI18n(); const [title, setTitle] = useState(""); @@ -2594,6 +2627,9 @@ const [priority, setPriority] = useState(0); const [parent, setParent] = useState(""); const [skills, setSkills] = useState(""); + const [profiles, setProfiles] = useState([]); + const [profilesLoading, setProfilesLoading] = useState(true); + const [profileLoadError, setProfileLoadError] = useState(null); // Workspace controls. `scratch` (default) ignores path; `worktree` optionally // takes a path (dispatcher derives one from the assignee profile otherwise); // `dir` requires a path. Backend enforces the rule — we only hide/show the @@ -2601,12 +2637,32 @@ const [workspaceKind, setWorkspaceKind] = useState("scratch"); const [workspacePath, setWorkspacePath] = useState(""); + useEffect(function () { + let alive = true; + setProfilesLoading(true); + setProfileLoadError(null); + SDK.fetchJSON(`${API}/profiles`) + .then(function (data) { + if (!alive) return; + setProfiles((data && data.profiles) || []); + }) + .catch(function (err) { + if (!alive) return; + setProfiles([]); + setProfileLoadError(parseApiErrorMessage(err)); + }) + .finally(function () { + if (alive) setProfilesLoading(false); + }); + return function () { alive = false; }; + }, []); + const submit = function () { const trimmed = title.trim(); if (!trimmed) return; const body = { title: trimmed, - assignee: assignee.trim() || null, + assignee: assignee || null, priority: Number(priority) || 0, triage: props.columnName === "triage", }; @@ -2653,20 +2709,15 @@ rows: 2, }), h("div", { className: "flex gap-2" }, - h(Input, { + h(ProfileSelector, { value: assignee, - onChange: function (e) { setAssignee(e.target.value); }, - placeholder: props.columnName === "triage" - ? tx(t, "specifier", "specifier") - : tx(t, "assigneePlaceholder", "assignee"), - className: "h-7 text-xs flex-1", + onChange: setAssignee, + profiles: profiles, + loading: profilesLoading, + error: profileLoadError, title: props.columnName === "triage" - ? "Hermes profile that will spec this task (default: the dispatcher's configured specifier). Leave blank to let the dispatcher pick." - : "Hermes profile to assign. Leave blank and the dispatcher will pick from available profiles when the task is Ready.", - style: { textTransform: "none" }, - autoCapitalize: "none", - autoCorrect: "off", - spellCheck: false, + ? "Hermes profile that will spec this task. Pick an installed profile, or leave blank to let the dispatcher pick." + : "Hermes profile to assign. Pick an installed profile, or leave blank and the dispatcher will pick from available profiles when the task is Ready.", }), h(Input, { type: "number", diff --git a/tests/gateway/test_slack_kanban_intake.py b/tests/gateway/test_slack_kanban_intake.py new file mode 100644 index 000000000000..e52a4a62415d --- /dev/null +++ b/tests/gateway/test_slack_kanban_intake.py @@ -0,0 +1,176 @@ +"""Tests for Slack direct Kanban task intake.""" + +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from gateway.config import PlatformConfig + + +def _ensure_slack_mock(): + if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): + return + + slack_bolt = MagicMock() + slack_bolt.async_app.AsyncApp = MagicMock + slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock + + slack_sdk = MagicMock() + slack_sdk.web.async_client.AsyncWebClient = MagicMock + + for name, mod in [ + ("slack_bolt", slack_bolt), + ("slack_bolt.async_app", slack_bolt.async_app), + ("slack_bolt.adapter", slack_bolt.adapter), + ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), + ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), + ("slack_sdk", slack_sdk), + ("slack_sdk.web", slack_sdk.web), + ("slack_sdk.web.async_client", slack_sdk.web.async_client), + ]: + sys.modules.setdefault(name, mod) + sys.modules.setdefault("aiohttp", MagicMock()) + + +_ensure_slack_mock() + +import gateway.platforms.slack as _slack_mod +_slack_mod.SLACK_AVAILABLE = True + +from gateway.platforms.slack import SlackAdapter # noqa: E402 +from hermes_cli import kanban_db # noqa: E402 +from hermes_cli.kanban_slack_intake import parse_slack_kanban_intake # noqa: E402 + + +def test_parse_slack_kanban_intake_defaults_to_triage(): + request = parse_slack_kanban_intake('title="Fix login" body="Steps here"') + + assert request.title == "Fix login" + assert request.body == "Steps here" + assert request.column == "triage" + + +def test_parse_slack_kanban_intake_accepts_todo_shorthand(): + request = parse_slack_kanban_intake('--todo "Fix login" assignee=default') + + assert request.title == "Fix login" + assert request.column == "todo" + assert request.assignee == "default" + + +def test_parse_slack_kanban_intake_rejects_empty_title(): + with pytest.raises(ValueError, match="title is required"): + parse_slack_kanban_intake("") + + +@pytest.mark.asyncio +async def test_kanban_add_slash_creates_triage_task_and_replies(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_HOME", str(tmp_path)) + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-test")) + adapter._reply_to_slack_kanban_intake = AsyncMock() + + await adapter._handle_slash_command( + { + "command": "/kanban-add", + "text": 'title="Fix login" body="Steps here"', + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + "response_url": "https://example.invalid/response", + } + ) + + conn = kanban_db.connect(board="default") + rows = conn.execute("SELECT id, title, body, status, created_by FROM tasks").fetchall() + conn.close() + + assert len(rows) == 1 + assert rows[0]["title"] == "Fix login" + assert rows[0]["body"] == "Steps here" + assert rows[0]["status"] == "triage" + assert rows[0]["created_by"] == "slack:U123" + + adapter._reply_to_slack_kanban_intake.assert_awaited_once() + await_args = adapter._reply_to_slack_kanban_intake.await_args + assert await_args is not None + reply = await_args.args[1] + assert "Kanban task created" in reply + assert f"task_id: {rows[0]['id']}" in reply + assert "column: triage" in reply + + +@pytest.mark.asyncio +async def test_kanban_add_slash_can_force_todo(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_HOME", str(tmp_path)) + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-test")) + adapter._reply_to_slack_kanban_intake = AsyncMock() + + await adapter._handle_slash_command( + { + "command": "/kanban-add", + "text": 'column=todo title="Prepare invoice"', + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + } + ) + + conn = kanban_db.connect(board="default") + row = conn.execute("SELECT title, status FROM tasks").fetchone() + conn.close() + + assert row["title"] == "Prepare invoice" + assert row["status"] == "todo" + await_args = adapter._reply_to_slack_kanban_intake.await_args + assert await_args is not None + reply = await_args.args[1] + assert "column: todo" in reply + assert "status: todo" in reply + + +@pytest.mark.asyncio +async def test_hermes_legacy_kanban_add_routes_directly(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_KANBAN_HOME", str(tmp_path)) + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-test")) + adapter._reply_to_slack_kanban_intake = AsyncMock() + adapter.handle_message = AsyncMock() + + await adapter._handle_slash_command( + { + "command": "/hermes", + "text": 'kanban-add title="Legacy route"', + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + } + ) + + adapter.handle_message.assert_not_awaited() + conn = kanban_db.connect(board="default") + row = conn.execute("SELECT title, status FROM tasks").fetchone() + conn.close() + assert row["title"] == "Legacy route" + assert row["status"] == "triage" + + +@pytest.mark.asyncio +async def test_kanban_add_slash_reports_invalid_input(monkeypatch): + adapter = SlackAdapter(PlatformConfig(enabled=True, token="xoxb-test")) + adapter._reply_to_slack_kanban_intake = AsyncMock() + + await adapter._handle_slash_command( + { + "command": "/kanban-add", + "text": "column=doing title=Bad", + "user_id": "U123", + "channel_id": "C123", + "team_id": "T123", + } + ) + + await_args = adapter._reply_to_slack_kanban_intake.await_args + assert await_args is not None + reply = await_args.args[1] + assert "Kanban task was not created" in reply + assert "column must be one of" in reply diff --git a/tests/plugins/test_kanban_dashboard_plugin.py b/tests/plugins/test_kanban_dashboard_plugin.py index 5fa1881fa329..75b92dd7b55c 100644 --- a/tests/plugins/test_kanban_dashboard_plugin.py +++ b/tests/plugins/test_kanban_dashboard_plugin.py @@ -114,6 +114,85 @@ def test_create_task_appears_on_board(client): assert "researcher" in data["assignees"] +def test_create_task_with_selected_profile_from_roster_appears_on_board(client, monkeypatch, tmp_path): + from hermes_cli import profiles as profiles_mod + + monkeypatch.setattr( + profiles_mod, + "list_profiles", + lambda: [ + profiles_mod.ProfileInfo( + name="default", + path=tmp_path / ".hermes", + is_default=True, + gateway_running=False, + model="gpt-5.5", + provider="openai-codex", + description="General worker", + ), + profiles_mod.ProfileInfo( + name="specifier-bot", + path=tmp_path / "specifier-bot", + is_default=False, + gateway_running=False, + model="glm-5.1", + provider="zai", + skill_count=3, + description="Turns rough ideas into ready specs", + ), + ], + ) + + roster = client.get("/api/plugins/kanban/profiles") + assert roster.status_code == 200, roster.text + profiles = {p["name"]: p for p in roster.json()["profiles"]} + assert "specifier-bot" in profiles + assert profiles["specifier-bot"]["description"] == "Turns rough ideas into ready specs" + assert "path" not in profiles["specifier-bot"] + assert "has_env" not in profiles["specifier-bot"] + + r = client.post( + "/api/plugins/kanban/tasks", + json={"title": "Specify dashboard quick create", "assignee": "specifier-bot"}, + ) + assert r.status_code == 200, r.text + task = r.json()["task"] + assert task["assignee"] == "specifier-bot" + assert task["status"] == "ready" + + board = client.get("/api/plugins/kanban/board").json() + ready = next(c for c in board["columns"] if c["name"] == "ready") + assert any(t["id"] == task["id"] and t["assignee"] == "specifier-bot" for t in ready["tasks"]) + assert "specifier-bot" in board["assignees"] + + +def test_profiles_endpoint_failure_is_understandable(client, monkeypatch): + from hermes_cli import profiles as profiles_mod + + def fail_to_list_profiles(): + raise RuntimeError("profile store unavailable") + + monkeypatch.setattr(profiles_mod, "list_profiles", fail_to_list_profiles) + + r = client.get("/api/plugins/kanban/profiles") + assert r.status_code == 500 + assert r.json()["detail"] == "failed to list profiles: profile store unavailable" + + +def test_inline_create_uses_profile_selector_not_free_text_assignee_input(): + repo_root = Path(__file__).resolve().parents[2] + bundle = repo_root / "plugins" / "kanban" / "dashboard" / "dist" / "index.js" + js = bundle.read_text() + + assert "function ProfileSelector(props)" in js + assert "SDK.fetchJSON(`${API}/profiles`)" in js + assert "profileLoadError" in js + assert "Select a profile" in js + assert "No profiles available" in js + assert "setAssignee(e.target.value)" not in js + assert "assignee: assignee || null" in js + + def test_scheduled_tasks_have_their_own_column_not_todo(client): """Scheduled/time-delay tasks must not be silently bucketed into todo."""