mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 08:28:36 +00:00
343 lines
9.5 KiB
Python
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()
|