From 464e876eff0cfb05619e2402cd02fcec27e5f1dc Mon Sep 17 00:00:00 2001 From: "nekomimi.pet" Date: Tue, 22 Sep 2026 22:51:23 -0400 Subject: [PATCH] fix: let /reload reach the kernel's command catalog commands.catalog() and commands.invoke() read the live catalog through the host route instead of the copy taken at kernel boot, so commands added by a session reload are visible without restarting the kernel. The extensions integration test hung forever: its stream snapshot helper json-loaded an endless SSE stream. It now reads one frame, replays the live log so it sees the compacted event, and checks the latest tool output rather than the first one in history. --- priv/python/albedo_plugins/commands.py | 28 +++++++++++++------------- test/daemon/extensions_integration.py | 15 +++++++++----- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/priv/python/albedo_plugins/commands.py b/priv/python/albedo_plugins/commands.py index b55b15b..da068b9 100644 --- a/priv/python/albedo_plugins/commands.py +++ b/priv/python/albedo_plugins/commands.py @@ -1,10 +1,11 @@ """This session's slash commands as typed bindings, minted from the catalog. -The daemon sends one immutable command catalog at kernel boot; each -model-callable command becomes a typed async method whose docstring and -signature come from its declared arguments, so help(commands.) is the -command's help text. A command whose method name is reserved, invalid, or -taken stays callable through commands.invoke(). +Each model-callable command in the catalog at kernel boot becomes a typed +async method whose docstring and signature come from its declared arguments, +so help(commands.) is the command's help text. catalog() and invoke() +read the live catalog, so a session /reload reaches them without a kernel +restart; a command whose method name is reserved, invalid, taken, or added +after boot stays callable through commands.invoke(). """ from __future__ import annotations @@ -104,17 +105,16 @@ def _method(host: Host, summary: CommandSummary) -> object: class Commands: - """This session's slash commands. Every model-callable command is a typed - async method minted from the catalog at kernel boot; see the catalog for - method names and help(commands.) for one command's help.""" + """This session's slash commands. Every model-callable command at kernel + boot is a typed async method; see the catalog for method names and + help(commands.) for one command's help.""" - def __init__(self, host: Host, catalog: list[CommandSummary]) -> None: + def __init__(self, host: Host) -> None: self._host = host - self._catalog = catalog async def catalog(self) -> list[CommandSummary]: - """This session's immutable command catalog (minted at kernel boot).""" - return list(self._catalog) + """This session's current command catalog, including reloaded ones.""" + return cast(list[CommandSummary], await self._host("commands.list", {})) async def invoke(self, name: str, arguments: str | dict[str, object] = "") -> object: """Run any model-callable command by slash name or method name. @@ -124,7 +124,7 @@ class Commands: submits a turn. """ target = name if name.startswith("/") else "/" + name - for summary in self._catalog: + for summary in await self.catalog(): if summary["name"] == target or summary["method"] == name: break else: @@ -152,4 +152,4 @@ async def setup(api: PythonApi) -> dict[str, object]: continue bindings[method] = staticmethod(_method(api.host, summary)) session_commands = type("SessionCommands", (Commands,), bindings) - return {"commands": session_commands(api.host, catalog), "CommandsError": api.HostError} + return {"commands": session_commands(api.host), "CommandsError": api.HostError} diff --git a/test/daemon/extensions_integration.py b/test/daemon/extensions_integration.py index 92cea28..4b02e71 100644 --- a/test/daemon/extensions_integration.py +++ b/test/daemon/extensions_integration.py @@ -144,7 +144,12 @@ def run(endpoint): assert error.code == 409, error.read() def snapshot(session): - with api(f"/sessions/{session}/stream") as response: + # `api` would json.load an endless event stream; read one frame instead. + # after_seq=0 replays the live log: a fresh stream gets a transcript + # snapshot, which does not carry live-only events like `compacted`. + request = urllib.request.Request(f"http://127.0.0.1:{connection['port']}/sessions/{session}/stream?after_seq=0", + headers={"Authorization": "Bearer " + connection["token"]}) + with urllib.request.urlopen(request, timeout=25) as response: while True: line = response.readline() if line.startswith(b"data: "): @@ -249,7 +254,7 @@ def run(endpoint): api(f"/sessions/{session}/events", {"content": "hot probe before reload"}) ready(session) assert len(Provider.requests) == before + 2 - tool_output = next(item["output"] for item in Provider.requests[-1]["input"] if item.get("type") == "function_call_output") + tool_output = next(item["output"] for item in reversed(Provider.requests[-1]["input"]) if item.get("type") == "function_call_output") assert "BEFORE_RELOAD_OK" in tool_output, tool_output reloaded = api(f"/sessions/{session}/commands", {"name": "/reload", "args": {"target": "session"}}) assert reloaded["result"]["reloaded"] == "session", reloaded @@ -260,21 +265,21 @@ def run(endpoint): api(f"/sessions/{session}/events", {"content": "hot probe after reload"}) ready(session) assert len(Provider.requests) == before + 2 - tool_output = next(item["output"] for item in Provider.requests[-1]["input"] if item.get("type") == "function_call_output") + tool_output = next(item["output"] for item in reversed(Provider.requests[-1]["input"]) if item.get("type") == "function_call_output") assert "AFTER_RELOAD_OK" in tool_output, tool_output python_session = json.loads(cli("new", str(workspace)))["session"] before = len(Provider.requests) api(f"/sessions/{python_session}/events", {"content": "activate demo via python"}) ready(python_session) assert len(Provider.requests) == before + 2, "python activation must not submit another user turn" - tool_output = next(item["output"] for item in Provider.requests[-1]["input"] if item.get("type") == "function_call_output") + tool_output = next(item["output"] for item in reversed(Provider.requests[-1]["input"]) if item.get("type") == "function_call_output") assert "SKILL_PYTHON_ACTIVATION_OK" in tool_output, tool_output model_session = json.loads(cli("new", str(workspace)))["session"] before = len(Provider.requests) api(f"/sessions/{model_session}/events", {"content": "model probe via python"}) ready(model_session) assert len(Provider.requests) == before + 2 - tool_output = next(item["output"] for item in Provider.requests[-1]["input"] if item.get("type") == "function_call_output") + tool_output = next(item["output"] for item in reversed(Provider.requests[-1]["input"]) if item.get("type") == "function_call_output") assert "MODEL_COMMAND_OK" in tool_output, tool_output switched = api(f"/sessions/{model_session}/commands", {"name": "/model", "args": {"model": "switched-model"}}) assert switched["result"]["model"] == "switched-model", switched -- 2.54.0