M7.1: extract nisse-core
What isn't the dev agent's moves to nisse-core, unchanged in behaviour, with its tests: canonical, render, render_anthropic, llm, catalog, probe, context, views, state, reconcile, worker, cancel, dump, transport, matrix, execution, testing, vault, memory, oplog, envfile and gitea (whole). - nisse_core.state.StateStore holds the tables every agent has; an agent's subclass adds its tables (version, migrations), roles and views. nisse_dev.state.StateStore adds the build VM blocks (v5). - views.project takes what each role is shown, rather than a fixed table. - Vault takes the agent's name (agent=), for provenance. - A test that nisse_core never imports an agent. - The loop, config, git, delegation, expert, tools and evals stay in nisse-dev until nisse-ops has a loop to share. Docs: the Development Plan marks M7.1 done and notes that Phase 6 waits on the agent host and Terraform; Architecture Part L names the package. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
4bbcb09c8e
commit
154a0c3fa8
119 files changed
+1086
-912
No files matched your search
@@ -0,0 +1,361 @@
|
||||
import json
|
||||
import threading
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nisse_core.matrix import MatrixConfig, MatrixTransport, TransportError
|
||||
from nisse_core.transport import InboundMessage
|
||||
|
||||
CONFIG = MatrixConfig(
|
||||
homeserver="https://matrix.test",
|
||||
user_id="@nisse-dev:test",
|
||||
token="syt_secret",
|
||||
room_id="!room:test",
|
||||
)
|
||||
|
||||
|
||||
def sync_body(events: list[dict], next_batch: str) -> dict:
|
||||
return {
|
||||
"next_batch": next_batch,
|
||||
"rooms": {"join": {"!room:test": {"timeline": {"events": events}}}},
|
||||
}
|
||||
|
||||
|
||||
def message_event(sender: str, body: str) -> dict:
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": sender,
|
||||
"content": {"msgtype": "m.text", "body": body},
|
||||
}
|
||||
|
||||
|
||||
def test_send_message_puts_event_with_auth_and_unique_txn() -> None:
|
||||
seen: list[httpx.Request] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(request)
|
||||
return httpx.Response(200, json={"event_id": "$e"})
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
transport.send_message("hello")
|
||||
transport.send_approval_request("plan rev-1")
|
||||
|
||||
first, second = seen
|
||||
assert first.headers["authorization"] == "Bearer syt_secret"
|
||||
assert "/rooms/!room:test/send/m.room.message/" in str(first.url)
|
||||
assert json.loads(first.content) == {"msgtype": "m.text", "body": "hello"}
|
||||
assert str(first.url) != str(second.url) # txn ids must be unique
|
||||
|
||||
|
||||
def test_send_failure_raises_transport_error() -> None:
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(403, text="forbidden")
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
with pytest.raises(TransportError, match="403"):
|
||||
transport.send_message("hello")
|
||||
|
||||
|
||||
class SyncScript:
|
||||
"""Serves scripted /sync responses, then endless empty ones."""
|
||||
|
||||
def __init__(self, responses: list[dict]) -> None:
|
||||
self._responses = responses
|
||||
self._i = 0
|
||||
self.batches_seen: list[str | None] = []
|
||||
|
||||
def __call__(self, request: httpx.Request) -> httpx.Response:
|
||||
assert request.url.path.endswith("/sync")
|
||||
self.batches_seen.append(request.url.params.get("since"))
|
||||
if self._i < len(self._responses):
|
||||
body = self._responses[self._i]
|
||||
self._i += 1
|
||||
return httpx.Response(200, json=body)
|
||||
return httpx.Response(200, json=sync_body([], f"b{self._i}"))
|
||||
|
||||
|
||||
def collect_messages(
|
||||
responses: list[dict], expected: int, timeout_s: float = 5.0
|
||||
) -> list[InboundMessage]:
|
||||
received: list[InboundMessage] = []
|
||||
done = threading.Event()
|
||||
|
||||
def on_message(msg: InboundMessage) -> None:
|
||||
received.append(msg)
|
||||
if len(received) >= expected:
|
||||
done.set()
|
||||
|
||||
transport = MatrixTransport(
|
||||
CONFIG,
|
||||
poll_timeout_ms=10,
|
||||
http_transport=httpx.MockTransport(SyncScript(responses)),
|
||||
)
|
||||
transport.on_message(on_message)
|
||||
transport.start()
|
||||
done.wait(timeout_s)
|
||||
transport.close()
|
||||
return received
|
||||
|
||||
|
||||
def test_listener_delivers_operator_messages() -> None:
|
||||
responses = [
|
||||
sync_body([message_event("@don:test", "backlog: never delivered")], "b1"),
|
||||
sync_body([message_event("@don:test", "approve")], "b2"),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="approve")]
|
||||
|
||||
|
||||
def test_own_and_non_message_events_are_filtered() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[
|
||||
message_event("@nisse-dev:test", "my own echo"),
|
||||
{"type": "m.room.member", "sender": "@don:test", "content": {}},
|
||||
message_event("@don:test", "status"),
|
||||
],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="status")]
|
||||
|
||||
|
||||
def test_first_sync_learns_position_without_delivering() -> None:
|
||||
script = SyncScript([sync_body([message_event("@don:test", "old")], "b1")])
|
||||
transport = MatrixTransport(
|
||||
CONFIG, poll_timeout_ms=10, http_transport=httpx.MockTransport(script)
|
||||
)
|
||||
got = threading.Event()
|
||||
transport.on_message(lambda msg: got.set())
|
||||
transport.start()
|
||||
assert not got.wait(0.3)
|
||||
transport.close()
|
||||
assert script.batches_seen[0] is None # initial sync carries no since
|
||||
assert "b1" in script.batches_seen # later polls resume from its batch
|
||||
|
||||
|
||||
def test_start_without_handler_raises() -> None:
|
||||
transport = MatrixTransport(
|
||||
CONFIG, http_transport=httpx.MockTransport(lambda r: httpx.Response(200))
|
||||
)
|
||||
with pytest.raises(TransportError, match="no message handler"):
|
||||
transport.start()
|
||||
|
||||
|
||||
# --- leading mentions --------------------------------------------------------
|
||||
|
||||
|
||||
def own_member_state(name: str) -> dict:
|
||||
"""The first sync's room state, which carries the agent's display name."""
|
||||
body = sync_body([], "b1")
|
||||
body["rooms"]["join"]["!room:test"]["state"] = {
|
||||
"events": [
|
||||
{
|
||||
"type": "m.room.member",
|
||||
"state_key": "@nisse-dev:test",
|
||||
"sender": "@nisse-dev:test",
|
||||
"content": {"membership": "join", "displayname": name},
|
||||
}
|
||||
]
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
def pill_event(sender: str, user_id: str, label: str, rest: str) -> dict:
|
||||
"""A message as Element sends one that starts by mentioning user_id."""
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": sender,
|
||||
"content": {
|
||||
"msgtype": "m.text",
|
||||
"body": f"{label}: {rest}",
|
||||
"format": "org.matrix.custom.html",
|
||||
"formatted_body": (
|
||||
f'<a href="https://matrix.to/#/{user_id}">{label}</a>: {rest}'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_a_leading_pill_for_the_agent_is_removed_before_matching() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[pill_event("@don:test", "@nisse-dev:test", "Nisse Dev", "approve")],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.sender == "@don:test" # from the event, never from the text
|
||||
assert msg.text == "approve"
|
||||
assert msg.original == "Nisse Dev: approve"
|
||||
|
||||
|
||||
def test_the_agents_display_name_typed_in_front_is_removed() -> None:
|
||||
responses = [
|
||||
own_member_state("Nisse Dev"),
|
||||
sync_body([message_event("@don:test", "Nisse Dev: re-plan")], "b2"),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.text == "re-plan"
|
||||
|
||||
|
||||
def test_the_agents_user_id_typed_in_front_is_removed() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body([message_event("@don:test", "@nisse-dev:test: status")], "b2"),
|
||||
]
|
||||
[msg] = collect_messages(responses, expected=1)
|
||||
assert msg.text == "status"
|
||||
|
||||
|
||||
def test_messages_addressed_to_someone_else_are_skipped() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body(
|
||||
[
|
||||
pill_event("@don:test", "@nisse-ops:test", "Nisse Ops", "approve"),
|
||||
message_event("@don:test", "@alice:test: looks good"),
|
||||
message_event("@don:test", "status"),
|
||||
],
|
||||
"b2",
|
||||
),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [InboundMessage(sender="@don:test", text="status")]
|
||||
|
||||
|
||||
def test_a_mention_later_in_the_message_is_left_alone() -> None:
|
||||
responses = [
|
||||
sync_body([], "b1"),
|
||||
sync_body([message_event("@don:test", "approve, cc @alice:test")], "b2"),
|
||||
]
|
||||
received = collect_messages(responses, expected=1)
|
||||
assert received == [
|
||||
InboundMessage(sender="@don:test", text="approve, cc @alice:test")
|
||||
]
|
||||
|
||||
|
||||
# --- threads (L5): a work item's conversation is a thread --------------------
|
||||
|
||||
|
||||
def threaded_transport() -> tuple[MatrixTransport, list[dict], list[InboundMessage]]:
|
||||
sent: list[dict] = []
|
||||
ids = iter(f"$sent{n}" for n in range(1, 100))
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent.append(json.loads(request.content))
|
||||
return httpx.Response(200, json={"event_id": next(ids)})
|
||||
|
||||
transport = MatrixTransport(CONFIG, http_transport=httpx.MockTransport(handler))
|
||||
received: list[InboundMessage] = []
|
||||
transport.on_message(received.append)
|
||||
return transport, sent, received
|
||||
|
||||
|
||||
def event(event_id: str, body: str, relates: dict | None = None) -> dict:
|
||||
content: dict = {"msgtype": "m.text", "body": body}
|
||||
if relates is not None:
|
||||
content["m.relates_to"] = relates
|
||||
return {
|
||||
"type": "m.room.message",
|
||||
"sender": "@don:test",
|
||||
"event_id": event_id,
|
||||
"content": content,
|
||||
}
|
||||
|
||||
|
||||
def deliver(transport: MatrixTransport, *events: dict) -> None:
|
||||
transport._deliver(sync_body(list(events), "b"))
|
||||
|
||||
|
||||
def test_post_returns_the_event_id_and_threads_a_reply() -> None:
|
||||
transport, sent, _ = threaded_transport()
|
||||
root = transport.post("queued sandbox#14")
|
||||
transport.post("As I understand it: ...", thread=root)
|
||||
|
||||
assert root == "$sent1"
|
||||
assert "m.relates_to" not in sent[0]
|
||||
assert sent[1]["m.relates_to"] == {
|
||||
"rel_type": "m.thread",
|
||||
"event_id": "$sent1",
|
||||
# Clients without threads show it as a reply to the root.
|
||||
"is_falling_back": True,
|
||||
"m.in_reply_to": {"event_id": "$sent1"},
|
||||
}
|
||||
|
||||
|
||||
def test_a_message_in_a_thread_carries_its_root() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$top", "status"),
|
||||
event("$in", "go", {"rel_type": "m.thread", "event_id": "$root"}),
|
||||
)
|
||||
assert [(m.text, m.event_id, m.thread) for m in received] == [
|
||||
("status", "$top", None),
|
||||
("go", "$in", "$root"),
|
||||
]
|
||||
|
||||
|
||||
def test_a_plain_reply_to_a_threaded_message_joins_its_thread() -> None:
|
||||
# Clients without thread support send an ordinary reply, quoting.
|
||||
transport, _, received = threaded_transport()
|
||||
root = transport.post("queued sandbox#14")
|
||||
in_thread = transport.post("As I understand it: ...", thread=root)
|
||||
deliver(
|
||||
transport,
|
||||
event(
|
||||
"$r1",
|
||||
"> <@nisse-dev:test> As I understand it: ...\n\nyes, go",
|
||||
{"m.in_reply_to": {"event_id": in_thread}},
|
||||
),
|
||||
event(
|
||||
"$r2",
|
||||
"> <@nisse-dev:test> queued sandbox#14\n\nthanks",
|
||||
{"m.in_reply_to": {"event_id": root}},
|
||||
),
|
||||
)
|
||||
assert [(m.text, m.thread) for m in received] == [
|
||||
("yes, go", root), # the quote is not what was said
|
||||
("thanks", root), # a reply to the root joins its thread
|
||||
]
|
||||
|
||||
|
||||
def test_a_reply_to_an_unknown_message_stays_at_top_level() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$r", "> <@x:test> old\n\nhm", {"m.in_reply_to": {"event_id": "$old"}}),
|
||||
)
|
||||
assert [(m.text, m.thread) for m in received] == [("hm", None)]
|
||||
|
||||
|
||||
def test_a_reply_to_the_operators_own_threaded_message_joins_the_thread() -> None:
|
||||
transport, _, received = threaded_transport()
|
||||
deliver(
|
||||
transport,
|
||||
event("$t", "first", {"rel_type": "m.thread", "event_id": "$root"}),
|
||||
event(
|
||||
"$r",
|
||||
"> <@don:test> first\n\nand also",
|
||||
{"m.in_reply_to": {"event_id": "$t"}},
|
||||
),
|
||||
)
|
||||
assert [m.thread for m in received] == ["$root", "$root"]
|
||||
|
||||
|
||||
def test_in_memory_transport_plays_threads() -> None:
|
||||
from nisse_core.transport import InMemoryTransport
|
||||
|
||||
transport = InMemoryTransport()
|
||||
seen: list[InboundMessage] = []
|
||||
transport.on_message(seen.append)
|
||||
root = transport.post("queued #14")
|
||||
transport.post("restated", thread=root)
|
||||
reply = transport.receive("@don:test", "go", thread=root)
|
||||
assert transport.outbound[-1].thread == root
|
||||
assert (seen[0].thread, seen[0].event_id) == (root, reply)
|
||||
Reference in new issue
Block a user