agent-chat-plugin 0.2.1__tar.gz → 0.3.0__tar.gz
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.
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/PKG-INFO +1 -1
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/PKG-INFO +1 -1
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/chat.py +118 -29
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/pyproject.toml +1 -1
- agent_chat_plugin-0.3.0/tests/test_chat.py +324 -0
- agent_chat_plugin-0.2.1/tests/test_chat.py +0 -132
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/LICENSE +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/README.md +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/SOURCES.txt +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/dependency_links.txt +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/entry_points.txt +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/top_level.txt +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/setup.cfg +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/tests/test_hooks.py +0 -0
- {agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/tests/test_plugin_manifest.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-chat-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Peer AI agents coordinate through markdown messages in shared channel folders — no orchestrator, autonomous zero-token waiting, cross-platform.
|
|
5
5
|
Author: n24q02m
|
|
6
6
|
License: Apache-2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: agent-chat-plugin
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Peer AI agents coordinate through markdown messages in shared channel folders — no orchestrator, autonomous zero-token waiting, cross-platform.
|
|
5
5
|
Author: n24q02m
|
|
6
6
|
License: Apache-2.0
|
|
@@ -30,6 +30,8 @@ import sys
|
|
|
30
30
|
import time
|
|
31
31
|
from pathlib import Path
|
|
32
32
|
|
|
33
|
+
import heapq
|
|
34
|
+
|
|
33
35
|
# --- root + small helpers ----------------------------------------------------
|
|
34
36
|
|
|
35
37
|
|
|
@@ -56,6 +58,10 @@ def _frontmatter_value(value) -> str:
|
|
|
56
58
|
return re.sub(r"[\r\n]+", " ", str(value))
|
|
57
59
|
|
|
58
60
|
|
|
61
|
+
class AgentChatError(Exception):
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
|
|
59
65
|
def die(msg: str, code: int = 1):
|
|
60
66
|
print(f"agent-chat: {msg}", file=sys.stderr)
|
|
61
67
|
raise SystemExit(code)
|
|
@@ -66,8 +72,13 @@ def die(msg: str, code: int = 1):
|
|
|
66
72
|
|
|
67
73
|
def _check_safe_name(name: str, kind: str):
|
|
68
74
|
"""Prevent path traversal vulnerabilities."""
|
|
69
|
-
if not name or "/" in name or "\\" in name or name in (".", ".."):
|
|
70
|
-
|
|
75
|
+
if not name or "/" in name or "\\" in name or ":" in name or name in (".", ".."):
|
|
76
|
+
raise AgentChatError(f"invalid {kind} name (path traversal blocked): '{name}'")
|
|
77
|
+
if name.startswith(".") or name.startswith("_"):
|
|
78
|
+
raise AgentChatError(f"invalid {kind} name (reserved prefix blocked): '{name}'")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_TASK_MARKER_RE = re.compile(r"task-[A-Za-z0-9][A-Za-z0-9_-]*\.md")
|
|
71
82
|
|
|
72
83
|
|
|
73
84
|
def channel_dir(root: Path, channel: str) -> Path:
|
|
@@ -78,7 +89,9 @@ def channel_dir(root: Path, channel: str) -> Path:
|
|
|
78
89
|
def require_channel(root: Path, channel: str) -> Path:
|
|
79
90
|
d = channel_dir(root, channel)
|
|
80
91
|
if not (d / "_meta.json").exists():
|
|
81
|
-
|
|
92
|
+
raise AgentChatError(
|
|
93
|
+
f"channel '{channel}' not found under {root} (run: init {channel})"
|
|
94
|
+
)
|
|
82
95
|
return d
|
|
83
96
|
|
|
84
97
|
|
|
@@ -117,7 +130,7 @@ def parse_frontmatter(path: Path) -> dict:
|
|
|
117
130
|
if not found_end:
|
|
118
131
|
return meta
|
|
119
132
|
meta = temp_meta
|
|
120
|
-
except OSError:
|
|
133
|
+
except (OSError, UnicodeDecodeError):
|
|
121
134
|
return meta
|
|
122
135
|
# Normalize `to` -> list of recipients (empty == everyone).
|
|
123
136
|
raw = meta.get("to", "").strip()
|
|
@@ -163,7 +176,9 @@ def _acquire_lock(chan: Path, timeout: float = 10.0, stale: float = 30.0) -> Pat
|
|
|
163
176
|
except FileNotFoundError:
|
|
164
177
|
continue
|
|
165
178
|
if time.time() - start > timeout:
|
|
166
|
-
|
|
179
|
+
raise AgentChatError(
|
|
180
|
+
"could not acquire channel seq lock (another poster is stuck?)"
|
|
181
|
+
)
|
|
167
182
|
time.sleep(0.05)
|
|
168
183
|
|
|
169
184
|
|
|
@@ -222,7 +237,7 @@ def cmd_init(root: Path, a):
|
|
|
222
237
|
(d / ".cursors").mkdir(exist_ok=True)
|
|
223
238
|
meta_path = d / "_meta.json"
|
|
224
239
|
if meta_path.exists():
|
|
225
|
-
|
|
240
|
+
raise AgentChatError(f"channel '{a.channel}' already exists")
|
|
226
241
|
members = [m.strip() for m in (a.members or "").split(",") if m.strip()]
|
|
227
242
|
meta_path.write_text(
|
|
228
243
|
json.dumps(
|
|
@@ -236,7 +251,8 @@ def cmd_init(root: Path, a):
|
|
|
236
251
|
),
|
|
237
252
|
encoding="utf-8",
|
|
238
253
|
)
|
|
239
|
-
|
|
254
|
+
m_str = ", ".join(members) if members else "(open)"
|
|
255
|
+
print(f"created channel '{a.channel}' at {d} members={m_str}")
|
|
240
256
|
|
|
241
257
|
|
|
242
258
|
def cmd_channels(root: Path, a):
|
|
@@ -264,9 +280,15 @@ def cmd_channels(root: Path, a):
|
|
|
264
280
|
last = "-"
|
|
265
281
|
if last_path is not None:
|
|
266
282
|
lm = parse_frontmatter(last_path)
|
|
267
|
-
|
|
283
|
+
title = lm.get("title", "")
|
|
284
|
+
if len(title) > 40:
|
|
285
|
+
title = title[:37] + "..."
|
|
286
|
+
last = f"#{last_seq} {lm.get('from', '?')}: {title}"
|
|
287
|
+
members_str = ", ".join(meta.get("members", [])) or "(open)"
|
|
288
|
+
if len(members_str) > 40:
|
|
289
|
+
members_str = members_str[:37] + "..."
|
|
268
290
|
rows.append(
|
|
269
|
-
(chan.name,
|
|
291
|
+
(chan.name, members_str, count, last)
|
|
270
292
|
)
|
|
271
293
|
if not rows:
|
|
272
294
|
print(f"(no channels yet under {root})")
|
|
@@ -280,26 +302,34 @@ def cmd_channels(root: Path, a):
|
|
|
280
302
|
|
|
281
303
|
def cmd_roster(root: Path, a):
|
|
282
304
|
d = require_channel(root, a.channel)
|
|
283
|
-
|
|
305
|
+
try:
|
|
306
|
+
meta = json.loads((d / "_meta.json").read_text(encoding="utf-8"))
|
|
307
|
+
except (OSError, ValueError):
|
|
308
|
+
raise AgentChatError(f"could not read or parse _meta.json for channel '{a.channel}'")
|
|
284
309
|
print(f"channel : {meta.get('channel')}")
|
|
285
310
|
print(f"topic : {meta.get('topic') or '(none)'}")
|
|
286
311
|
print(f"members : {', '.join(meta.get('members', [])) or '(open)'}")
|
|
287
|
-
|
|
312
|
+
count = sum(1 for p in d.glob("*.md") if _seq_from_name(p.name) is not None)
|
|
313
|
+
print(f"messages: {count}")
|
|
288
314
|
|
|
289
315
|
|
|
290
316
|
def _read_body(a) -> str:
|
|
291
317
|
if a.body is not None:
|
|
292
318
|
return a.body
|
|
293
319
|
if a.body_file:
|
|
294
|
-
|
|
320
|
+
try:
|
|
321
|
+
return Path(a.body_file).read_text(encoding="utf-8")
|
|
322
|
+
except OSError as e:
|
|
323
|
+
raise AgentChatError(f"could not read body file: {e}")
|
|
295
324
|
# Default: read from stdin so agents can pipe long markdown bodies.
|
|
296
325
|
if sys.stdin.isatty():
|
|
297
326
|
print(
|
|
298
|
-
"agent-chat: Enter message body;
|
|
327
|
+
"agent-chat: Enter message body; press Ctrl-D (or Ctrl-Z and Enter on Windows) to finish.",
|
|
328
|
+
file=sys.stderr,
|
|
299
329
|
)
|
|
300
330
|
data = sys.stdin.read()
|
|
301
331
|
if not data.strip():
|
|
302
|
-
|
|
332
|
+
raise AgentChatError("empty body (pass --body, --body-file, or pipe via stdin)")
|
|
303
333
|
return data
|
|
304
334
|
|
|
305
335
|
|
|
@@ -349,16 +379,29 @@ def cmd_read(root: Path, a):
|
|
|
349
379
|
d = require_channel(root, a.channel)
|
|
350
380
|
cur = 0 if a.all else read_cursor(d, a.agent)
|
|
351
381
|
shown = 0
|
|
352
|
-
|
|
382
|
+
|
|
383
|
+
# Optimization: One O(N) glob scan to find both top seq and unread messages,
|
|
384
|
+
# avoiding O(N log N) message_files sort and redundant max_seq glob.
|
|
385
|
+
found = []
|
|
386
|
+
top = 0
|
|
387
|
+
for p in d.glob("*.md"):
|
|
353
388
|
seq = _seq_from_name(p.name)
|
|
354
|
-
if seq
|
|
389
|
+
if seq is None:
|
|
355
390
|
continue
|
|
391
|
+
if seq > top:
|
|
392
|
+
top = seq
|
|
393
|
+
if seq > cur:
|
|
394
|
+
found.append((seq, p))
|
|
395
|
+
|
|
396
|
+
found.sort(key=lambda x: x[0])
|
|
397
|
+
|
|
398
|
+
for seq, p in found:
|
|
356
399
|
meta = parse_frontmatter(p)
|
|
357
400
|
if not a.all and not is_relevant(meta, a.agent):
|
|
358
401
|
continue
|
|
359
402
|
_print_message(p)
|
|
360
403
|
shown += 1
|
|
361
|
-
|
|
404
|
+
|
|
362
405
|
if not a.peek:
|
|
363
406
|
write_cursor(d, a.agent, top)
|
|
364
407
|
if shown == 0:
|
|
@@ -371,14 +414,25 @@ def cmd_wait(root: Path, a):
|
|
|
371
414
|
deadline = time.time() + a.timeout
|
|
372
415
|
while True:
|
|
373
416
|
found = []
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
417
|
+
# Optimization: use os.scandir to avoid Path instantiation overhead for
|
|
418
|
+
# thousands of old messages per tick.
|
|
419
|
+
try:
|
|
420
|
+
with os.scandir(d) as it:
|
|
421
|
+
for entry in it:
|
|
422
|
+
if not entry.name.endswith(".md"):
|
|
423
|
+
continue
|
|
424
|
+
seq = _seq_from_name(entry.name)
|
|
425
|
+
if seq is None or seq <= cur:
|
|
426
|
+
continue
|
|
427
|
+
p = Path(entry.path)
|
|
428
|
+
meta = parse_frontmatter(p)
|
|
429
|
+
if is_relevant(meta, a.agent):
|
|
430
|
+
found.append(p)
|
|
431
|
+
except OSError:
|
|
432
|
+
pass
|
|
381
433
|
if found:
|
|
434
|
+
# Sort only the newly found messages
|
|
435
|
+
found.sort(key=lambda p: _seq_from_name(p.name))
|
|
382
436
|
for p in found:
|
|
383
437
|
_print_message(p)
|
|
384
438
|
write_cursor(d, a.agent, max_seq(d))
|
|
@@ -394,7 +448,24 @@ def cmd_wait(root: Path, a):
|
|
|
394
448
|
|
|
395
449
|
def cmd_peek(root: Path, a):
|
|
396
450
|
d = require_channel(root, a.channel)
|
|
397
|
-
|
|
451
|
+
|
|
452
|
+
if a.n <= 0:
|
|
453
|
+
return
|
|
454
|
+
|
|
455
|
+
# Optimization: Use a min-heap to find top N messages in O(N log K) time
|
|
456
|
+
# rather than sorting all messages O(N log N) via message_files()
|
|
457
|
+
top_n = []
|
|
458
|
+
for p in d.glob("*.md"):
|
|
459
|
+
seq = _seq_from_name(p.name)
|
|
460
|
+
if seq is not None:
|
|
461
|
+
if len(top_n) < a.n:
|
|
462
|
+
heapq.heappush(top_n, (seq, p))
|
|
463
|
+
elif seq > top_n[0][0]:
|
|
464
|
+
heapq.heapreplace(top_n, (seq, p))
|
|
465
|
+
|
|
466
|
+
# Extract in ascending order (heappop gets the smallest first)
|
|
467
|
+
files = [heapq.heappop(top_n)[1] for _ in range(len(top_n))]
|
|
468
|
+
|
|
398
469
|
for p in files:
|
|
399
470
|
_print_message(p)
|
|
400
471
|
if not files:
|
|
@@ -409,13 +480,25 @@ def cmd_claim(root: Path, a):
|
|
|
409
480
|
won the race -- exit non-zero so the caller moves on.
|
|
410
481
|
"""
|
|
411
482
|
_check_safe_name(a.task, "task")
|
|
483
|
+
if not _TASK_MARKER_RE.fullmatch(a.task):
|
|
484
|
+
raise AgentChatError(
|
|
485
|
+
f"invalid task name (expected task-<id>.md marker): '{a.task}'"
|
|
486
|
+
)
|
|
412
487
|
d = require_channel(root, a.channel)
|
|
413
488
|
src = d / a.task
|
|
414
489
|
dst = d / (Path(a.task).stem + f".CLAIMED-{slugify(a.agent)}.md")
|
|
490
|
+
lock = _acquire_lock(d)
|
|
415
491
|
try:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
492
|
+
if dst.exists():
|
|
493
|
+
die(f"task '{a.task}' already claimed or missing (lost the race)", code=3)
|
|
494
|
+
if not src.is_file():
|
|
495
|
+
die(f"task '{a.task}' already claimed or missing (lost the race)", code=3)
|
|
496
|
+
try:
|
|
497
|
+
os.replace(src, dst) # atomic on Windows + POSIX within the claim lock
|
|
498
|
+
except FileNotFoundError:
|
|
499
|
+
die(f"task '{a.task}' already claimed or missing (lost the race)", code=3)
|
|
500
|
+
finally:
|
|
501
|
+
_release_lock(lock)
|
|
419
502
|
print(f"claimed {a.task} -> {dst.name}")
|
|
420
503
|
|
|
421
504
|
|
|
@@ -491,7 +574,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
491
574
|
def main(argv=None):
|
|
492
575
|
args = build_parser().parse_args(argv)
|
|
493
576
|
root = root_dir(args.root)
|
|
494
|
-
|
|
577
|
+
try:
|
|
578
|
+
args.func(root, args)
|
|
579
|
+
except AgentChatError as e:
|
|
580
|
+
die(str(e))
|
|
581
|
+
except KeyboardInterrupt:
|
|
582
|
+
print(file=sys.stderr) # print a newline to cleanly break from input prompts
|
|
583
|
+
die("cancelled by user", code=130)
|
|
495
584
|
|
|
496
585
|
|
|
497
586
|
if __name__ == "__main__":
|
|
@@ -10,7 +10,7 @@ requires-python = ">=3.8"
|
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
11
11
|
authors = [{ name = "n24q02m" }]
|
|
12
12
|
keywords = ["multi-agent", "coordination", "markdown", "blackboard", "claude-code", "agent-skills", "peer-to-peer"]
|
|
13
|
-
version = "0.
|
|
13
|
+
version = "0.3.0" # managed by python-semantic-release; do not hand-edit
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Programming Language :: Python :: 3",
|
|
16
16
|
"License :: OSI Approved :: Apache Software License",
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Regression tests for the stdlib-only agent-chat CLI."""
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from types import SimpleNamespace
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
import chat
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _FakeStdin(io.StringIO):
|
|
16
|
+
def __init__(self, value, is_tty):
|
|
17
|
+
super().__init__(value)
|
|
18
|
+
self._is_tty = is_tty
|
|
19
|
+
|
|
20
|
+
def isatty(self):
|
|
21
|
+
return self._is_tty
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ChatRegressionTests(unittest.TestCase):
|
|
25
|
+
def setUp(self):
|
|
26
|
+
self.temp_dir = tempfile.TemporaryDirectory()
|
|
27
|
+
self.root = Path(self.temp_dir.name)
|
|
28
|
+
|
|
29
|
+
def tearDown(self):
|
|
30
|
+
self.temp_dir.cleanup()
|
|
31
|
+
|
|
32
|
+
def _channel(self, name):
|
|
33
|
+
channel = self.root / name
|
|
34
|
+
channel.mkdir()
|
|
35
|
+
(channel / "_meta.json").write_text(
|
|
36
|
+
json.dumps({"channel": name, "members": [], "topic": ""}),
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
)
|
|
39
|
+
return channel
|
|
40
|
+
|
|
41
|
+
def _post_args(self, **overrides):
|
|
42
|
+
values = {
|
|
43
|
+
"channel": "general",
|
|
44
|
+
"sender": "alice",
|
|
45
|
+
"to": "bob",
|
|
46
|
+
"title": "Status update",
|
|
47
|
+
"reply": None,
|
|
48
|
+
"status": "discussion",
|
|
49
|
+
"body": "Message body",
|
|
50
|
+
"body_file": None,
|
|
51
|
+
}
|
|
52
|
+
values.update(overrides)
|
|
53
|
+
return SimpleNamespace(**values)
|
|
54
|
+
|
|
55
|
+
def test_post_title_newlines_cannot_forge_frontmatter_fields(self):
|
|
56
|
+
"""A newline in a title must remain title content, never new metadata."""
|
|
57
|
+
channel = self._channel("general")
|
|
58
|
+
args = self._post_args(title="Status\nfrom: mallory\nto: eve")
|
|
59
|
+
|
|
60
|
+
with patch.object(chat, "now_iso", return_value="2026-08-09T00:00:00+00:00"):
|
|
61
|
+
with contextlib.redirect_stdout(io.StringIO()):
|
|
62
|
+
chat.cmd_post(self.root, args)
|
|
63
|
+
|
|
64
|
+
message = next(channel.glob("*.md"))
|
|
65
|
+
meta = chat.parse_frontmatter(message)
|
|
66
|
+
self.assertEqual(meta["from"], "alice")
|
|
67
|
+
self.assertEqual(meta["to"], "bob")
|
|
68
|
+
self.assertEqual(meta["title"], "Status from: mallory to: eve")
|
|
69
|
+
|
|
70
|
+
def test_max_seq_uses_highest_valid_sequence_with_gaps_and_malformed_files(self):
|
|
71
|
+
"""Only numbered message filenames affect the maximum sequence."""
|
|
72
|
+
channel = self._channel("general")
|
|
73
|
+
for name in (
|
|
74
|
+
"0002-alice-first.md",
|
|
75
|
+
"0010-bob-last.md",
|
|
76
|
+
"broken.md",
|
|
77
|
+
"12x-nope.md",
|
|
78
|
+
):
|
|
79
|
+
(channel / name).write_text("body", encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
self.assertEqual(chat.max_seq(channel), 10)
|
|
82
|
+
|
|
83
|
+
def test_channels_reports_counts_and_last_message_for_empty_and_gapped_channels(
|
|
84
|
+
self,
|
|
85
|
+
):
|
|
86
|
+
"""Channel summaries count valid messages and select the highest sequence."""
|
|
87
|
+
alpha = self._channel("alpha")
|
|
88
|
+
self._channel("beta")
|
|
89
|
+
(alpha / "0002-alice-first.md").write_text(
|
|
90
|
+
"---\nfrom: alice\ntitle: First\n---\nbody\n", encoding="utf-8"
|
|
91
|
+
)
|
|
92
|
+
(alpha / "0010-bob-last.md").write_text(
|
|
93
|
+
"---\nfrom: bob\ntitle: Latest update\n---\nbody\n", encoding="utf-8"
|
|
94
|
+
)
|
|
95
|
+
(alpha / "not-a-message.md").write_text("ignored", encoding="utf-8")
|
|
96
|
+
|
|
97
|
+
output = io.StringIO()
|
|
98
|
+
with contextlib.redirect_stdout(output):
|
|
99
|
+
chat.cmd_channels(self.root, SimpleNamespace())
|
|
100
|
+
|
|
101
|
+
rendered = output.getvalue()
|
|
102
|
+
self.assertIn("alpha", rendered)
|
|
103
|
+
self.assertIn(" 2", rendered)
|
|
104
|
+
self.assertIn("last: #10 bob: Latest update", rendered)
|
|
105
|
+
self.assertIn("beta", rendered)
|
|
106
|
+
self.assertIn("last: -", rendered)
|
|
107
|
+
|
|
108
|
+
def test_channels_marks_truncated_titles_with_ellipsis(self):
|
|
109
|
+
"""Long channel titles retain an ASCII marker after truncation."""
|
|
110
|
+
channel = self._channel("general")
|
|
111
|
+
(channel / "0001-bob-long-title.md").write_text(
|
|
112
|
+
"---\nseq: 1\nfrom: bob\ntitle: " + ("A" * 41) + "\n---\nbody\n",
|
|
113
|
+
encoding="utf-8",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
output = io.StringIO()
|
|
117
|
+
with contextlib.redirect_stdout(output):
|
|
118
|
+
chat.cmd_channels(self.root, SimpleNamespace())
|
|
119
|
+
|
|
120
|
+
self.assertIn("last: #1 bob: " + ("A" * 37) + "...", output.getvalue())
|
|
121
|
+
|
|
122
|
+
def test_channels_keeps_titles_at_the_display_limit(self):
|
|
123
|
+
"""Titles at the 40-character limit are not shortened."""
|
|
124
|
+
channel = self._channel("general")
|
|
125
|
+
title = "B" * 40
|
|
126
|
+
(channel / "0001-bob-boundary.md").write_text(
|
|
127
|
+
f"---\nseq: 1\nfrom: bob\ntitle: {title}\n---\nbody\n",
|
|
128
|
+
encoding="utf-8",
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
output = io.StringIO()
|
|
132
|
+
with contextlib.redirect_stdout(output):
|
|
133
|
+
chat.cmd_channels(self.root, SimpleNamespace())
|
|
134
|
+
|
|
135
|
+
self.assertIn(f"last: #1 bob: {title}", output.getvalue())
|
|
136
|
+
|
|
137
|
+
def test_init_formats_member_list_for_cli_output(self):
|
|
138
|
+
"""Init output uses a readable member list, not Python repr syntax."""
|
|
139
|
+
output = io.StringIO()
|
|
140
|
+
with contextlib.redirect_stdout(output):
|
|
141
|
+
chat.cmd_init(
|
|
142
|
+
self.root,
|
|
143
|
+
SimpleNamespace(channel="general", members="alice,bob", topic=None),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
rendered = output.getvalue()
|
|
147
|
+
self.assertIn("members=alice, bob", rendered)
|
|
148
|
+
self.assertNotIn("members=['alice', 'bob']", rendered)
|
|
149
|
+
|
|
150
|
+
def test_main_maps_missing_body_file_to_application_error(self):
|
|
151
|
+
"""A missing body file returns the CLI error contract, not a traceback."""
|
|
152
|
+
channel = self._channel("general")
|
|
153
|
+
missing = self.root / "missing.md"
|
|
154
|
+
stderr = io.StringIO()
|
|
155
|
+
with contextlib.redirect_stderr(stderr):
|
|
156
|
+
with self.assertRaises(SystemExit) as raised:
|
|
157
|
+
chat.main(
|
|
158
|
+
[
|
|
159
|
+
"--root",
|
|
160
|
+
str(self.root),
|
|
161
|
+
"post",
|
|
162
|
+
"general",
|
|
163
|
+
"--from",
|
|
164
|
+
"alice",
|
|
165
|
+
"--to",
|
|
166
|
+
"bob",
|
|
167
|
+
"--title",
|
|
168
|
+
"Missing",
|
|
169
|
+
"--body-file",
|
|
170
|
+
str(missing),
|
|
171
|
+
]
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
self.assertEqual(raised.exception.code, 1)
|
|
175
|
+
self.assertIn("could not read body file", stderr.getvalue())
|
|
176
|
+
self.assertEqual(list(channel.glob("*.md")), [])
|
|
177
|
+
|
|
178
|
+
def test_main_maps_keyboard_interrupt_to_cancelled_exit(self):
|
|
179
|
+
"""Ctrl-C is reported as a clean cancellation with exit code 130."""
|
|
180
|
+
stderr = io.StringIO()
|
|
181
|
+
with patch.object(chat, "cmd_init", side_effect=KeyboardInterrupt):
|
|
182
|
+
with contextlib.redirect_stderr(stderr):
|
|
183
|
+
with self.assertRaises(SystemExit) as raised:
|
|
184
|
+
chat.main(["--root", str(self.root), "init", "cancelled"])
|
|
185
|
+
|
|
186
|
+
self.assertEqual(raised.exception.code, 130)
|
|
187
|
+
self.assertIn("cancelled by user", stderr.getvalue())
|
|
188
|
+
|
|
189
|
+
def test_init_rejects_reserved_channel_prefixes(self):
|
|
190
|
+
"""User channels cannot collide with dotfiles or internal directories."""
|
|
191
|
+
for channel in ("_internal", ".hidden"):
|
|
192
|
+
with self.subTest(channel=channel), self.assertRaises(chat.AgentChatError):
|
|
193
|
+
chat.cmd_init(
|
|
194
|
+
self.root,
|
|
195
|
+
SimpleNamespace(channel=channel, members=None, topic=None),
|
|
196
|
+
)
|
|
197
|
+
self.assertFalse((self.root / channel).exists())
|
|
198
|
+
|
|
199
|
+
def test_read_preserves_sequence_order_and_advances_cursor(self):
|
|
200
|
+
"""Unread messages are rendered in sequence order and advance the cursor."""
|
|
201
|
+
channel = self._channel("general")
|
|
202
|
+
(channel / "0002-bob-second.md").write_text(
|
|
203
|
+
"---\nseq: 2\nfrom: bob\nto: alice\ntitle: Second\n---\nbody\n",
|
|
204
|
+
encoding="utf-8",
|
|
205
|
+
)
|
|
206
|
+
(channel / "0001-bob-first.md").write_text(
|
|
207
|
+
"---\nseq: 1\nfrom: bob\nto: alice\ntitle: First\n---\nbody\n",
|
|
208
|
+
encoding="utf-8",
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
output = io.StringIO()
|
|
212
|
+
with contextlib.redirect_stdout(output):
|
|
213
|
+
chat.cmd_read(
|
|
214
|
+
self.root,
|
|
215
|
+
SimpleNamespace(
|
|
216
|
+
channel="general", agent="alice", all=False, peek=False
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
rendered = output.getvalue()
|
|
221
|
+
self.assertLess(rendered.index("title: First"), rendered.index("title: Second"))
|
|
222
|
+
self.assertEqual((channel / ".cursors" / "alice.txt").read_text(), "2")
|
|
223
|
+
|
|
224
|
+
def test_peek_zero_messages_is_empty(self):
|
|
225
|
+
"""A zero-size peek must not index an empty heap."""
|
|
226
|
+
channel = self._channel("general")
|
|
227
|
+
(channel / "0001-bob-message.md").write_text(
|
|
228
|
+
"---\nseq: 1\nfrom: bob\ntitle: Message\n---\nbody\n",
|
|
229
|
+
encoding="utf-8",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
output = io.StringIO()
|
|
233
|
+
with contextlib.redirect_stdout(output):
|
|
234
|
+
chat.cmd_peek(
|
|
235
|
+
self.root,
|
|
236
|
+
SimpleNamespace(channel="general", n=0),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
self.assertEqual(output.getvalue(), "")
|
|
240
|
+
|
|
241
|
+
def test_claim_rejects_internal_channel_files(self):
|
|
242
|
+
"""Claim must not rename channel metadata or cursor files."""
|
|
243
|
+
channel = self._channel("general")
|
|
244
|
+
args = SimpleNamespace(channel="general", task="_meta.json", agent="mallory")
|
|
245
|
+
|
|
246
|
+
with self.assertRaises(chat.AgentChatError):
|
|
247
|
+
chat.cmd_claim(self.root, args)
|
|
248
|
+
|
|
249
|
+
self.assertTrue((channel / "_meta.json").exists())
|
|
250
|
+
|
|
251
|
+
def test_claim_rejects_non_task_files(self):
|
|
252
|
+
"""Claim accepts only the documented task marker filename shape."""
|
|
253
|
+
channel = self._channel("general")
|
|
254
|
+
for name in ("README.md", "0001-bob-message.md", "task-.md"):
|
|
255
|
+
path = channel / name
|
|
256
|
+
path.write_text("original", encoding="utf-8")
|
|
257
|
+
with self.subTest(name=name), self.assertRaises(chat.AgentChatError):
|
|
258
|
+
chat.cmd_claim(
|
|
259
|
+
self.root,
|
|
260
|
+
SimpleNamespace(channel="general", task=name, agent="mallory"),
|
|
261
|
+
)
|
|
262
|
+
self.assertTrue(path.exists())
|
|
263
|
+
self.assertEqual(path.read_text(encoding="utf-8"), "original")
|
|
264
|
+
|
|
265
|
+
def test_claim_renames_task_marker_without_overwriting_existing_claim(self):
|
|
266
|
+
"""A destination collision is a lost claim, never an overwrite."""
|
|
267
|
+
channel = self._channel("general")
|
|
268
|
+
source = channel / "task-12.md"
|
|
269
|
+
claimed = channel / "task-12.CLAIMED-mallory.md"
|
|
270
|
+
source.write_text("task", encoding="utf-8")
|
|
271
|
+
claimed.write_text("other agent", encoding="utf-8")
|
|
272
|
+
|
|
273
|
+
with self.assertRaises(SystemExit) as raised:
|
|
274
|
+
chat.cmd_claim(
|
|
275
|
+
self.root,
|
|
276
|
+
SimpleNamespace(channel="general", task=source.name, agent="mallory"),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
self.assertEqual(raised.exception.code, 3)
|
|
280
|
+
self.assertEqual(source.read_text(encoding="utf-8"), "task")
|
|
281
|
+
self.assertEqual(claimed.read_text(encoding="utf-8"), "other agent")
|
|
282
|
+
|
|
283
|
+
def test_claim_renames_valid_task_marker(self):
|
|
284
|
+
"""A valid marker is renamed to the agent-specific claimed name."""
|
|
285
|
+
channel = self._channel("general")
|
|
286
|
+
source = channel / "task-12.md"
|
|
287
|
+
source.write_text("task", encoding="utf-8")
|
|
288
|
+
|
|
289
|
+
chat.cmd_claim(
|
|
290
|
+
self.root,
|
|
291
|
+
SimpleNamespace(channel="general", task=source.name, agent="alice"),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
self.assertFalse(source.exists())
|
|
295
|
+
self.assertEqual(
|
|
296
|
+
(channel / "task-12.CLAIMED-alice.md").read_text(encoding="utf-8"),
|
|
297
|
+
"task",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def test_post_prompts_for_tty_stdin_but_not_piped_stdin(self):
|
|
301
|
+
"""Interactive body entry gets guidance; a pipeline stays quiet."""
|
|
302
|
+
channel = self._channel("general")
|
|
303
|
+
tty_stderr = io.StringIO()
|
|
304
|
+
with patch.object(chat.sys, "stdin", _FakeStdin("typed body", True)):
|
|
305
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
|
|
306
|
+
tty_stderr
|
|
307
|
+
):
|
|
308
|
+
chat.cmd_post(self.root, self._post_args(body=None))
|
|
309
|
+
self.assertIn("Enter message body", tty_stderr.getvalue())
|
|
310
|
+
|
|
311
|
+
pipe_stderr = io.StringIO()
|
|
312
|
+
with patch.object(chat.sys, "stdin", _FakeStdin("piped body", False)):
|
|
313
|
+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
|
|
314
|
+
pipe_stderr
|
|
315
|
+
):
|
|
316
|
+
chat.cmd_post(self.root, self._post_args(body=None, title="Piped"))
|
|
317
|
+
self.assertEqual(pipe_stderr.getvalue(), "")
|
|
318
|
+
self.assertIn(
|
|
319
|
+
"piped body", (channel / "0002-alice-piped.md").read_text(encoding="utf-8")
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
unittest.main()
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
"""Regression tests for the stdlib-only agent-chat CLI."""
|
|
2
|
-
|
|
3
|
-
import contextlib
|
|
4
|
-
import io
|
|
5
|
-
import json
|
|
6
|
-
import tempfile
|
|
7
|
-
import unittest
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from types import SimpleNamespace
|
|
10
|
-
from unittest.mock import patch
|
|
11
|
-
|
|
12
|
-
import chat
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
class _FakeStdin(io.StringIO):
|
|
16
|
-
def __init__(self, value, is_tty):
|
|
17
|
-
super().__init__(value)
|
|
18
|
-
self._is_tty = is_tty
|
|
19
|
-
|
|
20
|
-
def isatty(self):
|
|
21
|
-
return self._is_tty
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
class ChatRegressionTests(unittest.TestCase):
|
|
25
|
-
def setUp(self):
|
|
26
|
-
self.temp_dir = tempfile.TemporaryDirectory()
|
|
27
|
-
self.root = Path(self.temp_dir.name)
|
|
28
|
-
|
|
29
|
-
def tearDown(self):
|
|
30
|
-
self.temp_dir.cleanup()
|
|
31
|
-
|
|
32
|
-
def _channel(self, name):
|
|
33
|
-
channel = self.root / name
|
|
34
|
-
channel.mkdir()
|
|
35
|
-
(channel / "_meta.json").write_text(
|
|
36
|
-
json.dumps({"channel": name, "members": [], "topic": ""}),
|
|
37
|
-
encoding="utf-8",
|
|
38
|
-
)
|
|
39
|
-
return channel
|
|
40
|
-
|
|
41
|
-
def _post_args(self, **overrides):
|
|
42
|
-
values = {
|
|
43
|
-
"channel": "general",
|
|
44
|
-
"sender": "alice",
|
|
45
|
-
"to": "bob",
|
|
46
|
-
"title": "Status update",
|
|
47
|
-
"reply": None,
|
|
48
|
-
"status": "discussion",
|
|
49
|
-
"body": "Message body",
|
|
50
|
-
"body_file": None,
|
|
51
|
-
}
|
|
52
|
-
values.update(overrides)
|
|
53
|
-
return SimpleNamespace(**values)
|
|
54
|
-
|
|
55
|
-
def test_post_title_newlines_cannot_forge_frontmatter_fields(self):
|
|
56
|
-
"""A newline in a title must remain title content, never new metadata."""
|
|
57
|
-
channel = self._channel("general")
|
|
58
|
-
args = self._post_args(title="Status\nfrom: mallory\nto: eve")
|
|
59
|
-
|
|
60
|
-
with patch.object(chat, "now_iso", return_value="2026-08-09T00:00:00+00:00"):
|
|
61
|
-
with contextlib.redirect_stdout(io.StringIO()):
|
|
62
|
-
chat.cmd_post(self.root, args)
|
|
63
|
-
|
|
64
|
-
message = next(channel.glob("*.md"))
|
|
65
|
-
meta = chat.parse_frontmatter(message)
|
|
66
|
-
self.assertEqual(meta["from"], "alice")
|
|
67
|
-
self.assertEqual(meta["to"], "bob")
|
|
68
|
-
self.assertEqual(meta["title"], "Status from: mallory to: eve")
|
|
69
|
-
|
|
70
|
-
def test_max_seq_uses_highest_valid_sequence_with_gaps_and_malformed_files(self):
|
|
71
|
-
"""Only numbered message filenames affect the maximum sequence."""
|
|
72
|
-
channel = self._channel("general")
|
|
73
|
-
for name in (
|
|
74
|
-
"0002-alice-first.md",
|
|
75
|
-
"0010-bob-last.md",
|
|
76
|
-
"broken.md",
|
|
77
|
-
"12x-nope.md",
|
|
78
|
-
):
|
|
79
|
-
(channel / name).write_text("body", encoding="utf-8")
|
|
80
|
-
|
|
81
|
-
self.assertEqual(chat.max_seq(channel), 10)
|
|
82
|
-
|
|
83
|
-
def test_channels_reports_counts_and_last_message_for_empty_and_gapped_channels(
|
|
84
|
-
self,
|
|
85
|
-
):
|
|
86
|
-
"""Channel summaries count valid messages and select the highest sequence."""
|
|
87
|
-
alpha = self._channel("alpha")
|
|
88
|
-
self._channel("beta")
|
|
89
|
-
(alpha / "0002-alice-first.md").write_text(
|
|
90
|
-
"---\nfrom: alice\ntitle: First\n---\nbody\n", encoding="utf-8"
|
|
91
|
-
)
|
|
92
|
-
(alpha / "0010-bob-last.md").write_text(
|
|
93
|
-
"---\nfrom: bob\ntitle: Latest update\n---\nbody\n", encoding="utf-8"
|
|
94
|
-
)
|
|
95
|
-
(alpha / "not-a-message.md").write_text("ignored", encoding="utf-8")
|
|
96
|
-
|
|
97
|
-
output = io.StringIO()
|
|
98
|
-
with contextlib.redirect_stdout(output):
|
|
99
|
-
chat.cmd_channels(self.root, SimpleNamespace())
|
|
100
|
-
|
|
101
|
-
rendered = output.getvalue()
|
|
102
|
-
self.assertIn("alpha", rendered)
|
|
103
|
-
self.assertIn(" 2", rendered)
|
|
104
|
-
self.assertIn("last: #10 bob: Latest update", rendered)
|
|
105
|
-
self.assertIn("beta", rendered)
|
|
106
|
-
self.assertIn("last: -", rendered)
|
|
107
|
-
|
|
108
|
-
def test_post_prompts_for_tty_stdin_but_not_piped_stdin(self):
|
|
109
|
-
"""Interactive body entry gets guidance; a pipeline stays quiet."""
|
|
110
|
-
channel = self._channel("general")
|
|
111
|
-
tty_stderr = io.StringIO()
|
|
112
|
-
with patch.object(chat.sys, "stdin", _FakeStdin("typed body", True)):
|
|
113
|
-
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
|
|
114
|
-
tty_stderr
|
|
115
|
-
):
|
|
116
|
-
chat.cmd_post(self.root, self._post_args(body=None))
|
|
117
|
-
self.assertIn("Enter message body", tty_stderr.getvalue())
|
|
118
|
-
|
|
119
|
-
pipe_stderr = io.StringIO()
|
|
120
|
-
with patch.object(chat.sys, "stdin", _FakeStdin("piped body", False)):
|
|
121
|
-
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(
|
|
122
|
-
pipe_stderr
|
|
123
|
-
):
|
|
124
|
-
chat.cmd_post(self.root, self._post_args(body=None, title="Piped"))
|
|
125
|
-
self.assertEqual(pipe_stderr.getvalue(), "")
|
|
126
|
-
self.assertIn(
|
|
127
|
-
"piped body", (channel / "0002-alice-piped.md").read_text(encoding="utf-8")
|
|
128
|
-
)
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if __name__ == "__main__":
|
|
132
|
-
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{agent_chat_plugin-0.2.1 → agent_chat_plugin-0.3.0}/agent_chat_plugin.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|