Skip to content

Commit 603b496

Browse files
authored
feat(projects): Beads bridge — A2A coordination + JSONL snapshot (#267)
* feat(projects): pure JSONL/ready helpers for Beads bridge * style(projects): drop unused typing imports in beads_format * feat(projects): system-message formatters for Beads bridge * feat(projects): verb + task-id parsers for Beads bridge * feat(projects): BeadsBridge skeleton with dirty-set writer loop * feat(projects): atomic JSONL snapshot writer in BeadsBridge * feat(projects): backfill_active and export_now on BeadsBridge * feat(projects): on_event posts claim/release/close system messages * feat(projects): synthesise task.ready when a closed task unblocks others * test(projects): expand task.ready synthesis coverage * feat(projects): A2A verb dispatch (/claim /release /close) on chat messages * feat(projects): attach A2A mentions as task comments (deduped) * feat(projects): subscribe BeadsBridge to per-project event broker * feat(projects): wire BeadsBridge into app lifespan with backfill * feat(projects): mark BeadsBridge dirty on task/rel mutations + export endpoint * feat(projects): hook chat send paths into BeadsBridge.on_chat_message * refactor(chat): simplify _is_a2a guardrail check * test(projects): bridge failure isolation does not break routes * chore(beads-bridge): address CodeRabbit nitpicks - chat.py: reuse _http_channel from line 292 instead of a second ch_store.get_channel(channel_id) call - test_routes_beads.py: replace bare next(...) with list+assert so a missing A2A channel produces a clear test failure rather than StopIteration (lines 109 and 151) Release-actor attribution (third nit) deferred — needs a payload change in task_store.release_task to surface releaser_id. * chore(beads-bridge): address CodeRabbit findings on chat hooks - Decouple beads handoff from agent_chat_router gating in WS path so WS and HTTP behave consistently. Now: if channel exists, dispatch to router (when present) AND spawn beads — independent systems. - Reuse _ws_channel from line 93 (drop duplicate get_channel call). - Add module-level _background_tasks set and _spawn_background helper so fire-and-forget tasks retain a reference until completion (RUF006). Used in both WS and HTTP beads hand-offs.
1 parent d0b2a5b commit 603b496

8 files changed

Lines changed: 1988 additions & 9 deletions

File tree

tests/projects/test_beads_bridge.py

Lines changed: 891 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
from __future__ import annotations
2+
3+
from tinyagentos.projects.beads_format import (
4+
compute_ready,
5+
task_to_jsonl_dict,
6+
)
7+
8+
9+
def _task(**kw) -> dict:
10+
base = {
11+
"id": "tsk_a3f8c2",
12+
"project_id": "prj_1",
13+
"parent_task_id": None,
14+
"title": "T",
15+
"body": "",
16+
"status": "open",
17+
"priority": 1,
18+
"labels": ["x"],
19+
"assignee_id": None,
20+
"claimed_by": None,
21+
"claimed_at": None,
22+
"closed_at": None,
23+
"closed_by": None,
24+
"close_reason": None,
25+
"created_by": "u1",
26+
"created_at": 1000.0,
27+
"updated_at": 1000.0,
28+
}
29+
base.update(kw)
30+
return base
31+
32+
33+
def test_task_to_jsonl_dict_no_relationships_no_assignee():
34+
t = _task(id="tsk_a", title="Hello")
35+
out = task_to_jsonl_dict(t, outbound_relationships=[], ready=True)
36+
assert out["id"] == "tsk_a"
37+
assert out["title"] == "Hello"
38+
assert out["status"] == "open"
39+
assert out["priority"] == "p2" # priority int 1 -> "p2"
40+
assert out["labels"] == ["x"]
41+
assert out["assignee_ids"] == []
42+
assert out["parent_id"] is None
43+
assert out["deps"] == []
44+
assert out["ready"] is True
45+
46+
47+
def test_task_to_jsonl_dict_with_assignee_and_parent():
48+
t = _task(id="tsk_b", assignee_id="agent_alice", parent_task_id="tsk_root")
49+
out = task_to_jsonl_dict(t, outbound_relationships=[], ready=False)
50+
assert out["assignee_ids"] == ["agent_alice"]
51+
assert out["parent_id"] == "tsk_root"
52+
53+
54+
def test_task_to_jsonl_dict_with_relationships_preserves_order():
55+
t = _task(id="tsk_c")
56+
rels = [
57+
{"from_task_id": "tsk_c", "to_task_id": "tsk_x", "kind": "blocks"},
58+
{"from_task_id": "tsk_c", "to_task_id": "tsk_y", "kind": "relates_to"},
59+
{"from_task_id": "tsk_c", "to_task_id": "tsk_z", "kind": "blocks"},
60+
]
61+
out = task_to_jsonl_dict(t, outbound_relationships=rels, ready=True)
62+
assert out["deps"] == [
63+
{"task_id": "tsk_x", "kind": "blocks"},
64+
{"task_id": "tsk_y", "kind": "relates_to"},
65+
{"task_id": "tsk_z", "kind": "blocks"},
66+
]
67+
68+
69+
def test_task_to_jsonl_dict_priority_clamped_to_p3():
70+
# priority ints map: 0→p3 (lowest), 1→p2, 2→p1, 3+→p0 (highest)
71+
assert task_to_jsonl_dict(_task(priority=0), [], False)["priority"] == "p3"
72+
assert task_to_jsonl_dict(_task(priority=1), [], False)["priority"] == "p2"
73+
assert task_to_jsonl_dict(_task(priority=2), [], False)["priority"] == "p1"
74+
assert task_to_jsonl_dict(_task(priority=3), [], False)["priority"] == "p0"
75+
assert task_to_jsonl_dict(_task(priority=99), [], False)["priority"] == "p0"
76+
77+
78+
def test_compute_ready_open_no_blockers_is_ready():
79+
assert compute_ready(_task(status="open"), incoming_blocker_statuses=[]) is True
80+
81+
82+
def test_compute_ready_open_with_open_blocker_not_ready():
83+
assert compute_ready(_task(status="open"), incoming_blocker_statuses=["open"]) is False
84+
85+
86+
def test_compute_ready_open_with_only_closed_blockers_is_ready():
87+
assert (
88+
compute_ready(_task(status="open"), incoming_blocker_statuses=["closed", "closed"])
89+
is True
90+
)
91+
92+
93+
def test_compute_ready_closed_task_never_ready():
94+
assert compute_ready(_task(status="closed"), incoming_blocker_statuses=[]) is False
95+
96+
97+
def test_compute_ready_claimed_task_not_ready():
98+
assert compute_ready(_task(status="claimed"), incoming_blocker_statuses=[]) is False
99+
100+
101+
from tinyagentos.projects.beads_format import (
102+
format_claimed,
103+
format_closed,
104+
format_ready,
105+
format_released,
106+
)
107+
108+
109+
def test_format_claimed():
110+
assert format_claimed("alice", "tsk_abc", "Wire OAuth") == (
111+
'🤚 alice claimed tsk_abc — "Wire OAuth"'
112+
)
113+
114+
115+
def test_format_released():
116+
assert format_released("alice", "tsk_abc", "Wire OAuth") == (
117+
'↩️ alice released tsk_abc — "Wire OAuth"'
118+
)
119+
120+
121+
def test_format_closed_without_note():
122+
assert format_closed("alice", "tsk_abc", "Wire OAuth", note=None) == (
123+
'✅ alice closed tsk_abc — "Wire OAuth"'
124+
)
125+
126+
127+
def test_format_closed_with_note():
128+
assert format_closed("alice", "tsk_abc", "Wire OAuth", note="ship it") == (
129+
'✅ alice closed tsk_abc — "Wire OAuth"\nship it'
130+
)
131+
132+
133+
def test_format_closed_strips_blank_note():
134+
assert format_closed("alice", "tsk_abc", "T", note=" ") == (
135+
'✅ alice closed tsk_abc — "T"'
136+
)
137+
138+
139+
def test_format_ready_with_labels():
140+
assert format_ready("tsk_abc", "Wire OAuth", labels=["auth", "ui"]) == (
141+
'⚡ tsk_abc ready — "Wire OAuth" — auth, ui'
142+
)
143+
144+
145+
def test_format_ready_no_labels():
146+
assert format_ready("tsk_abc", "Wire OAuth", labels=[]) == (
147+
'⚡ tsk_abc ready — "Wire OAuth"'
148+
)
149+
150+
151+
from tinyagentos.projects.beads_format import parse_verbs, scan_task_ids
152+
153+
154+
def test_parse_verbs_simple():
155+
assert parse_verbs("/claim tsk_abc") == [("claim", "tsk_abc", None)]
156+
157+
158+
def test_parse_verbs_release():
159+
assert parse_verbs("/release tsk_abc") == [("release", "tsk_abc", None)]
160+
161+
162+
def test_parse_verbs_close_with_note():
163+
assert parse_verbs("/close tsk_abc done shipping") == [
164+
("close", "tsk_abc", "done shipping")
165+
]
166+
167+
168+
def test_parse_verbs_close_without_note():
169+
assert parse_verbs("/close tsk_abc") == [("close", "tsk_abc", None)]
170+
171+
172+
def test_parse_verbs_multiple_lines():
173+
body = "/claim tsk_a\n/close tsk_b done\nstray"
174+
assert parse_verbs(body) == [
175+
("claim", "tsk_a", None),
176+
("close", "tsk_b", "done"),
177+
]
178+
179+
180+
def test_parse_verbs_indented_line_not_matched():
181+
assert parse_verbs(" /claim tsk_abc") == []
182+
183+
184+
def test_parse_verbs_unknown_verb_not_matched():
185+
assert parse_verbs("/foo tsk_abc") == []
186+
187+
188+
def test_parse_verbs_invalid_task_id_not_matched():
189+
assert parse_verbs("/claim notavalid") == []
190+
assert parse_verbs("/claim tsk_") == [] # must have at least one hex char
191+
192+
193+
def test_parse_verbs_empty_body():
194+
assert parse_verbs("") == []
195+
196+
197+
def test_scan_task_ids_finds_all():
198+
assert scan_task_ids("see tsk_abc and tsk_def for context") == [
199+
"tsk_abc",
200+
"tsk_def",
201+
]
202+
203+
204+
def test_scan_task_ids_dedupes_preserve_order():
205+
assert scan_task_ids("tsk_abc tsk_def tsk_abc") == ["tsk_abc", "tsk_def"]
206+
207+
208+
def test_scan_task_ids_word_boundary():
209+
# xtsk_abc is not a match (no leading word boundary)
210+
assert scan_task_ids("xtsk_abc tsk_def") == ["tsk_def"]
211+
212+
213+
def test_scan_task_ids_none_in_body():
214+
assert scan_task_ids("nothing here") == []

0 commit comments

Comments
 (0)