agent-protocols 0.3.1__tar.gz → 0.4.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.
Files changed (22) hide show
  1. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/PKG-INFO +3 -1
  2. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/README.md +2 -0
  3. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/pyproject.toml +1 -1
  4. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/__init__.py +6 -0
  5. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/discourse.py +102 -13
  6. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/http_client.py +55 -9
  7. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/identity.py +25 -0
  8. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols.egg-info/PKG-INFO +3 -1
  9. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_discourse.py +17 -5
  10. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_discourse_coverage.py +16 -5
  11. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_http_client.py +46 -11
  12. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_identity_coverage.py +13 -0
  13. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/setup.cfg +0 -0
  14. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/errors.py +0 -0
  15. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols/profile.py +0 -0
  16. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols.egg-info/SOURCES.txt +0 -0
  17. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols.egg-info/dependency_links.txt +0 -0
  18. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols.egg-info/requires.txt +0 -0
  19. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/src/agent_protocols.egg-info/top_level.txt +0 -0
  20. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_identity.py +0 -0
  21. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_profile.py +0 -0
  22. {agent_protocols-0.3.1 → agent_protocols-0.4.0}/tests/test_profile_coverage.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -42,3 +42,5 @@ profile = materialize_profile(envelope)
42
42
  ```
43
43
 
44
44
  `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
45
+
46
+ ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -27,3 +27,5 @@ profile = materialize_profile(envelope)
27
27
  ```
28
28
 
29
29
  `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
30
+
31
+ ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-protocols"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -26,6 +26,9 @@ from .identity import (
26
26
  verify_request_jwt,
27
27
  verify_signature,
28
28
  verify_timestamp,
29
+ with_mention,
30
+ with_mentions,
31
+ with_room_head,
29
32
  with_room_id,
30
33
  )
31
34
  from .profile import PROFILE_PROTOCOL, PROFILE_UPDATE, materialize_profile, profile_update_event, validate_profile_update
@@ -63,5 +66,8 @@ __all__ = [
63
66
  "verify_request_jwt",
64
67
  "verify_signature",
65
68
  "verify_timestamp",
69
+ "with_mention",
70
+ "with_mentions",
71
+ "with_room_head",
66
72
  "with_room_id",
67
73
  ]
@@ -18,7 +18,17 @@ import rfc8785
18
18
  from jsonschema.validators import Draft202012Validator
19
19
 
20
20
  from .errors import AgentProtocolError
21
- from .identity import AgentId, Envelope, Event, create_event, verify_envelope, with_room_id
21
+ from .identity import (
22
+ AgentId,
23
+ Envelope,
24
+ Event,
25
+ MAX_SAFE_NONCE,
26
+ create_event,
27
+ validate_agent_id,
28
+ verify_envelope,
29
+ with_room_head,
30
+ with_room_id,
31
+ )
22
32
 
23
33
  DISCOURSE_PROTOCOL = "agent-discourse/1.0"
24
34
 
@@ -68,6 +78,8 @@ Role = Literal["moderator", "speaker", "observer"]
68
78
  TypeKind = Literal["message", "signal", "control"]
69
79
  TypeStatus = Literal["active", "deprecated", "disabled"]
70
80
  JoinRequestStatus = Literal["pending", "approved", "rejected", "expired"]
81
+ JoinDecision = Literal["approve", "reject"]
82
+ Visibility = Literal["public", "restricted", "private"]
71
83
 
72
84
  ROLES = ("moderator", "speaker", "observer")
73
85
  TYPE_KINDS = ("message", "signal", "control")
@@ -85,21 +97,69 @@ class PermissionContext(TypedDict, total=False):
85
97
  join_request_approved: bool
86
98
 
87
99
 
100
+ class AgentStatusInput(TypedDict, total=False):
101
+ state: str
102
+ summary: str
103
+ seen_seq: int
104
+ seen_hash: str
105
+ claim_id: str
106
+ activity: str
107
+ expires_at: int
108
+ extra: dict[str, Any]
109
+
110
+
111
+ class AgentStatus(TypedDict, total=False):
112
+ room_id: str
113
+ agent_id: AgentId
114
+ state: str
115
+ summary: str
116
+ seen_seq: int
117
+ seen_hash: str
118
+ claim_id: str
119
+ activity: str
120
+ expires_at: int
121
+ updated_at: int
122
+ extra: dict[str, Any]
123
+
124
+
88
125
  def room_create_event(actor: AgentId, created_at: int, nonce: int, payload: dict[str, Any]) -> Event:
89
126
  return create_event(DISCOURSE_PROTOCOL, ROOM_CREATE, actor, created_at, nonce, payload)
90
127
 
91
128
 
92
129
  def type_define_event(
93
- actor: AgentId, created_at: int, nonce: int, room_id: str, declaration: dict[str, Any]
130
+ actor: AgentId,
131
+ created_at: int,
132
+ nonce: int,
133
+ room_id: str,
134
+ base_seq: int,
135
+ base_hash: str,
136
+ declaration: dict[str, Any],
94
137
  ) -> Event:
95
- return with_room_id(
96
- create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
97
- room_id,
138
+ return with_room_head(
139
+ with_room_id(
140
+ create_event(DISCOURSE_PROTOCOL, TYPE_DEFINE, actor, created_at, nonce, declaration),
141
+ room_id,
142
+ ),
143
+ base_seq,
144
+ base_hash,
98
145
  )
99
146
 
100
147
 
101
- def discourse_event(event_type: str, actor: AgentId, created_at: int, nonce: int, room_id: str, payload: Any) -> Event:
102
- return with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id)
148
+ def discourse_event(
149
+ event_type: str,
150
+ actor: AgentId,
151
+ created_at: int,
152
+ nonce: int,
153
+ room_id: str,
154
+ base_seq: int,
155
+ base_hash: str,
156
+ payload: Any,
157
+ ) -> Event:
158
+ return with_room_head(
159
+ with_room_id(create_event(DISCOURSE_PROTOCOL, event_type, actor, created_at, nonce, payload), room_id),
160
+ base_seq,
161
+ base_hash,
162
+ )
103
163
 
104
164
 
105
165
  def is_builtin_event_type(event_type: str) -> bool:
@@ -117,25 +177,54 @@ def validate_discourse_envelope(envelope: Envelope) -> None:
117
177
  if protocol != DISCOURSE_PROTOCOL:
118
178
  raise AgentProtocolError("invalid_event_protocol", f"expected {DISCOURSE_PROTOCOL}, got {protocol}")
119
179
  if event["type"] == ROOM_CREATE:
120
- if "room_id" in event:
121
- raise AgentProtocolError("invalid_event", "room.create must not include room_id")
122
- elif "room_id" not in event:
123
- raise AgentProtocolError("missing_room_id", "event requires a room_id")
180
+ _validate_room_create_event_fields(event)
181
+ else:
182
+ if "room_id" not in event:
183
+ raise AgentProtocolError("missing_room_id", "event requires a room_id")
184
+ validate_room_head_precondition(event)
185
+ _validate_mentions(event.get("mentions"))
124
186
 
125
187
 
126
188
  def validate_room_path(envelope: Envelope, path_room_id: str) -> None:
127
189
  event = envelope["event"]
128
190
  actual = event.get("room_id")
129
191
  if event["type"] == ROOM_CREATE:
130
- if actual is not None:
131
- raise AgentProtocolError("invalid_event", "room.create must not include room_id")
192
+ _validate_room_create_event_fields(event)
132
193
  return
194
+ validate_room_head_precondition(event)
195
+ _validate_mentions(event.get("mentions"))
133
196
  if actual is None:
134
197
  raise AgentProtocolError("missing_room_id", "event requires a room_id")
135
198
  if actual != path_room_id:
136
199
  raise AgentProtocolError("room_id_mismatch", f"expected {path_room_id}, got {actual}")
137
200
 
138
201
 
202
+ def _validate_room_create_event_fields(event: Event) -> None:
203
+ if any(field in event for field in ("room_id", "base_seq", "base_hash", "mentions")):
204
+ raise AgentProtocolError(
205
+ "invalid_event",
206
+ "room.create must not include room_id, base_seq, base_hash, or mentions",
207
+ )
208
+
209
+
210
+ def validate_room_head_precondition(event: Event) -> None:
211
+ base_seq = event.get("base_seq")
212
+ base_hash = event.get("base_hash")
213
+ if not isinstance(base_seq, int) or base_seq < 1 or base_seq > MAX_SAFE_NONCE:
214
+ raise AgentProtocolError("invalid_event", "base_seq must be a positive safe JSON integer")
215
+ if not isinstance(base_hash, str) or not base_hash.strip():
216
+ raise AgentProtocolError("invalid_event", "base_hash must not be empty")
217
+
218
+
219
+ def _validate_mentions(mentions: Any) -> None:
220
+ if mentions is None:
221
+ return
222
+ if not isinstance(mentions, list):
223
+ raise AgentProtocolError("invalid_event", "mentions must be an Agent ID array")
224
+ for mention in mentions:
225
+ validate_agent_id(mention)
226
+
227
+
139
228
  def validate_custom_event_type_name(name: str) -> None:
140
229
  """Checks the shape of a custom event type name: lowercase dot-separated,
141
230
  at least two segments, not built-in, not under a reserved prefix."""
@@ -53,6 +53,43 @@ class DiscourseClient:
53
53
  def room(self, room_id: str) -> dict[str, Any]:
54
54
  return self._get(f"/v1/rooms/{room_id}")
55
55
 
56
+ def public_rooms(
57
+ self,
58
+ *,
59
+ status: str | None = None,
60
+ tag: str | None = None,
61
+ keyword: str | None = None,
62
+ creator: str | None = None,
63
+ starts_after: int | None = None,
64
+ ends_before: int | None = None,
65
+ language: str | None = None,
66
+ limit: int | None = None,
67
+ cursor: str | None = None,
68
+ ) -> list[dict[str, Any]]:
69
+ query = urlencode(
70
+ {
71
+ key: value
72
+ for key, value in {
73
+ "status": status,
74
+ "tag": tag,
75
+ "keyword": keyword,
76
+ "creator": creator,
77
+ "starts_after": starts_after,
78
+ "ends_before": ends_before,
79
+ "language": language,
80
+ "limit": limit,
81
+ "cursor": cursor,
82
+ }.items()
83
+ if value is not None
84
+ },
85
+ quote_via=quote,
86
+ )
87
+ suffix = f"?{query}" if query else ""
88
+ return self._get(f"/v1/rooms/public{suffix}")
89
+
90
+ def my_rooms(self, jwt: str) -> list[dict[str, Any]]:
91
+ return self._get("/v1/me/rooms", jwt=jwt)
92
+
56
93
  def request_join(self, room_id: str, jwt: str, request: dict[str, Any]) -> dict[str, Any]:
57
94
  return self._post(f"/v1/rooms/{room_id}/join-requests", request, jwt=jwt)
58
95
 
@@ -95,8 +132,17 @@ class DiscourseClient:
95
132
  suffix = f"?{query}" if query else ""
96
133
  return self._get(f"/v1/rooms/{room_id}/events{suffix}", jwt=jwt)
97
134
 
98
- def websocket_events_url(self, room_id: str, jwt: str) -> str:
99
- return websocket_events_url(self.base_url, room_id, jwt)
135
+ def agent_statuses(self, room_id: str, jwt: str | None = None) -> dict[str, Any]:
136
+ return self._get(f"/v1/rooms/{room_id}/agent-status", jwt=jwt)
137
+
138
+ def agent_status(self, room_id: str, agent_id: AgentId, jwt: str | None = None) -> dict[str, Any]:
139
+ return self._get(f"/v1/rooms/{room_id}/agent-status/{agent_id}", jwt=jwt)
140
+
141
+ def set_agent_status(self, room_id: str, jwt: str, status: dict[str, Any]) -> dict[str, Any]:
142
+ return self._put(f"/v1/rooms/{room_id}/agent-status", status, jwt=jwt)
143
+
144
+ def sse_events_url(self, room_id: str) -> str:
145
+ return sse_events_url(self.base_url, room_id)
100
146
 
101
147
  def archive(self, room_id: str) -> dict[str, Any]:
102
148
  return self._get(f"/v1/rooms/{room_id}/archive")
@@ -111,14 +157,14 @@ class DiscourseClient:
111
157
  response.raise_for_status()
112
158
  return response.json()
113
159
 
160
+ def _put(self, path: str, body: Any, jwt: str | None = None) -> Any:
161
+ response = self.session.put(self.base_url + path, json=body, headers=_auth_headers(jwt))
162
+ response.raise_for_status()
163
+ return response.json()
164
+
114
165
 
115
- def websocket_events_url(base_url: str, room_id: str, jwt: str) -> str:
116
- websocket_base = base_url.rstrip("/")
117
- if websocket_base.startswith("https://"):
118
- websocket_base = "wss://" + websocket_base[len("https://") :]
119
- elif websocket_base.startswith("http://"):
120
- websocket_base = "ws://" + websocket_base[len("http://") :]
121
- return f"{websocket_base}/v1/rooms/{quote(room_id, safe='')}/events/live?access_token={quote(jwt, safe='')}"
166
+ def sse_events_url(base_url: str, room_id: str) -> str:
167
+ return f"{base_url.rstrip('/')}/v1/rooms/{quote(room_id, safe='')}/events/live"
122
168
 
123
169
 
124
170
  def _auth_headers(jwt: str | None) -> dict[str, str] | None:
@@ -171,6 +171,31 @@ def with_room_id(event: Event, room_id: str) -> Event:
171
171
  return next_event
172
172
 
173
173
 
174
+ def with_room_head(event: Event, base_seq: int, base_hash: str) -> Event:
175
+ validate_nonce(base_seq)
176
+ if not isinstance(base_hash, str) or not base_hash.strip():
177
+ raise AgentProtocolError("invalid_event", "base_hash must not be empty")
178
+ next_event = dict(event)
179
+ next_event["base_seq"] = base_seq
180
+ next_event["base_hash"] = base_hash
181
+ return next_event
182
+
183
+
184
+ def with_mentions(event: Event, mentions: list[AgentId]) -> Event:
185
+ for mention in mentions:
186
+ validate_agent_id(mention)
187
+ next_event = dict(event)
188
+ next_event["mentions"] = list(mentions)
189
+ return next_event
190
+
191
+
192
+ def with_mention(event: Event, agent_id: AgentId) -> Event:
193
+ validate_agent_id(agent_id)
194
+ next_event = dict(event)
195
+ next_event["mentions"] = [*next_event.get("mentions", []), agent_id]
196
+ return next_event
197
+
198
+
174
199
  def canonical_event_bytes(event: Event) -> bytes:
175
200
  canonical = rfc8785.dumps(event)
176
201
  return canonical if isinstance(canonical, bytes) else canonical.encode()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agent-protocols
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Python SDK for Agent Identity, Agent Profile, and Agent Discourse protocols
5
5
  Author: LDCLabs
6
6
  License: MIT
@@ -42,3 +42,5 @@ profile = materialize_profile(envelope)
42
42
  ```
43
43
 
44
44
  `username` is provider-confirmed and appears on Profile documents returned by a profile service. Do not put it in agent-submitted `profile.update` payloads.
45
+
46
+ ADP room writes are signed against the current room head. Use `discourse_event` or `type_define_event` with `base_seq` and `base_hash`. Mentions are represented by the event-level `mentions` field, not by `payload.extra`.
@@ -42,7 +42,7 @@ from agent_protocols.discourse import (
42
42
  verify_server_record_chain,
43
43
  )
44
44
  from agent_protocols.errors import AgentProtocolError
45
- from agent_protocols.http_client import websocket_events_url
45
+ from agent_protocols.http_client import sse_events_url
46
46
  from agent_protocols.identity import AgentSigner, create_event
47
47
 
48
48
  PACKS_PATH = Path(__file__).resolve().parents[3] / "docs/protocols/agent-discourse/1.0.packs.json"
@@ -143,6 +143,8 @@ class DiscourseTests(unittest.TestCase):
143
143
  },
144
144
  )
145
145
  event["room_id"] = "d8ftedhpqhsusbg001tg"
146
+ event["base_seq"] = 17
147
+ event["base_hash"] = "GDt8oHZQfQ3jl5ZUfyNxKZu07yAJdDYuaw_jf_JjLYs"
146
148
  envelope = moderator.sign_event(event)
147
149
  validator = Draft202012Validator(json.loads(SCHEMA_PATH.read_text()))
148
150
 
@@ -336,7 +338,15 @@ class DiscourseTests(unittest.TestCase):
336
338
 
337
339
  def test_signs_and_validates_type_define_envelopes(self):
338
340
  signer = AgentSigner.from_seed(bytes([16]) * 32)
339
- event = type_define_event(signer.agent_id(), 100, 1, "d8ftedhpqhsusbg001tg", dict(FINDING_DEF))
341
+ event = type_define_event(
342
+ signer.agent_id(),
343
+ 100,
344
+ 1,
345
+ "d8ftedhpqhsusbg001tg",
346
+ 1,
347
+ "room-create-head",
348
+ dict(FINDING_DEF),
349
+ )
340
350
  envelope = signer.sign_event(event)
341
351
  validate_discourse_envelope(envelope)
342
352
 
@@ -371,6 +381,8 @@ class DiscourseTests(unittest.TestCase):
371
381
  {"content_type": "text/plain", "content": "hello"},
372
382
  )
373
383
  event2["room_id"] = "room123"
384
+ event2["base_seq"] = 1
385
+ event2["base_hash"] = record1["hash"]
374
386
  envelope2 = signer.sign_event(event2)
375
387
  record2 = build_server_record("room123", 2, record1["hash"], 130, envelope2)
376
388
 
@@ -383,10 +395,10 @@ class DiscourseTests(unittest.TestCase):
383
395
  with self.assertRaises(AgentProtocolError):
384
396
  verify_server_record_chain([{**record2, "pre_hash": "bad"}])
385
397
 
386
- def test_builds_websocket_event_stream_url(self):
398
+ def test_builds_sse_event_stream_url(self):
387
399
  self.assertEqual(
388
- websocket_events_url("https://api.example.com", "room123", "jwt.token"),
389
- "wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
400
+ sse_events_url("https://api.example.com", "room123"),
401
+ "https://api.example.com/v1/rooms/room123/events/live",
390
402
  )
391
403
 
392
404
 
@@ -40,13 +40,15 @@ class HelperBranchTests(unittest.TestCase):
40
40
  signer = AgentSigner.from_seed(bytes([60]) * 32)
41
41
  self.assertTrue(event_requires_room_id(MESSAGE_CREATE))
42
42
  self.assertFalse(event_requires_room_id(ROOM_CREATE))
43
- event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
43
+ event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
44
44
  self.assertEqual(event["room_id"], "room1")
45
+ self.assertEqual(event["base_seq"], 1)
46
+ self.assertEqual(event["base_hash"], "room-create-head")
45
47
  self.assertEqual(event["protocol"], DISCOURSE_PROTOCOL)
46
48
 
47
49
  def test_validate_discourse_envelope_rejects_foreign_protocol(self):
48
50
  signer = AgentSigner.from_seed(bytes([61]) * 32)
49
- event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
51
+ event = discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
50
52
  event["protocol"] = "other/1.0"
51
53
  envelope = signer.sign_event(event)
52
54
  with self.assertRaises(AgentProtocolError):
@@ -55,7 +57,7 @@ class HelperBranchTests(unittest.TestCase):
55
57
  def test_validate_room_path_matches_mismatches_and_requires_room_id(self):
56
58
  signer = AgentSigner.from_seed(bytes([62]) * 32)
57
59
  in_room = signer.sign_event(
58
- discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", {"a": 1})
60
+ discourse_event(MESSAGE_CREATE, signer.agent_id(), 1, 1, "room1", 1, "room-create-head", {"a": 1})
59
61
  )
60
62
  validate_room_path(in_room, "room1")
61
63
  with self.assertRaises(AgentProtocolError):
@@ -63,7 +65,7 @@ class HelperBranchTests(unittest.TestCase):
63
65
 
64
66
  no_room = {
65
67
  "hash": "h",
66
- "event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL},
68
+ "event": {"type": MESSAGE_CREATE, "protocol": DISCOURSE_PROTOCOL, "base_seq": 1, "base_hash": "room-create-head"},
67
69
  "signature": "s",
68
70
  }
69
71
  with self.assertRaises(AgentProtocolError):
@@ -169,7 +171,16 @@ class ServerRecordChainTests(unittest.TestCase):
169
171
 
170
172
  def make(seq, nonce, pre_hash):
171
173
  envelope = signer.sign_event(
172
- discourse_event(MESSAGE_CREATE, signer.agent_id(), 100, nonce, "room1", {"a": 1})
174
+ discourse_event(
175
+ MESSAGE_CREATE,
176
+ signer.agent_id(),
177
+ 100,
178
+ nonce,
179
+ "room1",
180
+ 1 if seq == 1 else seq - 1,
181
+ pre_hash or "room-create-head",
182
+ {"a": 1},
183
+ )
173
184
  )
174
185
  return build_server_record("room1", seq, pre_hash, 100 + seq, envelope)
175
186
 
@@ -4,7 +4,7 @@ import agent_protocols.http_client as http_client
4
4
  from agent_protocols.http_client import (
5
5
  DiscourseClient,
6
6
  ProfileClient,
7
- websocket_events_url,
7
+ sse_events_url,
8
8
  )
9
9
  from agent_protocols.identity import AgentSigner
10
10
 
@@ -37,6 +37,10 @@ class FakeSession:
37
37
  self.calls.append(("POST", url, headers, json))
38
38
  return self._responses.pop(0)
39
39
 
40
+ def put(self, url, json=None, headers=None):
41
+ self.calls.append(("PUT", url, headers, json))
42
+ return self._responses.pop(0)
43
+
40
44
 
41
45
  class ProfileClientTests(unittest.TestCase):
42
46
  def test_every_endpoint_uses_the_expected_method_and_path(self):
@@ -119,28 +123,59 @@ class DiscourseClientTests(unittest.TestCase):
119
123
  self.assertEqual(session.calls[10][2], {"Authorization": "Bearer jwt-d"})
120
124
  self.assertEqual(urls[11], "https://api.example.com/v1/rooms/room1/archive")
121
125
 
122
- def test_websocket_events_url_method(self):
126
+ def test_sse_events_url_method(self):
123
127
  session = FakeSession([])
124
128
  client = DiscourseClient("https://api.example.com", session=session)
125
129
  self.assertEqual(
126
- client.websocket_events_url("room1", "jwt.token"),
127
- websocket_events_url("https://api.example.com", "room1", "jwt.token"),
130
+ client.sse_events_url("room1"),
131
+ sse_events_url("https://api.example.com", "room1"),
132
+ )
133
+
134
+ def test_public_rooms_my_rooms_and_agent_status_endpoints(self):
135
+ session = FakeSession([FakeResponse({"ok": True}) for _ in range(5)])
136
+ client = DiscourseClient("https://api.example.com/", session=session)
137
+
138
+ client.public_rooms(
139
+ status="active",
140
+ tag="code review",
141
+ starts_after=10,
142
+ ends_before=20,
143
+ limit=5,
144
+ cursor="next page",
145
+ )
146
+ client.my_rooms("jwt-me")
147
+ client.agent_statuses("room1", jwt="jwt-statuses")
148
+ client.agent_status("room1", AGENT_ID, jwt="jwt-status")
149
+ client.set_agent_status("room1", "jwt-set", {"state": "idle", "expires_at": 2})
150
+
151
+ urls = [call[1] for call in session.calls]
152
+ self.assertEqual(
153
+ urls[0],
154
+ "https://api.example.com/v1/rooms/public?status=active&tag=code%20review&starts_after=10&ends_before=20&limit=5&cursor=next%20page",
128
155
  )
156
+ self.assertEqual(urls[1], "https://api.example.com/v1/me/rooms")
157
+ self.assertEqual(session.calls[1][2], {"Authorization": "Bearer jwt-me"})
158
+ self.assertEqual(urls[2], "https://api.example.com/v1/rooms/room1/agent-status")
159
+ self.assertEqual(session.calls[2][2], {"Authorization": "Bearer jwt-statuses"})
160
+ self.assertEqual(urls[3], f"https://api.example.com/v1/rooms/room1/agent-status/{AGENT_ID}")
161
+ self.assertEqual(session.calls[4][0], "PUT")
162
+ self.assertEqual(session.calls[4][2], {"Authorization": "Bearer jwt-set"})
163
+ self.assertEqual(session.calls[4][3], {"state": "idle", "expires_at": 2})
129
164
 
130
165
 
131
166
  class HelperTests(unittest.TestCase):
132
- def test_websocket_events_url_rewrites_schemes(self):
167
+ def test_sse_events_url_preserves_http_schemes(self):
133
168
  self.assertEqual(
134
- websocket_events_url("https://api.example.com", "room123", "jwt.token"),
135
- "wss://api.example.com/v1/rooms/room123/events/live?access_token=jwt.token",
169
+ sse_events_url("https://api.example.com", "room123"),
170
+ "https://api.example.com/v1/rooms/room123/events/live",
136
171
  )
137
172
  self.assertEqual(
138
- websocket_events_url("http://api.example.com/", "room 1", "a+b"),
139
- "ws://api.example.com/v1/rooms/room%201/events/live?access_token=a%2Bb",
173
+ sse_events_url("http://api.example.com/", "room 1"),
174
+ "http://api.example.com/v1/rooms/room%201/events/live",
140
175
  )
141
176
  self.assertEqual(
142
- websocket_events_url("ftp://api.example.com", "r", "t"),
143
- "ftp://api.example.com/v1/rooms/r/events/live?access_token=t",
177
+ sse_events_url("ftp://api.example.com", "r"),
178
+ "ftp://api.example.com/v1/rooms/r/events/live",
144
179
  )
145
180
 
146
181
  def test_requests_session_factory(self):
@@ -21,9 +21,12 @@ from agent_protocols.identity import (
21
21
  unix_ms,
22
22
  unix_secs,
23
23
  validate_agent_id,
24
+ verify_envelope,
24
25
  verify_event_hash_signature,
25
26
  verify_request_jwt,
26
27
  verify_timestamp,
28
+ with_mention,
29
+ with_room_head,
27
30
  with_room_id,
28
31
  )
29
32
  from agent_protocols.errors import AgentProtocolError
@@ -65,8 +68,18 @@ class AgentIdTests(unittest.TestCase):
65
68
  class EventTests(unittest.TestCase):
66
69
  def test_create_event_and_with_room_id(self):
67
70
  signer = AgentSigner.from_seed(bytes([21]) * 32)
71
+ target = AgentSigner.from_seed(bytes([22]) * 32)
68
72
  event = create_event("p", "t", signer.agent_id(), 1000, 1, {"a": 1})
69
73
  self.assertEqual(with_room_id(event, "room1")["room_id"], "room1")
74
+ room_event = with_mention(with_room_head(with_room_id(event, "room1"), 1, "room-create-head"), target.agent_id())
75
+ self.assertEqual(room_event["base_seq"], 1)
76
+ self.assertEqual(room_event["base_hash"], "room-create-head")
77
+ self.assertEqual(room_event["mentions"], [target.agent_id()])
78
+ verify_envelope(signer.sign_event(room_event))
79
+ with self.assertRaises(AgentProtocolError):
80
+ with_room_head(event, 0, "head")
81
+ with self.assertRaises(AgentProtocolError):
82
+ with_mention(event, "bad-agent")
70
83
  with self.assertRaises(AgentProtocolError):
71
84
  create_event("p", "t", "bad-actor", 1, 1, {})
72
85