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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-chat-plugin
3
- Version: 0.2.1
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.2.1
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
- die(f"invalid {kind} name (path traversal blocked): '{name}'")
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
- die(f"channel '{channel}' not found under {root} (run: init {channel})")
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
- die("could not acquire channel seq lock (another poster is stuck?)")
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
- die(f"channel '{a.channel}' already exists")
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
- print(f"created channel '{a.channel}' at {d} members={members or '(open)'}")
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
- last = f"#{last_seq} {lm.get('from', '?')}: {lm.get('title', '')[:40]}"
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, ",".join(meta.get("members", [])) or "(open)", count, last)
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
- meta = json.loads((d / "_meta.json").read_text(encoding="utf-8"))
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
- print(f"messages: {len(message_files(d))}")
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
- return Path(a.body_file).read_text(encoding="utf-8")
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; send EOF when finished.", file=sys.stderr
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
- die("empty body (pass --body, --body-file, or pipe via stdin)")
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
- for p in message_files(d):
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 <= cur:
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
- top = max_seq(d)
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
- for p in message_files(d):
375
- seq = _seq_from_name(p.name)
376
- if seq <= cur:
377
- continue
378
- meta = parse_frontmatter(p)
379
- if is_relevant(meta, a.agent):
380
- found.append(p)
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
- files = message_files(d)[-a.n :]
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
- os.replace(src, dst) # atomic on Windows + POSIX when same directory
417
- except FileNotFoundError:
418
- die(f"task '{a.task}' already claimed or missing (lost the race)", code=3)
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
- args.func(root, args)
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.2.1" # managed by python-semantic-release; do not hand-edit
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()