switchroom 0.12.26 → 0.12.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +80 -80
- package/dist/auth-broker/index.js +80 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +359 -357
- package/dist/host-control/main.js +99 -99
- package/dist/vault/approvals/kernel-server.js +82 -82
- package/dist/vault/broker/server.js +83 -83
- package/package.json +2 -1
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +368 -209
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +55 -40
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +188 -0
- package/telegram-plugin/stderr-timestamps.ts +106 -0
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +240 -0
- package/telegram-plugin/tests/stderr-timestamps.test.ts +113 -0
- package/vendor/hindsight-memory/.claude-plugin/plugin.json +8 -0
- package/vendor/hindsight-memory/CHANGELOG.md +32 -0
- package/vendor/hindsight-memory/LICENSE +21 -0
- package/vendor/hindsight-memory/README.md +329 -0
- package/vendor/hindsight-memory/hooks/hooks.json +49 -0
- package/vendor/hindsight-memory/scripts/drain_pending.py +190 -0
- package/vendor/hindsight-memory/scripts/lib/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/lib/bank.py +122 -0
- package/vendor/hindsight-memory/scripts/lib/client.py +204 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +180 -0
- package/vendor/hindsight-memory/scripts/lib/content.py +493 -0
- package/vendor/hindsight-memory/scripts/lib/daemon.py +334 -0
- package/vendor/hindsight-memory/scripts/lib/directives.py +119 -0
- package/vendor/hindsight-memory/scripts/lib/gateway_ipc.py +126 -0
- package/vendor/hindsight-memory/scripts/lib/llm.py +146 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +218 -0
- package/vendor/hindsight-memory/scripts/lib/state.py +196 -0
- package/vendor/hindsight-memory/scripts/recall.py +873 -0
- package/vendor/hindsight-memory/scripts/retain.py +286 -0
- package/vendor/hindsight-memory/scripts/session_end.py +122 -0
- package/vendor/hindsight-memory/scripts/session_start.py +76 -0
- package/vendor/hindsight-memory/scripts/setup_hooks.py +115 -0
- package/vendor/hindsight-memory/scripts/tests/__init__.py +0 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +211 -0
- package/vendor/hindsight-memory/scripts/tests/test_gateway_ipc.py +205 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +621 -0
- package/vendor/hindsight-memory/settings.json +37 -0
- package/vendor/hindsight-memory/skills/setup.md +24 -0
- package/vendor/hindsight-memory/tests/conftest.py +94 -0
- package/vendor/hindsight-memory/tests/test_bank.py +142 -0
- package/vendor/hindsight-memory/tests/test_client.py +232 -0
- package/vendor/hindsight-memory/tests/test_config.py +128 -0
- package/vendor/hindsight-memory/tests/test_content.py +471 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +192 -0
- package/vendor/hindsight-memory/tests/test_hooks.py +808 -0
- package/vendor/hindsight-memory/tests/test_manifest.py +14 -0
- package/vendor/hindsight-memory/tests/test_pending.py +152 -0
- package/vendor/hindsight-memory/tests/test_recall_exit_codes.py +325 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +205 -0
- package/vendor/hindsight-memory/tests/test_state.py +125 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Tests for lib/directives.py (active-directives fetch + format).
|
|
2
|
+
|
|
3
|
+
Stdlib-only (unittest) — matches the rest of the hindsight-memory scripts,
|
|
4
|
+
which deliberately avoid third-party dependencies so the hooks run on a
|
|
5
|
+
bare Python install.
|
|
6
|
+
|
|
7
|
+
Run from the repo root:
|
|
8
|
+
python -m unittest discover -s vendor/hindsight-memory/scripts/tests -v
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import unittest
|
|
14
|
+
from io import StringIO
|
|
15
|
+
from unittest.mock import patch
|
|
16
|
+
|
|
17
|
+
# Ensure the scripts dir is importable so `lib.*` resolves.
|
|
18
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
19
|
+
if SCRIPTS_DIR not in sys.path:
|
|
20
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
21
|
+
|
|
22
|
+
from lib.directives import ( # noqa: E402
|
|
23
|
+
MAX_DIRECTIVES,
|
|
24
|
+
fetch_active_directives,
|
|
25
|
+
format_active_directives_block,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class _StubClient:
|
|
30
|
+
"""Minimal HindsightClient stand-in for tests.
|
|
31
|
+
|
|
32
|
+
Captures the args list_directives was called with and returns either
|
|
33
|
+
a canned response or raises a configured exception.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, response=None, exc=None):
|
|
37
|
+
self._response = response
|
|
38
|
+
self._exc = exc
|
|
39
|
+
self.calls = []
|
|
40
|
+
|
|
41
|
+
def list_directives(self, bank_id, active_only=True, timeout=2):
|
|
42
|
+
self.calls.append({"bank_id": bank_id, "active_only": active_only, "timeout": timeout})
|
|
43
|
+
if self._exc is not None:
|
|
44
|
+
raise self._exc
|
|
45
|
+
return self._response
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _directive(name, content, priority=5, tags=None):
|
|
49
|
+
"""Build a synthetic directive dict matching the API shape."""
|
|
50
|
+
return {
|
|
51
|
+
"id": f"id-{name}",
|
|
52
|
+
"bank_id": "test-bank",
|
|
53
|
+
"name": name,
|
|
54
|
+
"content": content,
|
|
55
|
+
"priority": priority,
|
|
56
|
+
"is_active": True,
|
|
57
|
+
"tags": tags or [],
|
|
58
|
+
"created_at": "2026-01-01T00:00:00+00:00",
|
|
59
|
+
"updated_at": "2026-01-01T00:00:00+00:00",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FetchActiveDirectivesTests(unittest.TestCase):
|
|
64
|
+
def test_returns_priority_sorted_descending(self):
|
|
65
|
+
client = _StubClient(
|
|
66
|
+
response={
|
|
67
|
+
"items": [
|
|
68
|
+
_directive("low", "low content", priority=1),
|
|
69
|
+
_directive("high", "high content", priority=10),
|
|
70
|
+
_directive("mid", "mid content", priority=5),
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
result = fetch_active_directives(client, "test-bank")
|
|
75
|
+
self.assertEqual([d["name"] for d in result], ["high", "mid", "low"])
|
|
76
|
+
|
|
77
|
+
def test_passes_active_only_true(self):
|
|
78
|
+
client = _StubClient(response={"items": []})
|
|
79
|
+
fetch_active_directives(client, "test-bank")
|
|
80
|
+
self.assertEqual(len(client.calls), 1)
|
|
81
|
+
self.assertTrue(client.calls[0]["active_only"])
|
|
82
|
+
self.assertEqual(client.calls[0]["bank_id"], "test-bank")
|
|
83
|
+
|
|
84
|
+
def test_empty_items_returns_empty_list(self):
|
|
85
|
+
client = _StubClient(response={"items": []})
|
|
86
|
+
self.assertEqual(fetch_active_directives(client, "test-bank"), [])
|
|
87
|
+
|
|
88
|
+
def test_http_failure_returns_empty_and_warns(self):
|
|
89
|
+
client = _StubClient(exc=RuntimeError("HTTP 503 from /directives"))
|
|
90
|
+
with patch("sys.stderr", new=StringIO()) as fake_err:
|
|
91
|
+
result = fetch_active_directives(client, "test-bank")
|
|
92
|
+
self.assertEqual(result, [])
|
|
93
|
+
err_output = fake_err.getvalue()
|
|
94
|
+
self.assertIn("list_directives failed", err_output)
|
|
95
|
+
self.assertIn("test-bank", err_output)
|
|
96
|
+
|
|
97
|
+
def test_timeout_exception_returns_empty_no_raise(self):
|
|
98
|
+
client = _StubClient(exc=TimeoutError("timed out"))
|
|
99
|
+
with patch("sys.stderr", new=StringIO()):
|
|
100
|
+
# Must not raise.
|
|
101
|
+
result = fetch_active_directives(client, "test-bank")
|
|
102
|
+
self.assertEqual(result, [])
|
|
103
|
+
|
|
104
|
+
def test_non_dict_response_returns_empty_and_warns(self):
|
|
105
|
+
client = _StubClient(response=["not", "a", "dict"])
|
|
106
|
+
with patch("sys.stderr", new=StringIO()) as fake_err:
|
|
107
|
+
result = fetch_active_directives(client, "test-bank")
|
|
108
|
+
self.assertEqual(result, [])
|
|
109
|
+
self.assertIn("non-dict", fake_err.getvalue())
|
|
110
|
+
|
|
111
|
+
def test_missing_items_key_returns_empty_quietly(self):
|
|
112
|
+
# Banks-with-no-directives is a normal state, not a warn-worthy event.
|
|
113
|
+
client = _StubClient(response={"unrelated": "shape"})
|
|
114
|
+
with patch("sys.stderr", new=StringIO()) as fake_err:
|
|
115
|
+
result = fetch_active_directives(client, "test-bank")
|
|
116
|
+
self.assertEqual(result, [])
|
|
117
|
+
self.assertEqual(fake_err.getvalue(), "")
|
|
118
|
+
|
|
119
|
+
def test_malformed_directive_entries_filtered(self):
|
|
120
|
+
client = _StubClient(
|
|
121
|
+
response={
|
|
122
|
+
"items": [
|
|
123
|
+
_directive("ok", "real content", priority=5),
|
|
124
|
+
"not-a-dict",
|
|
125
|
+
None,
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
result = fetch_active_directives(client, "test-bank")
|
|
130
|
+
self.assertEqual(len(result), 1)
|
|
131
|
+
self.assertEqual(result[0]["name"], "ok")
|
|
132
|
+
|
|
133
|
+
def test_missing_priority_treated_as_zero(self):
|
|
134
|
+
client = _StubClient(
|
|
135
|
+
response={
|
|
136
|
+
"items": [
|
|
137
|
+
_directive("has-priority", "content", priority=3),
|
|
138
|
+
{"name": "no-priority", "content": "content"},
|
|
139
|
+
]
|
|
140
|
+
}
|
|
141
|
+
)
|
|
142
|
+
result = fetch_active_directives(client, "test-bank")
|
|
143
|
+
self.assertEqual([d["name"] for d in result], ["has-priority", "no-priority"])
|
|
144
|
+
|
|
145
|
+
def test_uses_short_timeout(self):
|
|
146
|
+
# The recall hook is on the UserPromptSubmit critical path —
|
|
147
|
+
# directive fetch must not block it.
|
|
148
|
+
client = _StubClient(response={"items": []})
|
|
149
|
+
fetch_active_directives(client, "test-bank")
|
|
150
|
+
self.assertLessEqual(client.calls[0]["timeout"], 5)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class FormatActiveDirectivesBlockTests(unittest.TestCase):
|
|
154
|
+
def test_returns_none_for_empty_list(self):
|
|
155
|
+
self.assertIsNone(format_active_directives_block([]))
|
|
156
|
+
|
|
157
|
+
def test_formats_multiple_directives(self):
|
|
158
|
+
directives = [
|
|
159
|
+
_directive("trailer", "End every response with: [VERIFIED]", priority=10),
|
|
160
|
+
_directive("greeting", "Open with the user's first name.", priority=8),
|
|
161
|
+
]
|
|
162
|
+
out = format_active_directives_block(directives)
|
|
163
|
+
self.assertIsNotNone(out)
|
|
164
|
+
self.assertTrue(out.startswith("<active_directives>"))
|
|
165
|
+
self.assertTrue(out.endswith("</active_directives>"))
|
|
166
|
+
self.assertIn("HARD RULES", out)
|
|
167
|
+
self.assertIn("1. [P10] trailer: End every response with: [VERIFIED]", out)
|
|
168
|
+
self.assertIn("2. [P8] greeting: Open with the user's first name.", out)
|
|
169
|
+
|
|
170
|
+
def test_content_is_verbatim(self):
|
|
171
|
+
directives = [_directive("verbatim", "Line one.\nLine two.\nLine three.", priority=5)]
|
|
172
|
+
out = format_active_directives_block(directives)
|
|
173
|
+
self.assertIn("Line one.\nLine two.\nLine three.", out)
|
|
174
|
+
|
|
175
|
+
def test_truncates_at_cap_with_footer(self):
|
|
176
|
+
# 20 synthetic directives — should truncate to MAX_DIRECTIVES with
|
|
177
|
+
# a "(+N more, omitted)" footer.
|
|
178
|
+
directives = [
|
|
179
|
+
_directive(f"d{i}", f"content {i}", priority=20 - i) for i in range(20)
|
|
180
|
+
]
|
|
181
|
+
out = format_active_directives_block(directives)
|
|
182
|
+
# Cap should be 15 by default.
|
|
183
|
+
self.assertEqual(MAX_DIRECTIVES, 15)
|
|
184
|
+
self.assertIn("1. [P20] d0", out)
|
|
185
|
+
self.assertIn(f"{MAX_DIRECTIVES}. [P", out)
|
|
186
|
+
# 16th item should NOT appear.
|
|
187
|
+
self.assertNotIn(f"{MAX_DIRECTIVES + 1}. [P", out)
|
|
188
|
+
# Footer with the right omitted count.
|
|
189
|
+
self.assertIn(f"(+{20 - MAX_DIRECTIVES} more, omitted)", out)
|
|
190
|
+
|
|
191
|
+
def test_no_footer_when_under_cap(self):
|
|
192
|
+
directives = [_directive("only", "single", priority=5)]
|
|
193
|
+
out = format_active_directives_block(directives)
|
|
194
|
+
self.assertNotIn("more, omitted", out)
|
|
195
|
+
|
|
196
|
+
def test_handles_missing_name_and_content(self):
|
|
197
|
+
directives = [{"priority": 7}]
|
|
198
|
+
out = format_active_directives_block(directives)
|
|
199
|
+
self.assertIn("[P7] (unnamed):", out)
|
|
200
|
+
|
|
201
|
+
def test_custom_cap_respected(self):
|
|
202
|
+
directives = [_directive(f"d{i}", f"c{i}", priority=10) for i in range(5)]
|
|
203
|
+
out = format_active_directives_block(directives, max_directives=2)
|
|
204
|
+
self.assertIn("1. [P10] d0", out)
|
|
205
|
+
self.assertIn("2. [P10] d1", out)
|
|
206
|
+
self.assertNotIn("3. [P10] d2", out)
|
|
207
|
+
self.assertIn("(+3 more, omitted)", out)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
if __name__ == "__main__":
|
|
211
|
+
unittest.main()
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Unit tests for lib/gateway_ipc.py.
|
|
2
|
+
|
|
3
|
+
Covers:
|
|
4
|
+
- chat_id extraction from <channel ...> wrapper (various attribute orders,
|
|
5
|
+
quote styles, no-channel prompts).
|
|
6
|
+
- socket-path resolution (env override + CLAUDE_PLUGIN_DATA fallback).
|
|
7
|
+
- update_placeholder happy path (one JSON line written, valid shape).
|
|
8
|
+
- update_placeholder failure paths (no socket, refused, timeout) all
|
|
9
|
+
silent — return False but never raise.
|
|
10
|
+
|
|
11
|
+
Stdlib-only.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import socket
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import unittest
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
SCRIPTS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
23
|
+
if SCRIPTS_DIR not in sys.path:
|
|
24
|
+
sys.path.insert(0, SCRIPTS_DIR)
|
|
25
|
+
|
|
26
|
+
from lib.gateway_ipc import ( # noqa: E402
|
|
27
|
+
extract_chat_id_from_prompt,
|
|
28
|
+
gateway_socket_path,
|
|
29
|
+
update_placeholder,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ExtractChatIdTests(unittest.TestCase):
|
|
34
|
+
def test_double_quoted_attribute(self):
|
|
35
|
+
prompt = '<channel source="switchroom-telegram" chat_id="12345" thread_id="-">\nhi\n</channel>'
|
|
36
|
+
self.assertEqual(extract_chat_id_from_prompt(prompt), "12345")
|
|
37
|
+
|
|
38
|
+
def test_single_quoted_attribute(self):
|
|
39
|
+
prompt = "<channel source='switchroom-telegram' chat_id='12345' user_id='99'>\nhi\n</channel>"
|
|
40
|
+
self.assertEqual(extract_chat_id_from_prompt(prompt), "12345")
|
|
41
|
+
|
|
42
|
+
def test_negative_group_chat_id(self):
|
|
43
|
+
prompt = '<channel source="switchroom-telegram" chat_id="-1001234567890">\nhi\n</channel>'
|
|
44
|
+
self.assertEqual(extract_chat_id_from_prompt(prompt), "-1001234567890")
|
|
45
|
+
|
|
46
|
+
def test_attribute_order_doesnt_matter(self):
|
|
47
|
+
prompt = '<channel chat_id="999" source="switchroom-telegram" user="x">\nhi\n</channel>'
|
|
48
|
+
self.assertEqual(extract_chat_id_from_prompt(prompt), "999")
|
|
49
|
+
|
|
50
|
+
def test_no_channel_wrapper_returns_none(self):
|
|
51
|
+
self.assertIsNone(extract_chat_id_from_prompt("plain user prompt"))
|
|
52
|
+
|
|
53
|
+
def test_channel_without_chat_id_returns_none(self):
|
|
54
|
+
prompt = '<channel source="x" user_id="1">hi</channel>'
|
|
55
|
+
self.assertIsNone(extract_chat_id_from_prompt(prompt))
|
|
56
|
+
|
|
57
|
+
def test_empty_chat_id_returns_none(self):
|
|
58
|
+
prompt = '<channel chat_id="">hi</channel>'
|
|
59
|
+
self.assertIsNone(extract_chat_id_from_prompt(prompt))
|
|
60
|
+
|
|
61
|
+
def test_non_string_input(self):
|
|
62
|
+
self.assertIsNone(extract_chat_id_from_prompt(None)) # type: ignore[arg-type]
|
|
63
|
+
self.assertIsNone(extract_chat_id_from_prompt(""))
|
|
64
|
+
self.assertIsNone(extract_chat_id_from_prompt(12345)) # type: ignore[arg-type]
|
|
65
|
+
|
|
66
|
+
def test_only_inspects_first_kb(self):
|
|
67
|
+
# Pad with content BEFORE the channel wrapper; the regex shouldn't
|
|
68
|
+
# find it because we only inspect the first 1 KB.
|
|
69
|
+
prompt = ("x" * 2000) + '<channel chat_id="111">hi</channel>'
|
|
70
|
+
self.assertIsNone(extract_chat_id_from_prompt(prompt))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class GatewaySocketPathTests(unittest.TestCase):
|
|
74
|
+
def setUp(self):
|
|
75
|
+
self._saved = {
|
|
76
|
+
"SWITCHROOM_GATEWAY_SOCKET": os.environ.get("SWITCHROOM_GATEWAY_SOCKET"),
|
|
77
|
+
"CLAUDE_PLUGIN_DATA": os.environ.get("CLAUDE_PLUGIN_DATA"),
|
|
78
|
+
}
|
|
79
|
+
# Always start clean.
|
|
80
|
+
os.environ.pop("SWITCHROOM_GATEWAY_SOCKET", None)
|
|
81
|
+
os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
82
|
+
|
|
83
|
+
def tearDown(self):
|
|
84
|
+
for k, v in self._saved.items():
|
|
85
|
+
if v is None:
|
|
86
|
+
os.environ.pop(k, None)
|
|
87
|
+
else:
|
|
88
|
+
os.environ[k] = v
|
|
89
|
+
|
|
90
|
+
def test_explicit_env_override_wins(self):
|
|
91
|
+
os.environ["SWITCHROOM_GATEWAY_SOCKET"] = "/tmp/explicit.sock"
|
|
92
|
+
self.assertEqual(gateway_socket_path(), "/tmp/explicit.sock")
|
|
93
|
+
|
|
94
|
+
def test_env_override_with_only_whitespace_falls_through(self):
|
|
95
|
+
os.environ["SWITCHROOM_GATEWAY_SOCKET"] = " "
|
|
96
|
+
# No CLAUDE_PLUGIN_DATA set → returns None.
|
|
97
|
+
self.assertIsNone(gateway_socket_path())
|
|
98
|
+
|
|
99
|
+
def test_resolves_from_plugin_data_when_socket_exists(self):
|
|
100
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
101
|
+
agent_dir = os.path.join(tmp, "myagent")
|
|
102
|
+
plugin_data = os.path.join(
|
|
103
|
+
agent_dir, ".claude", "plugins", "data", "hindsight-memory-inline"
|
|
104
|
+
)
|
|
105
|
+
os.makedirs(plugin_data, exist_ok=True)
|
|
106
|
+
tg_dir = os.path.join(agent_dir, "telegram")
|
|
107
|
+
os.makedirs(tg_dir, exist_ok=True)
|
|
108
|
+
sock_path = os.path.join(tg_dir, "gateway.sock")
|
|
109
|
+
# Create a sentinel file so existence check passes.
|
|
110
|
+
open(sock_path, "w").close()
|
|
111
|
+
|
|
112
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = plugin_data
|
|
113
|
+
self.assertEqual(gateway_socket_path(), sock_path)
|
|
114
|
+
|
|
115
|
+
def test_returns_none_when_socket_does_not_exist(self):
|
|
116
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
117
|
+
plugin_data = os.path.join(
|
|
118
|
+
tmp, "agent", ".claude", "plugins", "data", "hindsight-memory-inline"
|
|
119
|
+
)
|
|
120
|
+
os.makedirs(plugin_data, exist_ok=True)
|
|
121
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = plugin_data
|
|
122
|
+
self.assertIsNone(gateway_socket_path())
|
|
123
|
+
|
|
124
|
+
def test_no_env_no_path(self):
|
|
125
|
+
self.assertIsNone(gateway_socket_path())
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class UpdatePlaceholderHappyPathTests(unittest.TestCase):
|
|
129
|
+
"""Spin up a real unix socket server, send a placeholder update,
|
|
130
|
+
assert the message we sent matches the wire protocol contract."""
|
|
131
|
+
|
|
132
|
+
def test_writes_one_json_line_with_correct_shape(self):
|
|
133
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
134
|
+
sock_path = os.path.join(tmp, "test.sock")
|
|
135
|
+
received: list[bytes] = []
|
|
136
|
+
ready = threading.Event()
|
|
137
|
+
|
|
138
|
+
def server():
|
|
139
|
+
srv = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
140
|
+
srv.bind(sock_path)
|
|
141
|
+
srv.listen(1)
|
|
142
|
+
ready.set()
|
|
143
|
+
conn, _ = srv.accept()
|
|
144
|
+
conn.settimeout(1.0)
|
|
145
|
+
try:
|
|
146
|
+
chunk = conn.recv(4096)
|
|
147
|
+
if chunk:
|
|
148
|
+
received.append(chunk)
|
|
149
|
+
finally:
|
|
150
|
+
conn.close()
|
|
151
|
+
srv.close()
|
|
152
|
+
|
|
153
|
+
t = threading.Thread(target=server, daemon=True)
|
|
154
|
+
t.start()
|
|
155
|
+
ready.wait(timeout=1.0)
|
|
156
|
+
|
|
157
|
+
ok = update_placeholder(
|
|
158
|
+
"12345",
|
|
159
|
+
"📚 recalling memories…",
|
|
160
|
+
socket_path=sock_path,
|
|
161
|
+
)
|
|
162
|
+
t.join(timeout=1.0)
|
|
163
|
+
|
|
164
|
+
self.assertTrue(ok)
|
|
165
|
+
self.assertEqual(len(received), 1)
|
|
166
|
+
line = received[0].decode("utf-8")
|
|
167
|
+
self.assertTrue(line.endswith("\n"))
|
|
168
|
+
payload = json.loads(line)
|
|
169
|
+
self.assertEqual(payload["type"], "update_placeholder")
|
|
170
|
+
self.assertEqual(payload["chatId"], "12345")
|
|
171
|
+
self.assertEqual(payload["text"], "📚 recalling memories…")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class UpdatePlaceholderFailureTests(unittest.TestCase):
|
|
175
|
+
"""Every failure path returns False — never raises."""
|
|
176
|
+
|
|
177
|
+
def test_no_socket_path_returns_false(self):
|
|
178
|
+
# No socket_path arg, no env override, no plugin data → resolves None.
|
|
179
|
+
prev = os.environ.pop("SWITCHROOM_GATEWAY_SOCKET", None)
|
|
180
|
+
prev_data = os.environ.pop("CLAUDE_PLUGIN_DATA", None)
|
|
181
|
+
try:
|
|
182
|
+
self.assertFalse(update_placeholder("123", "x"))
|
|
183
|
+
finally:
|
|
184
|
+
if prev is not None:
|
|
185
|
+
os.environ["SWITCHROOM_GATEWAY_SOCKET"] = prev
|
|
186
|
+
if prev_data is not None:
|
|
187
|
+
os.environ["CLAUDE_PLUGIN_DATA"] = prev_data
|
|
188
|
+
|
|
189
|
+
def test_socket_does_not_exist_returns_false(self):
|
|
190
|
+
# Path is provided but the socket file isn't there.
|
|
191
|
+
self.assertFalse(update_placeholder("123", "x", socket_path="/nonexistent/sock"))
|
|
192
|
+
|
|
193
|
+
def test_empty_chat_id_returns_false(self):
|
|
194
|
+
self.assertFalse(update_placeholder("", "x", socket_path="/tmp/whatever"))
|
|
195
|
+
|
|
196
|
+
def test_empty_text_returns_false(self):
|
|
197
|
+
self.assertFalse(update_placeholder("123", "", socket_path="/tmp/whatever"))
|
|
198
|
+
|
|
199
|
+
def test_non_string_inputs_return_false(self):
|
|
200
|
+
self.assertFalse(update_placeholder(None, "x", socket_path="/tmp/x")) # type: ignore[arg-type]
|
|
201
|
+
self.assertFalse(update_placeholder("123", None, socket_path="/tmp/x")) # type: ignore[arg-type]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
unittest.main()
|