nanobot/tests/extensions/test_node_compatibility.py

343 lines
9.5 KiB
Python

import asyncio
from pathlib import Path
import pytest
from nanobot.extensions.compatibility import CompatibleExtension
from nanobot.extensions.node_host import NodeSidecar
def _write(path: Path, content: str) -> Path:
path.write_text(content)
return path
@pytest.mark.asyncio
async def test_pi_extension_loads_tools_commands_and_events(tmp_path: Path) -> None:
entry = _write(
tmp_path / "pi-extension.mjs",
"""
export default function (pi) {
pi.registerTool({
name: "pi_echo",
label: "Echo",
description: "Echo input",
parameters: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"]
},
async execute(_id, params) {
return { content: [{ type: "text", text: `pi:${params.text}` }] };
}
});
pi.registerCommand("hello", {
description: "Say hello",
async handler(args, ctx) { ctx.ui.notify(`hello:${args}`); }
});
pi.on("agent_start", (event) => {
if (!event.messages) throw new Error("missing messages");
});
}
""",
)
host = NodeSidecar()
try:
result = await host.load(
runtime="pi",
entries=(entry,),
root=tmp_path,
extension_id="test.pi",
name="Pi test",
version="1.0.0",
workspace=tmp_path,
)
extension = CompatibleExtension(
host=host,
runtime="pi",
owner="test.pi",
result=result,
)
assert [(item.kind, item.name) for item in result.registrations] == [
("tool", "pi_echo"),
("command", "hello"),
("hook", "agent_start"),
]
assert await extension.tools[0].execute(text="ok") == "pi:ok"
assert extension.hook is not None
finally:
await host.close()
@pytest.mark.asyncio
async def test_openclaw_definition_loads_and_invokes_tool(tmp_path: Path) -> None:
entry = _write(
tmp_path / "openclaw-plugin.cjs",
"""
module.exports = {
id: "test.openclaw",
register(api) {
api.registerTool({
name: "claw_echo",
description: "Echo input",
parameters: {
type: "object",
properties: { text: { type: "string" } },
required: ["text"]
},
async execute(_id, params) {
return { content: [{ type: "text", text: `claw:${params.text}` }] };
}
});
api.registerCommand({
name: "status",
description: "Show status",
handler: async () => ({ text: "ready" })
});
api.on("agent_end", () => undefined);
}
};
""",
)
host = NodeSidecar()
try:
result = await host.load(
runtime="openclaw",
entries=(entry,),
root=tmp_path,
extension_id="test.openclaw",
name="OpenClaw test",
version="1.0.0",
workspace=tmp_path,
)
extension = CompatibleExtension(
host=host,
runtime="openclaw",
owner="test.openclaw",
result=result,
)
assert await extension.tools[0].execute(text="ok") == "claw:ok"
assert {item.name for item in result.registrations} == {
"claw_echo",
"status",
"agent_end",
}
finally:
await host.close()
@pytest.mark.asyncio
async def test_pi_package_loads_every_declared_entry(tmp_path: Path) -> None:
first = _write(
tmp_path / "first.mjs",
"""
export default function (pi) {
pi.registerCommand("first", { handler: async () => "first" });
}
""",
)
second = _write(
tmp_path / "second.mjs",
"""
export default function (pi) {
pi.registerCommand("second", { handler: async () => "second" });
}
""",
)
host = NodeSidecar()
try:
result = await host.load(
runtime="pi",
entries=(first, second),
root=tmp_path,
extension_id="test.multi",
name="Pi multi-entry test",
version="1.0.0",
workspace=tmp_path,
)
assert {
item.name for item in result.registrations if item.kind == "command"
} == {"first", "second"}
finally:
await host.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("kind", ["tool", "command"])
async def test_pi_package_rejects_duplicate_registrations(
tmp_path: Path,
kind: str,
) -> None:
registration = (
"""
pi.registerTool({
name: "duplicate",
description: "Duplicate",
parameters: { type: "object", properties: {} },
async execute() { return "ok"; }
});
"""
if kind == "tool"
else 'pi.registerCommand("duplicate", { handler: async () => "ok" });'
)
first = _write(
tmp_path / "first.mjs",
f"export default function (pi) {{ {registration} }}",
)
second = _write(
tmp_path / "second.mjs",
f"export default function (pi) {{ {registration} }}",
)
host = NodeSidecar()
try:
with pytest.raises(RuntimeError, match=f"{kind} 'duplicate' is already registered"):
await host.load(
runtime="pi",
entries=(first, second),
root=tmp_path,
extension_id="test.duplicate",
name="Duplicate registrations",
version="1.0.0",
)
finally:
await host.close()
@pytest.mark.asyncio
async def test_sidecar_accepts_tool_results_larger_than_default_stream_limit(
tmp_path: Path,
) -> None:
entry = _write(
tmp_path / "large-result.mjs",
"""
export default function (pi) {
pi.registerTool({
name: "large_result",
description: "Return a large result",
parameters: { type: "object", properties: {} },
async execute() { return "x".repeat(128 * 1024); }
});
}
""",
)
host = NodeSidecar()
try:
result = await host.load(
runtime="pi",
entries=(entry,),
root=tmp_path,
extension_id="test.large",
name="Large result",
version="1.0.0",
)
extension = CompatibleExtension(
host=host,
runtime="pi",
owner="test.large",
result=result,
)
output = await extension.tools[0].execute()
assert len(output) == 128 * 1024
finally:
await host.close()
@pytest.mark.asyncio
async def test_sidecar_terminates_extension_after_request_timeout(
tmp_path: Path,
) -> None:
entry = _write(
tmp_path / "timeout.mjs",
"""
export default function (pi) {
pi.registerTool({
name: "never_finishes",
description: "Never finishes",
parameters: { type: "object", properties: {} },
async execute() { await new Promise(() => {}); }
});
}
""",
)
host = NodeSidecar()
try:
await host.load(
runtime="pi",
entries=(entry,),
root=tmp_path,
extension_id="test.timeout",
name="Timeout",
version="1.0.0",
)
with pytest.raises(RuntimeError, match="timed out"):
await host.request(
"extension.call",
{
"kind": "tool",
"name": "never_finishes",
"callId": "test",
"input": {},
},
timeout=0.05,
)
assert not host.running
finally:
await host.close()
@pytest.mark.asyncio
async def test_cancelled_sidecar_call_does_not_fail_the_next_request(
tmp_path: Path,
) -> None:
entry = _write(
tmp_path / "cancel.mjs",
"""
export default function (pi) {
pi.registerTool({
name: "slow",
description: "Finish later",
parameters: { type: "object", properties: {} },
async execute() {
await new Promise((resolve) => setTimeout(resolve, 50));
return "slow";
}
});
pi.registerTool({
name: "fast",
description: "Finish immediately",
parameters: { type: "object", properties: {} },
async execute() { return "fast"; }
});
}
""",
)
host = NodeSidecar()
try:
result = await host.load(
runtime="pi",
entries=(entry,),
root=tmp_path,
extension_id="test.cancel",
name="Cancellation",
version="1.0.0",
)
extension = CompatibleExtension(
host=host,
runtime="pi",
owner="test.cancel",
result=result,
)
slow = asyncio.create_task(extension.tools[0].execute())
await asyncio.sleep(0.01)
slow.cancel()
with pytest.raises(asyncio.CancelledError):
await slow
assert await extension.tools[1].execute() == "fast"
finally:
await host.close()