summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2026-06-04 16:38:54 -0400
committerBen Sima <ben@bensima.com>2026-06-04 16:38:54 -0400
commitfe04ad47c9abf843cb6f64176981325a5b05f074 (patch)
tree212dc37a6ed0a16eebc72f3ab06b35949b09b71b
parent11fd65b015dc6e24127f3cfdcb08975704381d46 (diff)
Add tests and fix research tools
-rw-r--r--.env.example1
-rw-r--r--README.md12
-rwxr-xr-xdeploy.sh4
-rw-r--r--flake.nix51
-rw-r--r--main.py25
-rwxr-xr-xscripts/install-git-hooks.sh6
-rwxr-xr-xscripts/pre-commit4
-rwxr-xr-xtest.sh5
-rw-r--r--tests/test_main.py102
9 files changed, 191 insertions, 19 deletions
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()