From fe04ad47c9abf843cb6f64176981325a5b05f074 Mon Sep 17 00:00:00 2001 From: Ben Sima Date: Thu, 4 Jun 2026 16:38:54 -0400 Subject: Add tests and fix research tools --- .env.example | 1 + README.md | 12 +++++ deploy.sh | 4 ++ flake.nix | 51 +++++++++++++++++----- main.py | 25 +++++++---- scripts/install-git-hooks.sh | 6 +++ scripts/pre-commit | 4 ++ test.sh | 5 +++ tests/test_main.py | 102 +++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 191 insertions(+), 19 deletions(-) create mode 100755 scripts/install-git-hooks.sh create mode 100755 scripts/pre-commit create mode 100755 test.sh create mode 100644 tests/test_main.py diff --git a/.env.example b/.env.example index c9d6a72..3778611 100644 --- a/.env.example +++ b/.env.example @@ -8,5 +8,6 @@ SLOPBOT_AGENT_MODEL=parasail/kimi-k26 SLOPBOT_WORKERS=4 SLOPBOT_AGENT_WORKERS=1 SLOPBOT_AGENT_TIMEOUT=120 +SLOPBOT_AGENT_DEBUG=false SLOPBOT_HISTORY_LIMIT=20 SLOPBOT_SYSTEM_PROMPT=prompts/system.md diff --git a/README.md b/README.md index 020c310..a2b1e4a 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,18 @@ cp .env.example .env python main.py ``` +Run tests: + +```bash +./test.sh +``` + +Install the pre-commit hook: + +```bash +./scripts/install-git-hooks.sh +``` + Deploy as a user-level systemd service: ```bash diff --git a/deploy.sh b/deploy.sh index c729b6a..f03e5f5 100755 --- a/deploy.sh +++ b/deploy.sh @@ -26,6 +26,7 @@ SLOPBOT_AGENT_MODEL=parasail/kimi-k26 SLOPBOT_WORKERS=4 SLOPBOT_AGENT_WORKERS=1 SLOPBOT_AGENT_TIMEOUT=120 +SLOPBOT_AGENT_DEBUG=false SLOPBOT_HISTORY_LIMIT=20 SLOPBOT_SYSTEM_PROMPT=prompts/system.md EOF @@ -36,6 +37,9 @@ fi echo "locking local flake inputs..." "${NIX[@]}" flake lock +echo "running tests..." +./test.sh + echo "building slopbot..." "${NIX[@]}" build .#slopbot diff --git a/flake.nix b/flake.nix index a70e64e..a45dfa7 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ slopSearch = pkgs.writeShellApplication { name = "slop-search"; - runtimeInputs = [ pkgs.curl pkgs.jq pkgs.python3 ]; + runtimeInputs = [ pkgs.coreutils pkgs.curl pkgs.python3 ]; text = '' if [ "$#" -eq 0 ]; then echo "usage: slop-search QUERY" >&2 @@ -50,25 +50,44 @@ query="$*" encoded="$(${pkgs.python3}/bin/python -c 'import sys, urllib.parse; print(urllib.parse.urlencode({"q": " ".join(sys.argv[1:])}))' "$@")" + tmp="$(mktemp)" + trap 'rm -f "$tmp"' EXIT curl -fsS \ -H "Authorization: Bot ''${KAGI_API_KEY}" \ -H "Accept: application/json" \ "https://kagi.com/api/v0/search?''${encoded}" \ - | jq -r --arg query "$query" ' - "QUERY: \($query)", - "", - (.data // []) - | map(select((.t // .type // "") == "search_result" or has("url"))) - | .[:8] - | to_entries[] - | "[\(.key + 1)] \(.value.title // "untitled")\nURL: \(.value.url)\nSNIPPET: \(.value.snippet // .value.description // "")\n" - ' + -o "$tmp" + ${pkgs.python3}/bin/python - "$query" "$tmp" <<'PY' +import json +import sys + +query, path = sys.argv[1], sys.argv[2] +payload = json.load(open(path)) +items = payload.get("data") or [] +results = [] +for item in items: + if not item.get("url"): + continue + item_type = item.get("t") or item.get("type") or "" + if item_type and item_type != "search_result": + continue + results.append(item) + if len(results) >= 8: + break + +print(f"QUERY: {query}\n") +for i, item in enumerate(results, 1): + title = item.get("title") or "untitled" + url = item.get("url") or "" + snippet = item.get("snippet") or item.get("description") or "" + print(f"[{i}] {title}\nURL: {url}\nSNIPPET: {snippet}\n") +PY ''; }; slopFetch = pkgs.writeShellApplication { name = "slop-fetch"; - runtimeInputs = [ pkgs.curl researchPython ]; + runtimeInputs = [ pkgs.coreutils pkgs.curl researchPython ]; text = '' if [ "$#" -ne 1 ]; then echo "usage: slop-fetch URL" >&2 @@ -107,6 +126,16 @@ PY ''; }; + checks.${system}.default = pkgs.runCommand "slopbot-tests" { + buildInputs = [ pythonEnv ]; + } '' + cp -r ${self} source + cd source + chmod -R u+w . + python -m unittest discover -s tests + touch $out + ''; + packages.${system} = rec { default = slopbot; slopbot = pkgs.writeShellScriptBin "slopbot" '' diff --git a/main.py b/main.py index 96eb7ef..128339a 100644 --- a/main.py +++ b/main.py @@ -41,6 +41,7 @@ AGENT_TIMEOUT = int(os.environ.get("SLOPBOT_AGENT_TIMEOUT", "120")) AGENT_MODEL = os.environ.get("SLOPBOT_AGENT_MODEL", "parasail/kimi-k26") AGENT_BIN = os.environ.get("SLOPBOT_AGENT", "agent") AGENT_PATH = os.environ.get("SLOPBOT_AGENT_PATH", os.environ.get("PATH", "")) +AGENT_DEBUG = os.environ.get("SLOPBOT_AGENT_DEBUG", "").lower() in {"1", "true", "yes", "on"} WORKERS = int(os.environ.get("SLOPBOT_WORKERS", "4")) AGENT_WORKERS = int(os.environ.get("SLOPBOT_AGENT_WORKERS", "1")) HISTORY_LIMIT = int(os.environ.get("SLOPBOT_HISTORY_LIMIT", "20")) @@ -126,16 +127,24 @@ def _agent(prompt: str) -> tuple[int, str, str]: """Run agent once; return (returncode, stdout, stderr).""" env = os.environ.copy() env["PATH"] = AGENT_PATH + command = [ + AGENT_BIN, + "--model", AGENT_MODEL, + "--eval-cwd", str(BASE_DIR), + ] + if AGENT_DEBUG: + command.append("--debug") + command.append(prompt) + result = subprocess.run( - [ - AGENT_BIN, - "--model", AGENT_MODEL, - "--eval-cwd", str(BASE_DIR), - prompt, - ], - capture_output=True, text=True, timeout=AGENT_TIMEOUT, env=env, + command, + stdout=subprocess.PIPE, + stderr=None if AGENT_DEBUG else subprocess.PIPE, + text=True, + timeout=AGENT_TIMEOUT, + env=env, ) - return result.returncode, result.stdout.strip(), result.stderr.strip() + return result.returncode, result.stdout.strip(), (result.stderr or "").strip() def _unique_session_name(msg: dict) -> str: diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100755 index 0000000..9cc0748 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." +install -m 0755 scripts/pre-commit .git/hooks/pre-commit +echo "installed .git/hooks/pre-commit" diff --git a/scripts/pre-commit b/scripts/pre-commit new file mode 100755 index 0000000..9168ce8 --- /dev/null +++ b/scripts/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +./test.sh diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..618aed7 --- /dev/null +++ b/test.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")" +exec nix --extra-experimental-features 'nix-command flakes' develop --command python -m unittest discover -s tests diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..0d7e051 --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,102 @@ +import unittest +from unittest.mock import Mock, patch + +import main + + +class SlopbotTests(unittest.TestCase): + def setUp(self): + self.old_client = main.client + self.old_executor = main.executor + + def tearDown(self): + main.client = self.old_client + main.executor = self.old_executor + + def stream_msg(self, content, **overrides): + msg = { + "id": 123, + "type": "stream", + "sender_email": "alice@example.com", + "sender_full_name": "Alice", + "display_recipient": "test stream", + "stream_id": 23, + "subject": "slop", + "content": content, + } + msg.update(overrides) + return msg + + def test_ticker_regex_deduplicates_in_handle_message(self): + sent = [] + main.client = Mock(email="slop-bot@example.com") + main.client.send_message.side_effect = sent.append + + with patch.object(main, "fetch_price", side_effect=lambda ticker: f"${ticker}: $1.00") as fetch: + main.handle_message(self.stream_msg("$NVDA $AAPL $NVDA")) + + self.assertEqual([call.args[0] for call in fetch.call_args_list], ["NVDA", "AAPL"]) + self.assertEqual(len(sent), 1) + self.assertEqual(sent[0]["type"], "stream") + self.assertEqual(sent[0]["to"], 23) + self.assertEqual(sent[0]["topic"], "slop") + self.assertEqual(sent[0]["content"], "$NVDA: $1.00\n$AAPL: $1.00") + + def test_mention_calls_agent_and_appends_prices(self): + sent = [] + main.client = Mock(email="slop-bot@example.com") + main.client.send_message.side_effect = sent.append + + with patch.object(main, "ask_agent", return_value="agent reply") as ask_agent, \ + patch.object(main, "fetch_price", return_value="$NVDA: $1.00") as fetch_price: + main.handle_message(self.stream_msg("@**slop-bot** summarize $NVDA")) + + ask_agent.assert_called_once() + self.assertEqual(ask_agent.call_args.args[1], "summarize $NVDA") + fetch_price.assert_called_once_with("NVDA") + self.assertEqual(sent[0]["content"], "agent reply\n\n$NVDA: $1.00") + + def test_ignores_self_and_irrelevant_messages(self): + main.client = Mock(email="slop-bot@example.com") + main.handle_message(self.stream_msg("$NVDA", sender_email="slop-bot@example.com")) + main.handle_message(self.stream_msg("nothing to do here")) + main.client.send_message.assert_not_called() + + def test_private_reply_goes_to_sender_email(self): + sent = [] + main.client = Mock(email="slop-bot@example.com") + main.client.send_message.side_effect = sent.append + msg = self.stream_msg("$NVDA", type="private", sender_email="alice@example.com") + + with patch.object(main, "fetch_price", return_value="$NVDA: $1.00"): + main.handle_message(msg) + + self.assertEqual(sent[0]["type"], "private") + self.assertEqual(sent[0]["to"], "alice@example.com") + + def test_handle_event_submits_message_work(self): + submitted = [] + main.executor = Mock() + main.executor.submit.side_effect = lambda fn, msg: submitted.append((fn, msg)) + msg = self.stream_msg("$NVDA") + + main.handle_event({"type": "message", "message": msg}) + main.handle_event({"type": "heartbeat"}) + + self.assertEqual(submitted, [(main.handle_message, msg)]) + + def test_agent_debug_streams_stderr_to_journal(self): + with patch.object(main.subprocess, "run") as run: + run.return_value = Mock(returncode=0, stdout="ok\n", stderr=None) + with patch.object(main, "AGENT_DEBUG", True): + rc, out, err = main._agent("hello") + + self.assertEqual((rc, out, err), (0, "ok", "")) + kwargs = run.call_args.kwargs + command = run.call_args.args[0] + self.assertIn("--debug", command) + self.assertIsNone(kwargs["stderr"]) + + +if __name__ == "__main__": + unittest.main() -- cgit v1.2.3