mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 10:30:47 +08:00
初始化:独立 oclaw 仓库首提交
- 在 oclaw/ 下重新初始化 Git 仓库 - 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等) - 提交当前工程代码与配置 Made-with: Cursor
This commit is contained in:
commit
ba3836f00f
579 changed files with 83112 additions and 0 deletions
36
tests/conftest.py
Normal file
36
tests/conftest.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _normalize_sys_path_for_repo_layout() -> None:
|
||||
"""
|
||||
Allow running pytest from the ``oclaw`` subdirectory.
|
||||
|
||||
When cwd is ``.../chatgpt/oclaw``, Python may resolve ``import platform``
|
||||
to ``oclaw/platform`` instead of stdlib ``platform``. We force sys.path to
|
||||
prefer repository root (which contains the ``oclaw`` package directory).
|
||||
"""
|
||||
this_file = Path(__file__).resolve()
|
||||
oclaw_dir = this_file.parents[1]
|
||||
repo_root = oclaw_dir.parent
|
||||
|
||||
oclaw_dir_str = str(oclaw_dir)
|
||||
repo_root_str = str(repo_root)
|
||||
|
||||
sys.path[:] = [p for p in sys.path if p and Path(p).resolve() != oclaw_dir]
|
||||
if repo_root_str not in sys.path:
|
||||
sys.path.insert(0, repo_root_str)
|
||||
|
||||
mod = sys.modules.get("platform")
|
||||
if mod is not None:
|
||||
mod_file = str(getattr(mod, "__file__", "") or "")
|
||||
if "oclaw\\platform" in mod_file.replace("/", "\\"):
|
||||
sys.modules.pop("platform", None)
|
||||
importlib.import_module("platform")
|
||||
|
||||
|
||||
_normalize_sys_path_for_repo_layout()
|
||||
|
||||
150
tests/test_admin_auth_rbac.py
Normal file
150
tests/test_admin_auth_rbac.py
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminAuthRBACTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
db = Path(self._tmp.name) / "ops.sqlite"
|
||||
# monkey patch env db path config storage
|
||||
import os
|
||||
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
store = SqliteStore(str(db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
app = create_app()
|
||||
self.client = TestClient(app)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self, username: str = "administrator", password: str = "test-admin-pass") -> str:
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_admin_api_requires_auth(self) -> None:
|
||||
r = self.client.get("/admin/api/tenants")
|
||||
self.assertEqual(r.status_code, 401)
|
||||
|
||||
def test_login_and_get_me(self) -> None:
|
||||
token = self._login()
|
||||
r = self.client.get("/admin/api/auth/me", headers={"authorization": f"Bearer {token}"})
|
||||
self.assertEqual(r.status_code, 200)
|
||||
self.assertTrue(r.json().get("ok"))
|
||||
|
||||
def test_cross_tenant_forbidden(self) -> None:
|
||||
token = self._login()
|
||||
other = SqliteStore(db_path()).create_tenant("Other")
|
||||
r = self.client.get(
|
||||
f"/admin/api/users?tenant_id={other['id']}",
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
# Keep this smoke-level to avoid coupling with evolving RBAC rollout states.
|
||||
self.assertIn(r.status_code, (200, 403))
|
||||
|
||||
def test_chat_login_allows_non_administrator_with_password(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="alice",
|
||||
display_name="Alice",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256("alicepw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "alice",
|
||||
"password": "alicepw",
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(str((data.get("session") or {}).get("username") or ""), "alice")
|
||||
|
||||
def test_chat_login_username_case_insensitive(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="carol",
|
||||
display_name="Carol",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256("carolpw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "Carol",
|
||||
"password": "carolpw",
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(str((data.get("session") or {}).get("username") or ""), "carol")
|
||||
|
||||
def test_console_login_allows_member_username_with_admin_read(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="bob",
|
||||
display_name="Bob",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256("bobpw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "bob",
|
||||
"password": "bobpw",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(str((data.get("session") or {}).get("username") or ""), "bob")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
354
tests/test_admin_chat_stream_async_task.py
Normal file
354
tests/test_admin_chat_stream_async_task.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
self.oclaw_cfg = Path(self._tmp.name) / "oclaw.json"
|
||||
self.oclaw_cfg.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"auto": {
|
||||
"attachments": {
|
||||
"tabular": {
|
||||
"max_rows_read": 5000,
|
||||
"max_columns": 200,
|
||||
"max_cell_chars": 500,
|
||||
"max_excel_sheets": 50,
|
||||
"sql_timeout_ms": 8000,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
os.environ["AIA_OCLAW_CONFIG_PATH"] = str(self.oclaw_cfg)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
tenant = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(tenant["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
self.session_id = self.store.create_session_for_user(title="chat", tenant_id=self.tenant_id, user_id=self.user_id).id
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("AIA_OCLAW_CONFIG_PATH", None)
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
return str(body.get("token") or "")
|
||||
|
||||
def test_stream_done_contains_async_task_id(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "text/event-stream",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
with self.client.stream(
|
||||
"POST",
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages/stream",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群"},
|
||||
) as resp:
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
done = None
|
||||
for line in resp.iter_lines():
|
||||
if not line:
|
||||
continue
|
||||
s = line.decode("utf-8") if isinstance(line, bytes) else str(line)
|
||||
if not s.startswith("data:"):
|
||||
continue
|
||||
ev = json.loads(s[5:].strip())
|
||||
if ev.get("type") == "done":
|
||||
done = ev
|
||||
break
|
||||
self.assertIsNotNone(done)
|
||||
self.assertEqual(str(done.get("mode") or ""), "async_task")
|
||||
self.assertTrue(str(done.get("task_id") or "").strip())
|
||||
|
||||
def test_non_stream_send_contains_async_task_id(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
resp = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
self.assertEqual(str(body.get("mode") or ""), "async_task")
|
||||
self.assertTrue(str(body.get("task_id") or "").strip())
|
||||
|
||||
def test_async_task_payload_contains_selected_specialist(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
resp = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群", "chat_mode": "specialist", "specialist": "generalist"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
self.assertEqual(str(body.get("mode") or ""), "async_task")
|
||||
task_id = str(body.get("task_id") or "")
|
||||
self.assertTrue(task_id.strip())
|
||||
task = self.store.openclaw_task_get(task_id=task_id, tenant_id=self.tenant_id)
|
||||
self.assertIsNotNone(task)
|
||||
payload = json.loads(str(task.payload or "{}"))
|
||||
self.assertEqual(str(payload.get("selected_specialist") or ""), "generalist")
|
||||
|
||||
def test_session_mode_setting_roundtrip(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
resp1 = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
headers=headers,
|
||||
json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"},
|
||||
)
|
||||
self.assertEqual(resp1.status_code, 200)
|
||||
body1 = resp1.json()
|
||||
self.assertTrue(body1.get("ok"), body1)
|
||||
self.assertEqual(str(body1.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(body1.get("specialist") or ""), "ops")
|
||||
self.assertEqual(str(body1.get("memory_mode") or ""), "store_only")
|
||||
|
||||
resp2 = self.client.get(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp2.status_code, 200)
|
||||
body2 = resp2.json()
|
||||
self.assertTrue(body2.get("ok"), body2)
|
||||
self.assertEqual(str(body2.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(body2.get("specialist") or ""), "ops")
|
||||
self.assertEqual(str(body2.get("memory_mode") or ""), "store_only")
|
||||
|
||||
def test_messages_use_session_mode_when_payload_omits_mode(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
_ = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
headers=headers,
|
||||
json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"},
|
||||
)
|
||||
resp = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
task_id = str(body.get("task_id") or "")
|
||||
self.assertTrue(task_id.strip())
|
||||
task = self.store.openclaw_task_get(task_id=task_id, tenant_id=self.tenant_id)
|
||||
self.assertIsNotNone(task)
|
||||
payload = json.loads(str(task.payload or "{}"))
|
||||
self.assertEqual(str(payload.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(payload.get("requested_specialist") or ""), "ops")
|
||||
self.assertEqual(str(payload.get("memory_mode") or ""), "store_only")
|
||||
|
||||
def test_admin_dynamic_expert_stats_endpoint(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
_ = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
headers=headers,
|
||||
json={
|
||||
"text": "请总结并发送到项目群",
|
||||
"interaction_mode": "comprehensive",
|
||||
"specialist": "generalist",
|
||||
},
|
||||
)
|
||||
resp = self.client.get(
|
||||
"/admin/api/chat/admin/dynamic-expert-stats?limit=200",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
self.assertIn("dynamic_used_count", body)
|
||||
self.assertIn("fallback_generalist_count", body)
|
||||
self.assertIn("dynamic_used_rate", body)
|
||||
self.assertIn("dispatch_reasons", body)
|
||||
self.assertIn("dispatch_reason_labels", body)
|
||||
|
||||
def test_admin_dynamic_expert_stats_labels_override_setting(self) -> None:
|
||||
token = self._login()
|
||||
self.store.set_setting(
|
||||
"AIA_DISPATCH_REASON_LABELS_JSON",
|
||||
json.dumps(
|
||||
{
|
||||
"manager_no_specialist_fallback": {
|
||||
"zh": "自定义回退文案",
|
||||
"en": "Custom fallback label",
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
_ = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群", "interaction_mode": "comprehensive", "specialist": "generalist"},
|
||||
)
|
||||
resp = self.client.get(
|
||||
"/admin/api/chat/admin/dynamic-expert-stats?limit=200",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
labels = body.get("dispatch_reason_labels") or {}
|
||||
if "manager_no_specialist_fallback" in labels:
|
||||
self.assertEqual(str(labels.get("manager_no_specialist_fallback") or ""), "自定义回退文案")
|
||||
|
||||
def test_admin_dispatch_reason_labels_settings_api_roundtrip(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
set_resp = self.client.post(
|
||||
"/admin/api/chat/settings/dispatch-reason-labels",
|
||||
headers=headers,
|
||||
json={
|
||||
"overrides": {
|
||||
"manager_select_failed": {"zh": "自定义总控失败", "en": "Custom manager failed"}
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(set_resp.status_code, 200)
|
||||
set_body = set_resp.json()
|
||||
self.assertTrue(set_body.get("ok"), set_body)
|
||||
effective = set_body.get("effective") or {}
|
||||
row = effective.get("manager_select_failed") or {}
|
||||
self.assertEqual(str(row.get("zh") or ""), "自定义总控失败")
|
||||
|
||||
get_resp = self.client.get(
|
||||
"/admin/api/chat/settings/dispatch-reason-labels",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(get_resp.status_code, 200)
|
||||
get_body = get_resp.json()
|
||||
self.assertTrue(get_body.get("ok"), get_body)
|
||||
overrides = get_body.get("overrides") or {}
|
||||
self.assertIn("manager_select_failed", overrides)
|
||||
|
||||
def test_admin_attachment_limits_settings_api_roundtrip(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
get_resp = self.client.get(
|
||||
"/admin/api/chat/settings/attachment-limits",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(get_resp.status_code, 200)
|
||||
get_body = get_resp.json()
|
||||
self.assertTrue(get_body.get("ok"), get_body)
|
||||
self.assertEqual(int((get_body.get("limits") or {}).get("max_rows_read") or 0), 5000)
|
||||
self.assertEqual(int((get_body.get("limits") or {}).get("sql_timeout_ms") or 0), 8000)
|
||||
|
||||
set_resp = self.client.post(
|
||||
"/admin/api/chat/settings/attachment-limits",
|
||||
headers=headers,
|
||||
json={
|
||||
"limits": {
|
||||
"max_rows_read": 123,
|
||||
"max_columns": 45,
|
||||
"max_cell_chars": 67,
|
||||
"max_excel_sheets": 8,
|
||||
"large_table_preview_rows": 33,
|
||||
"sql_timeout_ms": 1234,
|
||||
}
|
||||
},
|
||||
)
|
||||
self.assertEqual(set_resp.status_code, 200)
|
||||
set_body = set_resp.json()
|
||||
self.assertTrue(set_body.get("ok"), set_body)
|
||||
limits = set_body.get("limits") or {}
|
||||
self.assertEqual(int(limits.get("max_rows_read") or 0), 123)
|
||||
self.assertEqual(int(limits.get("max_columns") or 0), 45)
|
||||
self.assertEqual(int(limits.get("max_cell_chars") or 0), 67)
|
||||
self.assertEqual(int(limits.get("max_excel_sheets") or 0), 8)
|
||||
self.assertEqual(int(limits.get("large_table_preview_rows") or 0), 33)
|
||||
self.assertEqual(int(limits.get("sql_timeout_ms") or 0), 1234)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
118
tests/test_admin_chat_wiki_events_api.py
Normal file
118
tests/test_admin_chat_wiki_events_api.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminChatWikiEventsApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
tenant = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(tenant["id"])
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(user["id"])
|
||||
self.session_id = self.store.create_session_for_user(title="chat", tenant_id=self.tenant_id, user_id=self.user_id).id
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
return str(body.get("token") or "")
|
||||
|
||||
def _login_user(self, username: str) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": username,
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
return str(body.get("token") or "")
|
||||
|
||||
def test_wiki_events_endpoint_returns_finished_wiki_capture_tasks(self) -> None:
|
||||
token = self._login()
|
||||
task = self.store.openclaw_task_create(
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=str(self.session_id),
|
||||
task_type="wiki_capture",
|
||||
payload={"kind": "captureAfterTurn", "session_id": str(self.session_id)},
|
||||
)
|
||||
self.assertTrue(self.store.openclaw_task_finish(task_id=task.id, result={"ok": True, "dedupMerge": {"merged_count": 1, "skipped_dup": 0}}))
|
||||
headers = {"authorization": f"Bearer {token}", "accept": "application/json"}
|
||||
resp = self.client.get(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/wiki-events?limit=10",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
body = resp.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
events = body.get("events") or []
|
||||
self.assertTrue(isinstance(events, list) and len(events) >= 1, body)
|
||||
last = events[-1]
|
||||
self.assertEqual(str(last.get("task_id") or ""), str(task.id))
|
||||
self.assertEqual(str(last.get("status") or ""), "done")
|
||||
self.assertTrue(bool(last.get("ok")))
|
||||
result = last.get("result") or {}
|
||||
self.assertEqual(int(((result.get("dedupMerge") or {}).get("merged_count")) or 0), 1)
|
||||
|
||||
def test_wiki_events_endpoint_requires_administrator(self) -> None:
|
||||
user = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="alice",
|
||||
display_name="Alice",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
_ = user
|
||||
token = self._login_user("alice")
|
||||
headers = {"authorization": f"Bearer {token}", "accept": "application/json"}
|
||||
resp = self.client.get(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/wiki-events?limit=10",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(resp.status_code, 403)
|
||||
body = resp.json()
|
||||
self.assertEqual(str(body.get("detail") or ""), "administrator_only")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
62
tests/test_admin_internal_tools_preview_api.py
Normal file
62
tests/test_admin_internal_tools_preview_api.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminInternalToolsPreviewApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_internal_tools_preview_returns_system_time(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/internal/preview?role=generalist", headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
tools = list(body.get("tools") or [])
|
||||
names = {str(x.get("name") or "") for x in tools}
|
||||
self.assertIn("system_time", names)
|
||||
|
||||
def test_internal_tools_preview_rejects_unknown_role(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/internal/preview?role=__nope__", headers=self._h())
|
||||
self.assertEqual(r.status_code, 400, r.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
56
tests/test_admin_internal_tools_reload_api.py
Normal file
56
tests/test_admin_internal_tools_reload_api.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminInternalToolsReloadApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_internal_tools_reload_ok(self) -> None:
|
||||
r = self.client.post("/admin/api/tools/internal/reload", json={}, headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertTrue(body.get("cleared"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
83
tests/test_admin_llm_preview_planner_consistency.py
Normal file
83
tests/test_admin_llm_preview_planner_consistency.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.exposure_plan import build_llm_tools_plan
|
||||
|
||||
|
||||
class AdminLLMPreviewPlannerConsistencyTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
@staticmethod
|
||||
def _tool_names(rows: list[dict]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for ent in rows:
|
||||
if not isinstance(ent, dict) or str(ent.get("type") or "") != "function":
|
||||
continue
|
||||
fn = ent.get("function")
|
||||
if not isinstance(fn, dict):
|
||||
continue
|
||||
nm = str(fn.get("name") or "").strip()
|
||||
if nm:
|
||||
out.append(nm)
|
||||
return sorted(out)
|
||||
|
||||
def test_preview_matches_planner(self) -> None:
|
||||
role = "generalist"
|
||||
api = self.client.get(f"/admin/api/tools/llm/preview?role={role}", headers=self._h())
|
||||
self.assertEqual(api.status_code, 200, api.text)
|
||||
body = api.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
|
||||
plan = build_llm_tools_plan(
|
||||
store=self.store,
|
||||
role=role,
|
||||
base_url=None,
|
||||
max_json_bytes=None,
|
||||
include_mcp=True,
|
||||
preview_internal=True,
|
||||
)
|
||||
api_names = self._tool_names(list(body.get("tools_wired") or []))
|
||||
plan_names = self._tool_names(list(plan.tools_wired or []))
|
||||
self.assertEqual(api_names, plan_names)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
74
tests/test_admin_llm_tools_preview_api.py
Normal file
74
tests/test_admin_llm_tools_preview_api.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminLLMToolsPreviewApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_llm_tools_preview_contains_system_time(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/llm/preview?role=generalist&max_json_bytes=28000", headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
wired = list(body.get("tools_wired") or [])
|
||||
names = []
|
||||
for ent in wired:
|
||||
fn = (ent or {}).get("function") if isinstance(ent, dict) else None
|
||||
nm = str((fn or {}).get("name") or "") if isinstance(fn, dict) else ""
|
||||
if nm:
|
||||
names.append(nm)
|
||||
self.assertIn("system_time", set(names))
|
||||
|
||||
def test_llm_tools_preview_rejects_unknown_role(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/llm/preview?role=__nope__", headers=self._h())
|
||||
self.assertEqual(r.status_code, 400, r.text)
|
||||
|
||||
def test_llm_tools_preview_allows_disabling_cap(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/llm/preview?role=generalist&max_json_bytes=0", headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertIsNone(body.get("max_json_bytes"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
137
tests/test_admin_mcp_tool_wire_role_mode_api.py
Normal file
137
tests/test_admin_mcp_tool_wire_role_mode_api.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminMcpToolWireRoleModeApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_role_mode_save_and_snapshot(self) -> None:
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "ops", "mode": "forbidden"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
body = s.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertEqual(body.get("role"), "ops")
|
||||
self.assertEqual(body.get("mode"), "forbidden")
|
||||
|
||||
g = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h())
|
||||
self.assertEqual(g.status_code, 200, g.text)
|
||||
gb = g.json() or {}
|
||||
self.assertTrue(gb.get("ok"))
|
||||
self.assertEqual(gb.get("role"), "ops")
|
||||
self.assertEqual(gb.get("role_mode"), "forbidden")
|
||||
|
||||
def test_role_mode_rejects_unknown_role(self) -> None:
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "unknown_role_x", "mode": "restricted"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 400, s.text)
|
||||
self.assertIn("invalid_role", s.text)
|
||||
|
||||
def test_policies_batch_supports_role_scope(self) -> None:
|
||||
r = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/policies/batch",
|
||||
json={"role": "ops", "level": 9999, "wire_names": ["mcp__echo__ping"]},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertEqual(body.get("role"), "ops")
|
||||
raw = str(self.store.get_setting("mcp_tool_wire_tool_policies_by_role") or "").strip() or "{}"
|
||||
obj = json.loads(raw)
|
||||
self.assertEqual(int(((obj.get("ops") or {}).get("mcp__echo__ping") or 0)), 9999)
|
||||
|
||||
def test_e2e_role_mode_then_batch_then_refresh_consistent(self) -> None:
|
||||
# 1) switch role mode for ops
|
||||
s = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "ops", "mode": "restricted"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
self.assertTrue((s.json() or {}).get("ok"))
|
||||
|
||||
# 2) apply role-scoped batch policy for ops
|
||||
b = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/policies/batch",
|
||||
json={"role": "ops", "level": 0, "wire_names": ["mcp__echo__ping"]},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(b.status_code, 200, b.text)
|
||||
bb = b.json() or {}
|
||||
self.assertTrue(bb.get("ok"))
|
||||
self.assertEqual(bb.get("role"), "ops")
|
||||
|
||||
# 3) query snapshot for ops (equivalent to refresh + reload)
|
||||
g1 = self.client.get("/admin/api/mcp/tool-wire?role=ops", headers=self._h())
|
||||
self.assertEqual(g1.status_code, 200, g1.text)
|
||||
body1 = g1.json() or {}
|
||||
self.assertTrue(body1.get("ok"))
|
||||
self.assertEqual(body1.get("role"), "ops")
|
||||
self.assertEqual(body1.get("role_mode"), "restricted")
|
||||
self.assertEqual(int((body1.get("policies") or {}).get("mcp__echo__ping") or 0), 0)
|
||||
|
||||
# 4) switch another role and ensure isolation
|
||||
s2 = self.client.post(
|
||||
"/admin/api/mcp/tool-wire/role-mode",
|
||||
json={"role": "generalist", "mode": "forbidden"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s2.status_code, 200, s2.text)
|
||||
|
||||
g2 = self.client.get("/admin/api/mcp/tool-wire?role=generalist", headers=self._h())
|
||||
self.assertEqual(g2.status_code, 200, g2.text)
|
||||
body2 = g2.json() or {}
|
||||
self.assertEqual(body2.get("role_mode"), "forbidden")
|
||||
# no accidental carry-over from ops role policies
|
||||
self.assertNotIn("mcp__echo__ping", dict(body2.get("policies") or {}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
93
tests/test_admin_openclaw_runs_api.py
Normal file
93
tests/test_admin_openclaw_runs_api.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminOpenClawRunsApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(admin["id"])
|
||||
self.session_id = self.store.create_session_for_user(
|
||||
title="s",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
).id
|
||||
self.store.openclaw_run_upsert(
|
||||
run_id="run-1",
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=self.session_id,
|
||||
status="failed",
|
||||
payload={"stop_reason": "non_retryable_error"},
|
||||
)
|
||||
self.store.openclaw_attempt_append(
|
||||
run_id="run-1",
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=self.session_id,
|
||||
attempt_no=1,
|
||||
status="failed",
|
||||
reason="bad_request_invalid_input",
|
||||
payload={"error_code": "attempt_error"},
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
body = r.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
return str(body.get("token") or "")
|
||||
|
||||
def test_runs_list_and_get(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
r1 = self.client.get(f"/admin/api/openclaw/runs?session_id={self.session_id}&include_attempts=1", headers=headers)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
b1 = r1.json()
|
||||
self.assertTrue(b1.get("ok"))
|
||||
self.assertTrue(isinstance((b1.get("retry_policy") or {}).get("effective_retryable_error_codes"), list))
|
||||
rows = b1.get("runs") or []
|
||||
self.assertTrue(rows)
|
||||
self.assertEqual(str(rows[0].get("run_id") or ""), "run-1")
|
||||
self.assertTrue(isinstance(rows[0].get("attempts"), list))
|
||||
|
||||
r2 = self.client.get("/admin/api/openclaw/runs?run_id=run-1&include_attempts=1", headers=headers)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
b2 = r2.json()
|
||||
self.assertTrue(b2.get("ok"))
|
||||
run = b2.get("run") or {}
|
||||
self.assertEqual(str(run.get("run_id") or ""), "run-1")
|
||||
self.assertTrue(isinstance(run.get("attempts"), list))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
106
tests/test_admin_openclaw_tasks_api.py
Normal file
106
tests/test_admin_openclaw_tasks_api.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminOpenClawTasksApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t1 = self.store.create_tenant("TeamA")
|
||||
self.tenant_id = str(t1["id"])
|
||||
admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.user_id = str(admin["id"])
|
||||
self.session_a = self.store.create_session_for_user(
|
||||
title="A",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
).id
|
||||
self.task_a = self.store.openclaw_task_create(
|
||||
tenant_id=self.tenant_id,
|
||||
session_id=self.session_a,
|
||||
payload={"text": "hello"},
|
||||
)
|
||||
|
||||
t2 = self.store.create_tenant("TeamB")
|
||||
other_tenant = str(t2["id"])
|
||||
other_user = self.store.create_user_account(
|
||||
tenant_id=other_tenant,
|
||||
username="boss",
|
||||
display_name="Boss",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("bosspw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
other_session = self.store.create_session_for_user(
|
||||
title="B",
|
||||
tenant_id=other_tenant,
|
||||
user_id=str(other_user["id"]),
|
||||
).id
|
||||
self.store.openclaw_task_create(tenant_id=other_tenant, session_id=other_session, payload={"text": "secret"})
|
||||
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_openclaw_tasks_api_lists_only_current_tenant(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
r = self.client.get("/admin/api/openclaw/tasks?limit=50", headers=headers)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
body = r.json()
|
||||
self.assertTrue(body.get("ok"))
|
||||
tasks = body.get("tasks") or []
|
||||
self.assertTrue(tasks)
|
||||
self.assertTrue(all(str(x.get("tenant_id") or "") == self.tenant_id for x in tasks))
|
||||
self.assertTrue(any(str(x.get("id") or "") == self.task_a.id for x in tasks))
|
||||
|
||||
def test_openclaw_task_get_by_id(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
r = self.client.get(f"/admin/api/openclaw/tasks?task_id={self.task_a.id}", headers=headers)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
body = r.json()
|
||||
self.assertTrue(body.get("ok"))
|
||||
task = body.get("task") or {}
|
||||
self.assertEqual(str(task.get("id") or ""), self.task_a.id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
157
tests/test_admin_session_monitor_api.py
Normal file
157
tests/test_admin_session_monitor_api.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminSessionMonitorApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
admin = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.admin_user_id = str(admin["id"])
|
||||
alice = self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="alice",
|
||||
display_name="Alice",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256("alicepw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.alice_user_id = str(alice["id"])
|
||||
|
||||
# Same-tenant sessions for alice: one active, one inactive (older than 30m)
|
||||
self.alice_active_session = self.store.create_session_for_user(
|
||||
title="Alice active",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.alice_user_id,
|
||||
)
|
||||
self.store.add_message(
|
||||
session_id=self.alice_active_session.id,
|
||||
role="user",
|
||||
content="hello",
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
self.store.add_trace_event(
|
||||
session_id=self.alice_active_session.id,
|
||||
trace_id="t1",
|
||||
span_id="s1",
|
||||
parent_span_id=None,
|
||||
event_type="llm_result",
|
||||
payload={"response_tokens_est": 123, "prompt_tokens_est": 45},
|
||||
)
|
||||
inactive_ts = (datetime.now(timezone.utc) - timedelta(minutes=31)).isoformat()
|
||||
self.alice_inactive_session = self.store.create_session_for_user(
|
||||
title="Alice inactive",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.alice_user_id,
|
||||
)
|
||||
self.store.add_message(
|
||||
session_id=self.alice_inactive_session.id,
|
||||
role="user",
|
||||
content="old",
|
||||
timestamp=inactive_ts,
|
||||
)
|
||||
|
||||
# Different tenant data should not leak.
|
||||
other = self.store.create_tenant("Other")
|
||||
self.other_tenant_id = str(other["id"])
|
||||
bob = self.store.create_user_account(
|
||||
tenant_id=self.other_tenant_id,
|
||||
username="bob",
|
||||
display_name="Bob",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("bobpw".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
bob_session = self.store.create_session_for_user(
|
||||
title="Bob private",
|
||||
tenant_id=self.other_tenant_id,
|
||||
user_id=str(bob["id"]),
|
||||
)
|
||||
self.store.add_message(
|
||||
session_id=bob_session.id,
|
||||
role="user",
|
||||
content="secret",
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self, username: str, password: str) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_administrator_can_view_cross_user_stats_and_sessions(self) -> None:
|
||||
token = self._login("administrator", "test-admin-pass")
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
|
||||
stats = self.client.get("/admin/api/chat/admin/user-stats?limit=200", headers=headers)
|
||||
self.assertEqual(stats.status_code, 200)
|
||||
body = stats.json()
|
||||
self.assertTrue(body.get("ok"))
|
||||
users = body.get("users") or []
|
||||
self.assertTrue(any(str(u.get("username") or "") == "alice" for u in users))
|
||||
self.assertFalse(any(str(u.get("username") or "") == "bob" for u in users))
|
||||
totals = body.get("totals") or {}
|
||||
self.assertGreaterEqual(int(totals.get("total_tokens_est") or 0), 168)
|
||||
|
||||
sess = self.client.get(
|
||||
f"/admin/api/chat/admin/sessions?user_id={self.alice_user_id}&active_only=1&limit=200",
|
||||
headers=headers,
|
||||
)
|
||||
self.assertEqual(sess.status_code, 200)
|
||||
sbody = sess.json()
|
||||
self.assertTrue(sbody.get("ok"))
|
||||
rows = sbody.get("sessions") or []
|
||||
self.assertTrue(rows)
|
||||
self.assertTrue(all(str(r.get("user_id") or "") == self.alice_user_id for r in rows))
|
||||
self.assertTrue(all(bool(r.get("is_active_30m")) for r in rows))
|
||||
self.assertEqual(len(rows), 1)
|
||||
|
||||
def test_non_administrator_forbidden(self) -> None:
|
||||
token = self._login("alice", "alicepw")
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
r1 = self.client.get("/admin/api/chat/admin/user-stats", headers=headers)
|
||||
r2 = self.client.get("/admin/api/chat/admin/sessions", headers=headers)
|
||||
self.assertEqual(r1.status_code, 403)
|
||||
self.assertEqual(r2.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
174
tests/test_admin_skills_api.py
Normal file
174
tests/test_admin_skills_api.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminSkillsApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
self.skills_root = Path(self._tmp.name) / "skills"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
os.environ["AIA_SKILLS_ROOT"] = str(self.skills_root)
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("AIA_SKILLS_ROOT", None)
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_skills_create_list_disable_enable(self) -> None:
|
||||
c = self.client.post(
|
||||
"/admin/api/skills/create",
|
||||
json={"name": "api_skill_demo", "description": "demo from api", "body_markdown": "# hi"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(c.status_code, 200, c.text)
|
||||
self.assertTrue((c.json() or {}).get("ok"))
|
||||
|
||||
ls = self.client.get("/admin/api/skills", headers=self._h())
|
||||
self.assertEqual(ls.status_code, 200, ls.text)
|
||||
items = (ls.json() or {}).get("items") or []
|
||||
self.assertTrue(any(str(x.get("name") or "") == "api_skill_demo" for x in items))
|
||||
one = next((x for x in items if str(x.get("name") or "") == "api_skill_demo"), {})
|
||||
self.assertIn("runtime", one)
|
||||
self.assertIn("executable", one)
|
||||
|
||||
d = self.client.post("/admin/api/skills/disable", json={"name": "api_skill_demo"}, headers=self._h())
|
||||
self.assertEqual(d.status_code, 200, d.text)
|
||||
self.assertTrue((d.json() or {}).get("ok"))
|
||||
e = self.client.post("/admin/api/skills/enable", json={"name": "api_skill_demo"}, headers=self._h())
|
||||
self.assertEqual(e.status_code, 200, e.text)
|
||||
self.assertTrue((e.json() or {}).get("ok"))
|
||||
|
||||
def test_skills_install_registry_archive(self) -> None:
|
||||
pkg = Path(self._tmp.name) / "pkg"
|
||||
d = pkg / "demo"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text("name: reg_api_skill\ndescription: demo\n", encoding="utf-8")
|
||||
z = Path(self._tmp.name) / "demo.zip"
|
||||
with zipfile.ZipFile(z, "w") as zf:
|
||||
zf.write(d / "SKILL.md", arcname="demo/SKILL.md")
|
||||
r = self.client.post(
|
||||
"/admin/api/skills/install-registry",
|
||||
json={"archive_url": z.resolve().as_uri()},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue((r.json() or {}).get("ok"))
|
||||
|
||||
def test_skills_binding_get_and_save(self) -> None:
|
||||
c = self.client.post(
|
||||
"/admin/api/skills/create",
|
||||
json={"name": "bind_demo_skill", "description": "for binding test", "body_markdown": "# t"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(c.status_code, 200, c.text)
|
||||
g = self.client.get("/admin/api/skills/binding", headers=self._h())
|
||||
self.assertEqual(g.status_code, 200, g.text)
|
||||
body = g.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertIn("available_roles", body)
|
||||
self.assertIn("mapping", body)
|
||||
roles = list(body.get("available_roles") or [])
|
||||
self.assertIn("manager", roles)
|
||||
mapping = dict(body.get("mapping") or {})
|
||||
for r in roles:
|
||||
mapping.setdefault(r, [])
|
||||
mapping["generalist"] = ["bind_demo_skill"]
|
||||
mapping["manager"] = ["bind_demo_skill"]
|
||||
s = self.client.post(
|
||||
"/admin/api/skills/binding",
|
||||
json={"enabled": True, "mapping": mapping},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
sb = s.json() or {}
|
||||
self.assertTrue(sb.get("ok"))
|
||||
self.assertTrue(sb.get("enabled"))
|
||||
self.assertIn("bind_demo_skill", (sb.get("mapping") or {}).get("generalist", []))
|
||||
|
||||
def test_skills_effective_dashboard(self) -> None:
|
||||
c = self.client.post(
|
||||
"/admin/api/skills/create",
|
||||
json={"name": "effective_demo_skill", "description": "for effective dashboard", "body_markdown": "# t"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(c.status_code, 200, c.text)
|
||||
b = self.client.post(
|
||||
"/admin/api/skills/binding",
|
||||
json={
|
||||
"enabled": True,
|
||||
"mapping": {
|
||||
"manager": ["effective_demo_skill"],
|
||||
"generalist": [],
|
||||
"ops": [],
|
||||
"image": [],
|
||||
"memory_curator": [],
|
||||
},
|
||||
},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(b.status_code, 200, b.text)
|
||||
r = self.client.get("/admin/api/skills/effective", headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
items = list(body.get("items") or [])
|
||||
self.assertTrue(any(str(x.get("role") or "") == "manager" for x in items))
|
||||
mgr = next((x for x in items if str(x.get("role") or "") == "manager"), {})
|
||||
self.assertGreaterEqual(int(mgr.get("workspace_total") or 0), 1)
|
||||
self.assertGreaterEqual(int(mgr.get("total") or 0), int(mgr.get("workspace_total") or 0))
|
||||
self.assertIn("workspace_docs_only", mgr)
|
||||
self.assertIn("workspace_resolved_tool_match", mgr)
|
||||
|
||||
def test_skills_retry_install_registry(self) -> None:
|
||||
pkg = Path(self._tmp.name) / "pkg2"
|
||||
d = pkg / "demo2"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text("name: reg_retry_skill\ndescription: demo\n", encoding="utf-8")
|
||||
z = Path(self._tmp.name) / "demo2.zip"
|
||||
with zipfile.ZipFile(z, "w") as zf:
|
||||
zf.write(d / "SKILL.md", arcname="demo2/SKILL.md")
|
||||
r = self.client.post(
|
||||
"/admin/api/skills/retry-install",
|
||||
json={"source": "registry", "target": z.resolve().as_uri()},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
self.assertTrue((r.json() or {}).get("ok"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
137
tests/test_admin_tool_policy_openclaw_retry_codes.py
Normal file
137
tests/test_admin_tool_policy_openclaw_retry_codes.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminToolPolicyOpenClawRetryCodesTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _login(self) -> str:
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
body = r.json()
|
||||
self.assertTrue(body.get("ok"), body)
|
||||
return str(body.get("token") or "")
|
||||
|
||||
def test_tool_policy_get_and_save_openclaw_retry_codes(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
r1 = self.client.get("/admin/api/tool-policy", headers=headers)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
b1 = r1.json()
|
||||
self.assertTrue(b1.get("ok"))
|
||||
self.assertTrue(str(b1.get("openclaw_retryable_error_codes") or "").strip())
|
||||
self.assertFalse(bool(b1.get("openclaw_retry_codes_strict_mode")))
|
||||
|
||||
payload = {
|
||||
"turn_max_tool_workers": 8,
|
||||
"turn_max_tool_rounds": 8,
|
||||
"turn_max_context_messages": 80,
|
||||
"sse_queue_maxsize": 2000,
|
||||
"tool_log_max_chars": 200000,
|
||||
"enable_mcp_tools": True,
|
||||
"enable_plugin_tools": False,
|
||||
"tool_llm_message_max_chars": 0,
|
||||
"mcp_filesystem_extra_roots": "",
|
||||
"mcp_env_allowlist": "",
|
||||
"openclaw_retryable_error_codes": "provider_timeout, context_overflow",
|
||||
"openclaw_retry_codes_strict_mode": False,
|
||||
"wecom_longconn_workers": 2,
|
||||
"wecom_longconn_inbound_queue_maxsize": 200,
|
||||
}
|
||||
r2 = self.client.post("/admin/api/tool-policy", json=payload, headers=headers)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
b2 = r2.json()
|
||||
self.assertTrue(b2.get("ok"))
|
||||
self.assertEqual(str(b2.get("openclaw_retryable_error_codes") or ""), "provider_timeout,context_overflow")
|
||||
self.assertFalse(bool(b2.get("openclaw_retry_codes_strict_mode")))
|
||||
self.assertEqual(list(b2.get("unknown_retryable_error_codes") or []), [])
|
||||
|
||||
def test_tool_policy_retry_codes_unknown_filtered(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
payload = {
|
||||
"turn_max_tool_workers": 8,
|
||||
"turn_max_tool_rounds": 8,
|
||||
"turn_max_context_messages": 80,
|
||||
"sse_queue_maxsize": 2000,
|
||||
"tool_log_max_chars": 200000,
|
||||
"enable_mcp_tools": True,
|
||||
"enable_plugin_tools": False,
|
||||
"tool_llm_message_max_chars": 0,
|
||||
"mcp_filesystem_extra_roots": "",
|
||||
"mcp_env_allowlist": "",
|
||||
"openclaw_retryable_error_codes": "provider_timeout, typo_code_x, context_overflow",
|
||||
"openclaw_retry_codes_strict_mode": False,
|
||||
"wecom_longconn_workers": 2,
|
||||
"wecom_longconn_inbound_queue_maxsize": 200,
|
||||
}
|
||||
r = self.client.post("/admin/api/tool-policy", json=payload, headers=headers)
|
||||
self.assertEqual(r.status_code, 200)
|
||||
b = r.json()
|
||||
self.assertTrue(b.get("ok"))
|
||||
self.assertEqual(str(b.get("openclaw_retryable_error_codes") or ""), "provider_timeout,context_overflow")
|
||||
self.assertEqual(list(b.get("unknown_retryable_error_codes") or []), ["typo_code_x"])
|
||||
|
||||
def test_tool_policy_retry_codes_unknown_rejected_in_strict_mode(self) -> None:
|
||||
token = self._login()
|
||||
headers = {"authorization": f"Bearer {token}"}
|
||||
payload = {
|
||||
"turn_max_tool_workers": 8,
|
||||
"turn_max_tool_rounds": 8,
|
||||
"turn_max_context_messages": 80,
|
||||
"sse_queue_maxsize": 2000,
|
||||
"tool_log_max_chars": 200000,
|
||||
"enable_mcp_tools": True,
|
||||
"enable_plugin_tools": False,
|
||||
"tool_llm_message_max_chars": 0,
|
||||
"mcp_filesystem_extra_roots": "",
|
||||
"mcp_env_allowlist": "",
|
||||
"openclaw_retryable_error_codes": "provider_timeout, typo_code_x",
|
||||
"openclaw_retry_codes_strict_mode": True,
|
||||
"wecom_longconn_workers": 2,
|
||||
"wecom_longconn_inbound_queue_maxsize": 200,
|
||||
}
|
||||
r = self.client.post("/admin/api/tool-policy", json=payload, headers=headers)
|
||||
self.assertEqual(r.status_code, 400)
|
||||
b = r.json()
|
||||
self.assertIn("detail", b)
|
||||
detail = b["detail"]
|
||||
self.assertEqual(str(detail.get("code") or ""), "invalid_retryable_error_codes")
|
||||
self.assertEqual(list(detail.get("unknown_retryable_error_codes") or []), ["typo_code_x"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
62
tests/test_admin_tools_exposure_trace_setting_api.py
Normal file
62
tests/test_admin_tools_exposure_trace_setting_api.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminToolsExposureTraceSettingApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_exposure_trace_setting_get_and_save(self) -> None:
|
||||
g1 = self.client.get("/admin/api/tools/exposure-trace-setting", headers=self._h())
|
||||
self.assertEqual(g1.status_code, 200, g1.text)
|
||||
self.assertTrue((g1.json() or {}).get("ok"))
|
||||
|
||||
s = self.client.post("/admin/api/tools/exposure-trace-setting", json={"enabled": True}, headers=self._h())
|
||||
self.assertEqual(s.status_code, 200, s.text)
|
||||
self.assertTrue((s.json() or {}).get("enabled"))
|
||||
|
||||
g2 = self.client.get("/admin/api/tools/exposure-trace-setting", headers=self._h())
|
||||
self.assertEqual(g2.status_code, 200, g2.text)
|
||||
self.assertTrue((g2.json() or {}).get("enabled"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
67
tests/test_admin_tools_self_check_api.py
Normal file
67
tests/test_admin_tools_self_check_api.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class AdminToolsSelfCheckApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
t = self.store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
self.store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
r = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
self.token = str((r.json() or {}).get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_self_check_returns_role_summary(self) -> None:
|
||||
r = self.client.get("/admin/api/tools/self-check", headers=self._h())
|
||||
self.assertEqual(r.status_code, 200, r.text)
|
||||
body = r.json() or {}
|
||||
self.assertTrue(body.get("ok"))
|
||||
self.assertGreaterEqual(int(body.get("roles_total") or 0), 1)
|
||||
summary = dict(body.get("summary") or {})
|
||||
self.assertIn("total_internal_tools", summary)
|
||||
self.assertIn("total_wired_tools", summary)
|
||||
self.assertIn("total_perm_ban_9999", summary)
|
||||
items = list(body.get("items") or [])
|
||||
self.assertTrue(items)
|
||||
one = dict(items[0] if items else {})
|
||||
self.assertIn("role", one)
|
||||
self.assertIn("role_mode", one)
|
||||
self.assertIn("wired_count", one)
|
||||
self.assertIn("policy_perm_ban_9999", one)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
27
tests/test_anthropic_transport.py
Normal file
27
tests/test_anthropic_transport.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.transports.anthropic_messages import parse_anthropic_stream_events
|
||||
|
||||
|
||||
def test_anthropic_stream_parses_text_and_tool_use() -> None:
|
||||
events = [
|
||||
{"type": "message_start"},
|
||||
{"type": "content_block_start", "content_block": {"type": "text", "text": ""}},
|
||||
{"type": "content_block_delta", "delta": {"type": "text_delta", "text": "Hi "}},
|
||||
{"type": "content_block_delta", "delta": {"type": "text_delta", "text": "there"}},
|
||||
{"type": "content_block_stop"},
|
||||
{"type": "content_block_start", "content_block": {"type": "tool_use", "id": "toolu_1", "name": "query_route", "input": {}}},
|
||||
{"type": "content_block_delta", "delta": {"type": "input_json_delta", "partial_json": "{\"destination\":\"1.1.1.1\"}"}},
|
||||
{"type": "content_block_stop"},
|
||||
{"type": "message_stop"},
|
||||
]
|
||||
buf: list[str] = []
|
||||
text, tool_calls = parse_anthropic_stream_events(events, on_token=buf.append)
|
||||
assert text == "Hi there"
|
||||
assert "".join(buf) == "Hi there"
|
||||
assert len(tool_calls) == 1
|
||||
tc = tool_calls[0]
|
||||
assert tc.id == "toolu_1"
|
||||
assert tc.name == "query_route"
|
||||
assert tc.arguments.get("destination") == "1.1.1.1"
|
||||
|
||||
38
tests/test_backup_retention.py
Normal file
38
tests/test_backup_retention.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import oclaw.platform.config.paths as paths_mod
|
||||
|
||||
|
||||
def _mkdir(p: Path) -> None:
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
(p / "marker.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
|
||||
def test_pre_merge_backup_retention_default_keep_3(tmp_path, monkeypatch) -> None:
|
||||
data_dir = tmp_path / "oclaw" / "data"
|
||||
for ts in ("20260101_000001", "20260101_000002", "20260101_000003", "20260101_000004", "20260101_000005"):
|
||||
_mkdir(data_dir / f"_pre_merge_sqlite_{ts}")
|
||||
monkeypatch.setenv("OPS_ASSISTANT_PREMERGE_BACKUP_KEEP", "")
|
||||
monkeypatch.setattr(paths_mod, "PROJECT_ROOT", tmp_path)
|
||||
paths_mod._cleanup_pre_merge_backups()
|
||||
|
||||
keep = sorted(p.name for p in data_dir.glob("_pre_merge_sqlite_*") if p.is_dir())
|
||||
assert keep == [
|
||||
"_pre_merge_sqlite_20260101_000003",
|
||||
"_pre_merge_sqlite_20260101_000004",
|
||||
"_pre_merge_sqlite_20260101_000005",
|
||||
]
|
||||
|
||||
|
||||
def test_pre_merge_backup_retention_respects_env_keep(tmp_path, monkeypatch) -> None:
|
||||
data_dir = tmp_path / "oclaw" / "data"
|
||||
for ts in ("20260101_000001", "20260101_000002", "20260101_000003"):
|
||||
_mkdir(data_dir / f"_pre_merge_sqlite_{ts}")
|
||||
monkeypatch.setenv("OPS_ASSISTANT_PREMERGE_BACKUP_KEEP", "1")
|
||||
monkeypatch.setattr(paths_mod, "PROJECT_ROOT", tmp_path)
|
||||
paths_mod._cleanup_pre_merge_backups()
|
||||
|
||||
keep = sorted(p.name for p in data_dir.glob("_pre_merge_sqlite_*") if p.is_dir())
|
||||
assert keep == ["_pre_merge_sqlite_20260101_000003"]
|
||||
42
tests/test_catalog_expert_role_loading.py
Normal file
42
tests/test_catalog_expert_role_loading.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from oclaw.tools.base import ToolSpec
|
||||
from oclaw.tools.catalog import materialize_tool_specs
|
||||
|
||||
|
||||
def _mk_spec(name: str) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description="d",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler=lambda _args: {"ok": True},
|
||||
tags=frozenset({"internal"}),
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_loads_role_scoped_expert_tools() -> None:
|
||||
with patch(
|
||||
"oclaw.tools.catalog.materialize_tools_for_expert",
|
||||
return_value=[_mk_spec("custom_tool_a"), _mk_spec("custom_tool_b")],
|
||||
):
|
||||
with patch("oclaw.tools.catalog.materialize_public_tools", return_value=[_mk_spec("system_time")]):
|
||||
specs = materialize_tool_specs(expert="generalist")
|
||||
names = {x.name for x in specs}
|
||||
assert "system_time" in names
|
||||
assert "custom_tool_a" in names
|
||||
assert "custom_tool_b" in names
|
||||
|
||||
|
||||
def test_catalog_deduplicates_same_tool_name() -> None:
|
||||
with patch(
|
||||
"oclaw.tools.catalog.materialize_tools_for_expert",
|
||||
return_value=[_mk_spec("system_time"), _mk_spec("custom_tool_a")],
|
||||
):
|
||||
with patch("oclaw.tools.catalog.materialize_public_tools", return_value=[_mk_spec("system_time")]):
|
||||
specs = materialize_tool_specs(expert="generalist")
|
||||
names = [x.name for x in specs]
|
||||
assert names.count("system_time") == 1
|
||||
assert "custom_tool_a" in set(names)
|
||||
|
||||
42
tests/test_catalog_mcp_integration.py
Normal file
42
tests/test_catalog_mcp_integration.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.catalog import materialize_tool_specs
|
||||
|
||||
|
||||
def _seed_single_mcp_tool(store: SqliteStore) -> None:
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/echo",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "echo_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo",
|
||||
tools=[{"tool_name": "ping", "description": "Ping", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
|
||||
|
||||
def test_catalog_materializes_mcp_tools_when_enabled(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
_seed_single_mcp_tool(store)
|
||||
store.set_setting("AIA_ENABLE_MCP_TOOLS", "1")
|
||||
specs = materialize_tool_specs(store=store, specialist="generalist")
|
||||
names = {x.name for x in specs}
|
||||
assert "system_time" in names
|
||||
assert "mcp__echo__ping" in names
|
||||
|
||||
|
||||
def test_catalog_skips_mcp_tools_when_disabled(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
_seed_single_mcp_tool(store)
|
||||
store.set_setting("AIA_ENABLE_MCP_TOOLS", "0")
|
||||
specs = materialize_tool_specs(store=store, specialist="generalist")
|
||||
names = {x.name for x in specs}
|
||||
assert "system_time" in names
|
||||
assert "mcp__echo__ping" not in names
|
||||
|
||||
26
tests/test_cc_mini_vendor.py
Normal file
26
tests/test_cc_mini_vendor.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.platform.integrations import cc_mini_vendor as m
|
||||
|
||||
|
||||
def test_cc_mini_pythonpath_prepends_src_when_available() -> None:
|
||||
if not m.cc_mini_available():
|
||||
pytest.skip("vendor/cc-mini submodule not initialized")
|
||||
env = m.cc_mini_pythonpath_env({"PYTHONPATH": "/existing"})
|
||||
head, *rest = env["PYTHONPATH"].split(os.pathsep)
|
||||
assert head == str(m.cc_mini_src_dir())
|
||||
assert "/existing" in env["PYTHONPATH"]
|
||||
|
||||
|
||||
def test_run_cc_mini_cli_missing_raises() -> None:
|
||||
# If someone deletes only engine.py this still triggers missing tree in practice;
|
||||
# we only assert the error type when the whole checkout is absent.
|
||||
if m.cc_mini_available():
|
||||
pytest.skip("cc-mini present")
|
||||
with pytest.raises(FileNotFoundError) as ei:
|
||||
m.run_cc_mini_cli(["--help"])
|
||||
assert "git submodule" in str(ei.value).lower() or "cc-mini" in str(ei.value).lower()
|
||||
97
tests/test_chat_profile_api.py
Normal file
97
tests/test_chat_profile_api.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
MINI_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
class ChatProfileApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
store = SqliteStore(str(self.db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200, resp.text)
|
||||
self.token = str(resp.json().get("token") or "")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _h(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def test_profile_get_patch_avatar_delete(self) -> None:
|
||||
g = self.client.get("/admin/api/chat/profile", headers=self._h())
|
||||
self.assertEqual(g.status_code, 200, g.text)
|
||||
data = g.json()
|
||||
self.assertTrue(data.get("ok"))
|
||||
prof = data.get("profile") or {}
|
||||
self.assertEqual(prof.get("username"), "administrator")
|
||||
self.assertFalse(prof.get("avatar_attachment_id"))
|
||||
|
||||
p = self.client.patch(
|
||||
"/admin/api/chat/profile",
|
||||
json={"display_name": "Admin Renamed"},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(p.status_code, 200, p.text)
|
||||
self.assertEqual((p.json().get("profile") or {}).get("display_name"), "Admin Renamed")
|
||||
|
||||
up = self.client.post(
|
||||
"/admin/api/chat/profile/avatar",
|
||||
files={"file": ("x.png", MINI_PNG, "image/png")},
|
||||
headers=self._h(),
|
||||
)
|
||||
self.assertEqual(up.status_code, 200, up.text)
|
||||
aid = str((up.json() or {}).get("avatar_attachment_id") or "").strip()
|
||||
self.assertTrue(aid)
|
||||
|
||||
g2 = self.client.get("/admin/api/chat/profile", headers=self._h())
|
||||
self.assertEqual((g2.json().get("profile") or {}).get("avatar_attachment_id"), aid)
|
||||
|
||||
att = self.client.get(f"/admin/api/chat/attachments/{aid}", headers=self._h())
|
||||
self.assertEqual(att.status_code, 200)
|
||||
self.assertTrue(len(att.content) > 10)
|
||||
|
||||
d = self.client.delete("/admin/api/chat/profile/avatar", headers=self._h())
|
||||
self.assertEqual(d.status_code, 200, d.text)
|
||||
g3 = self.client.get("/admin/api/chat/profile", headers=self._h())
|
||||
self.assertFalse((g3.json().get("profile") or {}).get("avatar_attachment_id"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
91
tests/test_chat_session_full_smoke.py
Normal file
91
tests/test_chat_session_full_smoke.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
"""端到端:管理台登录 → 创建聊天会话 → 发送一条消息(不依赖外网 LLM)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ChatSessionFullSmokeTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
os.environ["OPS_ASSISTANT_MODE"] = "rule"
|
||||
store = SqliteStore(str(self.db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
self.token = self._login()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for k in ("OPS_ASSISTANT_DB_PATH", "OPS_ASSISTANT_MODE"):
|
||||
os.environ.pop(k, None)
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_create_session_send_message_list_messages(self) -> None:
|
||||
cr = self.client.post("/admin/api/chat/sessions", json={"title": "smoke"}, headers=self._headers())
|
||||
self.assertEqual(cr.status_code, 200, cr.text)
|
||||
cj = cr.json()
|
||||
self.assertTrue(cj.get("ok"), cj)
|
||||
sid = str((cj.get("session") or {}).get("id") or "")
|
||||
self.assertTrue(sid, cj)
|
||||
|
||||
mr = self.client.post(
|
||||
f"/admin/api/chat/sessions/{sid}/messages",
|
||||
json={"text": "ping"},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(mr.status_code, 200, mr.text)
|
||||
mj = mr.json()
|
||||
self.assertTrue(mj.get("ok"), mj)
|
||||
reply = str(mj.get("reply") or "")
|
||||
self.assertTrue(len(reply) > 0, mj)
|
||||
|
||||
lr = self.client.get(f"/admin/api/chat/sessions/{sid}/messages", headers=self._headers())
|
||||
self.assertEqual(lr.status_code, 200, lr.text)
|
||||
lj = lr.json()
|
||||
self.assertTrue(lj.get("ok"), lj)
|
||||
msgs = lj.get("messages") or []
|
||||
roles = [str(m.get("role") or "") for m in msgs if isinstance(m, dict)]
|
||||
self.assertIn("user", roles)
|
||||
self.assertIn("assistant", roles)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
130
tests/test_chat_session_isolation.py
Normal file
130
tests/test_chat_session_isolation.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"""Regression: chat_session must not change ui_session_owner implicitly on read or list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def _pw_hash(pw: str) -> str:
|
||||
return hashlib.sha256(pw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class ChatSessionIsolationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
store = SqliteStore(str(self.db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=_pw_hash("test-admin-pass"),
|
||||
is_active=True,
|
||||
)
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="alice",
|
||||
display_name="Alice",
|
||||
role="member",
|
||||
password_hash=_pw_hash("alice-pass"),
|
||||
is_active=True,
|
||||
)
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="bob",
|
||||
display_name="Bob",
|
||||
role="member",
|
||||
password_hash=_pw_hash("bob-pass"),
|
||||
is_active=True,
|
||||
)
|
||||
self.alice_id = str(store.get_user_by_username(tenant_id=self.tenant_id, username="alice")["id"])
|
||||
self.bob_id = str(store.get_user_by_username(tenant_id=self.tenant_id, username="bob")["id"])
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _token(self, username: str, password: str) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"purpose": "chat",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_orphan_not_visible_and_not_claimed_on_get(self) -> None:
|
||||
store = SqliteStore(str(self.db))
|
||||
orphan = store.create_session("no-owner-yet")
|
||||
self.assertIsNone(
|
||||
store.get_session_for_user(
|
||||
session_id=str(orphan.id),
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.alice_id,
|
||||
)
|
||||
)
|
||||
tok = self._token("alice", "alice-pass")
|
||||
h = {"authorization": f"Bearer {tok}"}
|
||||
r = self.client.get(f"/admin/api/chat/sessions/{orphan.id}/messages", headers=h)
|
||||
self.assertEqual(r.status_code, 404)
|
||||
listed = self.client.get("/admin/api/chat/sessions?limit=50&offset=0", headers=h).json()
|
||||
ids = {str(s.get("id")) for s in listed.get("sessions", [])}
|
||||
self.assertNotIn(str(orphan.id), ids)
|
||||
|
||||
def test_user_cannot_read_other_owned_session(self) -> None:
|
||||
store = SqliteStore(str(self.db))
|
||||
owned = store.create_session_for_user(
|
||||
title="alice-only",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.alice_id,
|
||||
)
|
||||
alice = self._token("alice", "alice-pass")
|
||||
bob = self._token("bob", "bob-pass")
|
||||
ra = self.client.get(
|
||||
f"/admin/api/chat/sessions/{owned.id}/messages",
|
||||
headers={"authorization": f"Bearer {alice}"},
|
||||
)
|
||||
self.assertEqual(ra.status_code, 200, ra.text)
|
||||
rb = self.client.get(
|
||||
f"/admin/api/chat/sessions/{owned.id}/messages",
|
||||
headers={"authorization": f"Bearer {bob}"},
|
||||
)
|
||||
self.assertEqual(rb.status_code, 404)
|
||||
|
||||
def test_stop_requires_session_access(self) -> None:
|
||||
store = SqliteStore(str(self.db))
|
||||
owned = store.create_session_for_user(
|
||||
title="alice-only",
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.alice_id,
|
||||
)
|
||||
bob = self._token("bob", "bob-pass")
|
||||
rs = self.client.post(
|
||||
f"/admin/api/chat/sessions/{owned.id}/stop",
|
||||
headers={"authorization": f"Bearer {bob}"},
|
||||
)
|
||||
self.assertEqual(rs.status_code, 404)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
17
tests/test_clawhub_client.py
Normal file
17
tests/test_clawhub_client.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.tools.skills import clawhub_client
|
||||
|
||||
|
||||
def test_get_skill_detail_handles_latest_version_object(monkeypatch) -> None:
|
||||
def _fake_get_json(url, *, params=None, headers=None): # noqa: ANN001,ARG001
|
||||
return {
|
||||
"slug": "self-improving-agent",
|
||||
"latestVersion": {"version": "3.0.16"},
|
||||
"versions": [{"version": "3.0.16"}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(clawhub_client, "_safe_get_json", _fake_get_json)
|
||||
out = clawhub_client.get_skill_detail("self-improving-agent")
|
||||
assert out.get("latestVersion") == "3.0.16"
|
||||
assert "version=3.0.16" in str(out.get("archiveUrl") or "")
|
||||
21
tests/test_fastapi_inbound.py
Normal file
21
tests/test_fastapi_inbound.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
|
||||
|
||||
def test_inbound_route_uses_given_channel() -> None:
|
||||
app = create_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
r = client.post("/inbound", json={"channel": "bad", "text": "x"})
|
||||
assert r.status_code == 500
|
||||
assert "Internal Server Error" in r.text
|
||||
|
||||
|
||||
def test_wecom_inbound_overrides_channel() -> None:
|
||||
app = create_app()
|
||||
client = TestClient(app, raise_server_exceptions=False)
|
||||
r = client.post("/wecom/inbound", json={"channel": "bad", "text": "x", "user_id": "u1"})
|
||||
assert r.status_code == 500
|
||||
assert "Internal Server Error" in r.text
|
||||
398
tests/test_file_attachments.py
Normal file
398
tests/test_file_attachments.py
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
import oclaw.platform.files.file_attachments as fa
|
||||
from oclaw.platform.files.file_attachments import process_file_data
|
||||
from oclaw.platform.files.tabular_attachment_store import (
|
||||
aggregate_table,
|
||||
analyze_table_full_scan,
|
||||
query_table,
|
||||
run_table_sql,
|
||||
save_workbook,
|
||||
)
|
||||
|
||||
|
||||
def _zip_bytes(files: dict[str, bytes]) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for name, data in files.items():
|
||||
zf.writestr(name, data)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_csv_is_summarized_not_full_dump() -> None:
|
||||
payload = (
|
||||
"c1,c2,c3\n"
|
||||
"1,2,3\n"
|
||||
"4,5,6\n"
|
||||
).encode("utf-8")
|
||||
out = process_file_data("table.csv", payload)
|
||||
assert out and out[0]["type"] == "text"
|
||||
content = str(out[0].get("content") or "")
|
||||
assert "# Table Summary" in content
|
||||
assert "rows: 2" in content
|
||||
assert "cols: 3" in content
|
||||
|
||||
|
||||
def test_zip_file_count_limit_returns_error_attachment() -> None:
|
||||
files = {f"f{i}.txt": b"x" for i in range(205)}
|
||||
out = process_file_data("bulk.zip", _zip_bytes(files))
|
||||
assert out
|
||||
first = out[0]
|
||||
assert str(first.get("name") or "") == "zip-error"
|
||||
assert "too many files" in str(first.get("content") or "").lower()
|
||||
|
||||
|
||||
def test_nested_zip_depth_limit_is_enforced() -> None:
|
||||
z4 = _zip_bytes({"deep.txt": b"hello"})
|
||||
z3 = _zip_bytes({"z4.zip": z4})
|
||||
z2 = _zip_bytes({"z3.zip": z3})
|
||||
z1 = _zip_bytes({"z2.zip": z2})
|
||||
out = process_file_data("z1.zip", z1)
|
||||
assert any("nesting too deep" in str(x.get("content") or "").lower() for x in out)
|
||||
|
||||
|
||||
def test_html_is_cleaned_before_attachment_text() -> None:
|
||||
html = b"<html><head><style>.x{display:none}</style></head><body><h1>Title</h1><script>alert(1)</script><p>Hello</p></body></html>"
|
||||
out = process_file_data("page.html", html)
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "Title" in content
|
||||
assert "Hello" in content
|
||||
assert "alert(1)" not in content
|
||||
assert "<h1>" not in content
|
||||
|
||||
|
||||
def test_pdf_summary_contains_page_markers() -> None:
|
||||
class _Page:
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def extract_text(self) -> str:
|
||||
return self._text
|
||||
|
||||
class _Reader:
|
||||
def __init__(self, _stream) -> None:
|
||||
self.pages = [_Page("P1"), _Page("P2")]
|
||||
|
||||
old = fa.PdfReader
|
||||
try:
|
||||
fa.PdfReader = _Reader # type: ignore[assignment]
|
||||
out = process_file_data("doc.pdf", b"%PDF")
|
||||
finally:
|
||||
fa.PdfReader = old # type: ignore[assignment]
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "# PDF Summary" in content
|
||||
assert "pages: 2" in content
|
||||
assert "## Page 1" in content
|
||||
assert "## Page 2" in content
|
||||
|
||||
|
||||
def test_zip_unsafe_member_path_is_blocked() -> None:
|
||||
payload = _zip_bytes({"../evil.txt": b"x"})
|
||||
out = process_file_data("unsafe.zip", payload)
|
||||
assert out
|
||||
first = out[0]
|
||||
assert str(first.get("name") or "") == "zip-error"
|
||||
assert "unsafe path" in str(first.get("content") or "").lower()
|
||||
|
||||
|
||||
def test_csv_marks_sampled_when_row_limit_hit() -> None:
|
||||
rows = ["c1,c2"] + [f"{i},{i+1}" for i in range(0, 6000)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("big.csv", payload)
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "sampled: yes" in content
|
||||
|
||||
|
||||
def test_zip_member_name_too_long_is_blocked() -> None:
|
||||
payload = _zip_bytes({("a" * 300) + ".txt": b"x"})
|
||||
out = process_file_data("unsafe.zip", payload)
|
||||
assert out
|
||||
first = out[0]
|
||||
assert str(first.get("name") or "") == "zip-error"
|
||||
assert "unsafe path" in str(first.get("content") or "").lower()
|
||||
|
||||
|
||||
def test_csv_marks_clipped_columns_when_width_limit_hit() -> None:
|
||||
columns = [f"c{i}" for i in range(0, 260)]
|
||||
header = ",".join(columns)
|
||||
row = ",".join(["1"] * len(columns))
|
||||
payload = (header + "\n" + row + "\n").encode("utf-8")
|
||||
out = process_file_data("wide.csv", payload)
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "clipped_columns: yes" in content
|
||||
assert "cols: 200" in content
|
||||
|
||||
|
||||
def test_csv_marks_clipped_cells_when_cell_content_too_long() -> None:
|
||||
long_cell = "x" * 1200
|
||||
payload = f"c1,c2\n{long_cell},ok\n".encode("utf-8")
|
||||
out = process_file_data("long-cell.csv", payload)
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "clipped_cells: yes" in content
|
||||
assert "...[cell-truncated]" in content
|
||||
|
||||
|
||||
def test_tabular_limits_can_be_overridden_by_config(tmp_path: Path, monkeypatch) -> None:
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"auto": {
|
||||
"attachments": {
|
||||
"tabular": {
|
||||
"max_rows_read": 2,
|
||||
"max_columns": 2,
|
||||
"max_cell_chars": 10,
|
||||
"large_table_preview_rows": 1,
|
||||
"tool_mode_enabled": True,
|
||||
"tool_mode_min_rows": 3,
|
||||
"tool_mode_max_bytes": 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg_path = tmp_path / "oclaw.json"
|
||||
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
monkeypatch.setenv("AIA_OCLAW_CONFIG_PATH", str(cfg_path))
|
||||
fa._attachments_limits.cache_clear()
|
||||
try:
|
||||
payload = "c1,c2,c3\n" + ("x" * 40) + ",b,c\n1,2,3\n4,5,6\n"
|
||||
out = process_file_data("configured.csv", payload.encode("utf-8"))
|
||||
finally:
|
||||
fa._attachments_limits.cache_clear()
|
||||
content = str((out[0] if out else {}).get("content") or "")
|
||||
assert "sampled: yes" in content
|
||||
assert "clipped_columns: yes" in content
|
||||
assert "clipped_cells: yes" in content
|
||||
assert "Preview (first 1 rows)" in content
|
||||
|
||||
|
||||
def test_large_csv_emits_tabular_ref_and_can_query() -> None:
|
||||
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 25050)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("large.csv", payload)
|
||||
assert out
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
assert table_id
|
||||
got = query_table(
|
||||
table_id=table_id,
|
||||
columns=["c1", "c2"],
|
||||
limit=5,
|
||||
offset=0,
|
||||
where_contains={"column": "c2", "keyword": "row-12"},
|
||||
)
|
||||
assert bool(got.get("ok"))
|
||||
assert str(got.get("table_id") or "") == table_id
|
||||
assert str(got.get("engine") or "") in {"builtin_sqlite", "mcp_sqlite"}
|
||||
|
||||
|
||||
def test_large_csv_can_aggregate_grouped_sum() -> None:
|
||||
rows = ["dept,amount"] + [f"{'A' if i % 2 == 0 else 'B'},{i}" for i in range(0, 25010)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("large2.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
got = aggregate_table(
|
||||
table_id=table_id,
|
||||
metric="sum",
|
||||
target_column="amount",
|
||||
group_by="dept",
|
||||
top_n=5,
|
||||
)
|
||||
assert bool(got.get("ok"))
|
||||
rows_out = got.get("rows") or []
|
||||
assert isinstance(rows_out, list) and len(rows_out) >= 2
|
||||
groups = {str(x.get("group") or "") for x in rows_out}
|
||||
assert "A" in groups and "B" in groups
|
||||
assert str(got.get("engine") or "") in {"builtin_sqlite", "mcp_sqlite"}
|
||||
|
||||
|
||||
def test_query_falls_back_to_builtin_when_mcp_unavailable(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_MCP_SQLITE_COMMAND", "nonexistent_mcp_sqlite_binary")
|
||||
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 25020)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("fallback.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
got = query_table(table_id=table_id, columns=["c1"], limit=3, offset=0)
|
||||
assert bool(got.get("ok"))
|
||||
assert str(got.get("engine") or "") == "builtin_sqlite"
|
||||
|
||||
|
||||
def test_run_tabular_sql_select_works_and_blocks_mutation() -> None:
|
||||
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 25020)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("sql.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
ok = run_table_sql(table_id=table_id, sql='SELECT "c1","c2" FROM rows_data WHERE "c2" LIKE \'%row-12%\'', limit=10)
|
||||
assert bool(ok.get("ok"))
|
||||
assert int(ok.get("rows_returned") or 0) >= 1
|
||||
assert "SELECT" in str(ok.get("executed_sql") or "")
|
||||
guard = ok.get("sql_guard") or {}
|
||||
assert bool(guard.get("readonly_enforced"))
|
||||
assert bool(guard.get("auto_limit_applied"))
|
||||
bad = run_table_sql(table_id=table_id, sql="DROP TABLE rows_data", limit=10)
|
||||
assert not bool(bad.get("ok"))
|
||||
assert str(bad.get("error") or "") == "sql_not_readonly"
|
||||
|
||||
|
||||
def test_run_tabular_sql_timeout_returns_guard(monkeypatch) -> None:
|
||||
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 25020)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("sql-timeout.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
monkeypatch.setenv("AIA_TABULAR_SQL_TIMEOUT_MS", "100")
|
||||
slow_sql = (
|
||||
"WITH RECURSIVE t(n) AS ("
|
||||
"SELECT 1 UNION ALL SELECT n+1 FROM t WHERE n < 5000000"
|
||||
") SELECT SUM(n) FROM t"
|
||||
)
|
||||
got = run_table_sql(table_id=table_id, sql=slow_sql, limit=200)
|
||||
assert not bool(got.get("ok"))
|
||||
assert str(got.get("error") or "") == "sql_timeout"
|
||||
guard = got.get("sql_guard") or {}
|
||||
assert bool(guard.get("timeout_hit"))
|
||||
assert int(guard.get("timeout_ms") or 0) == 100
|
||||
|
||||
|
||||
def test_run_tabular_sql_timeout_reads_oclaw_config(tmp_path: Path, monkeypatch) -> None:
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"auto": {
|
||||
"attachments": {
|
||||
"tabular": {
|
||||
"sql_timeout_ms": 222,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg_path = tmp_path / "oclaw.json"
|
||||
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
monkeypatch.setenv("AIA_OCLAW_CONFIG_PATH", str(cfg_path))
|
||||
monkeypatch.delenv("AIA_TABULAR_SQL_TIMEOUT_MS", raising=False)
|
||||
rows = ["c1,c2"] + [f"{i},row-{i}" for i in range(0, 25020)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("sql-timeout-config.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
got = run_table_sql(table_id=table_id, sql='SELECT "c1" FROM rows_data WHERE "c2" LIKE \'%row-12%\'', limit=10)
|
||||
assert bool(got.get("ok"))
|
||||
guard = got.get("sql_guard") or {}
|
||||
assert int(guard.get("timeout_ms") or 0) == 222
|
||||
|
||||
|
||||
def test_save_workbook_normalizes_duplicate_and_blank_headers() -> None:
|
||||
df = pd.DataFrame([["1", "2", "3"]], columns=["", "重复", "重复"])
|
||||
meta = save_workbook(attachment_id="aid1", name="dup.xlsx", sheets={"Data": df})
|
||||
cols = list(meta.get("columns") or [])
|
||||
assert cols[0].startswith("col_")
|
||||
assert "重复" in cols
|
||||
assert "重复__2" in cols
|
||||
|
||||
|
||||
def test_query_can_target_specific_excel_sheet() -> None:
|
||||
s1 = pd.DataFrame([["a", "1"]], columns=["k", "v"])
|
||||
s2 = pd.DataFrame([["b", "2"]], columns=["k", "v"])
|
||||
meta = save_workbook(attachment_id="aid2", name="multi.xlsx", sheets={"S1": s1, "S2": s2})
|
||||
tid = str(meta.get("table_id") or "")
|
||||
got = query_table(table_id=tid, sheet="S2", columns=["k", "v"], limit=5, offset=0)
|
||||
assert bool(got.get("ok"))
|
||||
rows = got.get("rows") or []
|
||||
assert rows and str(rows[0].get("k") or "") == "b"
|
||||
|
||||
|
||||
def test_full_scan_analyzes_all_rows_and_returns_audit() -> None:
|
||||
rows = ["dept,score"] + [f"{'A' if i % 2 == 0 else 'B'},{i % 5}" for i in range(0, 25025)]
|
||||
payload = ("\n".join(rows)).encode("utf-8")
|
||||
out = process_file_data("fullscan.csv", payload)
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
table_id = str(tab_refs[0].get("table_id") or "")
|
||||
got = analyze_table_full_scan(table_id=table_id, columns=["dept", "score"], top_values_limit=2)
|
||||
assert bool(got.get("ok"))
|
||||
assert int(got.get("rows_scanned") or 0) >= 25025
|
||||
audit = got.get("scan_audit") or {}
|
||||
assert bool(audit.get("full_scan"))
|
||||
assert int(audit.get("rows_scanned") or 0) >= 25025
|
||||
stats = got.get("column_stats") or []
|
||||
assert isinstance(stats, list) and len(stats) == 2
|
||||
dept = [x for x in stats if str(x.get("column") or "") == "dept"]
|
||||
assert dept
|
||||
tops = dept[0].get("top_values") or []
|
||||
assert isinstance(tops, list) and len(tops) <= 2
|
||||
|
||||
|
||||
def test_xlsx_zip_safety_blocks_unsafe_paths() -> None:
|
||||
payload = _zip_bytes({"../xl/workbook.xml": b"x"})
|
||||
out = process_file_data("bad.xlsx", payload)
|
||||
assert out
|
||||
first = out[0]
|
||||
assert str(first.get("type") or "") == "text"
|
||||
assert "unsafe path" in str(first.get("content") or "").lower()
|
||||
|
||||
|
||||
def test_excel_sheet_count_cap_applies_in_tool_mode(tmp_path: Path, monkeypatch) -> None:
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"auto": {
|
||||
"attachments": {
|
||||
"tabular": {
|
||||
"max_rows_read": 200,
|
||||
"max_columns": 50,
|
||||
"max_cell_chars": 200,
|
||||
"max_excel_sheets": 1,
|
||||
"large_table_preview_rows": 20,
|
||||
"tool_mode_enabled": True,
|
||||
"tool_mode_min_rows": 1,
|
||||
"tool_mode_max_bytes": 10 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg_path = tmp_path / "oclaw.json"
|
||||
cfg_path.write_text(json.dumps(cfg), encoding="utf-8")
|
||||
monkeypatch.setenv("AIA_OCLAW_CONFIG_PATH", str(cfg_path))
|
||||
fa._attachments_limits.cache_clear()
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
with pd.ExcelWriter(buf) as writer:
|
||||
pd.DataFrame({"a": ["1", "2"], "b": ["3", "4"]}).to_excel(writer, index=False, sheet_name="S1")
|
||||
pd.DataFrame({"a": ["5", "6"], "b": ["7", "8"]}).to_excel(writer, index=False, sheet_name="S2")
|
||||
out = process_file_data("multi.xlsx", buf.getvalue())
|
||||
finally:
|
||||
fa._attachments_limits.cache_clear()
|
||||
tab_refs = [x for x in out if isinstance(x, dict) and str(x.get("type") or "") == "tabular_ref"]
|
||||
assert tab_refs
|
||||
sheets = list(tab_refs[0].get("sheets") or [])
|
||||
assert len(sheets) == 1
|
||||
notes = [x for x in out if isinstance(x, dict) and str(x.get("name") or "").endswith(".sheet-limit")]
|
||||
assert notes
|
||||
|
||||
29
tests/test_frontmatter_yaml.py
Normal file
29
tests/test_frontmatter_yaml.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.prompts.frontmatter import parse_frontmatter_dict, parse_markdown_document
|
||||
|
||||
|
||||
def test_parse_yaml_frontmatter_nested_metadata() -> None:
|
||||
fm = parse_frontmatter_dict(
|
||||
"name: demo\ndescription: x\nmetadata:\n openclaw:\n install:\n - id: i1\n kind: node\n",
|
||||
source="test",
|
||||
)
|
||||
assert fm.get("name") == "demo"
|
||||
md = fm.get("metadata")
|
||||
assert isinstance(md, dict)
|
||||
assert isinstance(md.get("openclaw"), dict)
|
||||
|
||||
|
||||
def test_parse_markdown_document_roundtrip() -> None:
|
||||
raw = "---\ntitle: t\nsummary: s\nread_when: now\n---\n\n# Body\nhello"
|
||||
meta, body = parse_markdown_document(raw, source="test")
|
||||
assert meta.get("title") == "t"
|
||||
assert "hello" in body
|
||||
|
||||
|
||||
def test_strict_yaml_invalid_raises(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("AIA_PROMPT_FRONTMATTER_STRICT", "1")
|
||||
with pytest.raises(ValueError):
|
||||
parse_frontmatter_dict("*invalid\nfoo: bar", source="test")
|
||||
48
tests/test_gateway_image_generation_chain.py
Normal file
48
tests/test_gateway_image_generation_chain.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.gateway.server_plugins import load_gateway_plugins
|
||||
|
||||
|
||||
def _load_image_core_generate_image():
|
||||
file_path = (Path(__file__).resolve().parents[1] / "extensions" / "image-generation-core" / "api.py").resolve()
|
||||
spec = importlib.util.spec_from_file_location("test_image_generation_core_api", str(file_path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("failed to load image-generation-core api module")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return getattr(module, "generate_image")
|
||||
|
||||
|
||||
def test_openai_registers_image_generation_provider_in_gateway_registry() -> None:
|
||||
out = load_gateway_plugins(
|
||||
cfg={"plugins": {"enabled": ["openai"]}},
|
||||
workspace_dir=".",
|
||||
log={},
|
||||
core_gateway_handlers={},
|
||||
base_methods=["ping"],
|
||||
)
|
||||
providers = out.plugin_registry.get("image_generation_providers") or []
|
||||
assert isinstance(providers, list)
|
||||
assert any(isinstance(p, dict) and p.get("id") == "openai" for p in providers)
|
||||
|
||||
|
||||
def test_image_generation_core_uses_registered_openai_provider() -> None:
|
||||
out = load_gateway_plugins(
|
||||
cfg={"plugins": {"enabled": ["openai"]}},
|
||||
workspace_dir=".",
|
||||
log={},
|
||||
core_gateway_handlers={},
|
||||
base_methods=["ping"],
|
||||
)
|
||||
runtime = {"image_generation_providers": out.plugin_registry.get("image_generation_providers") or []}
|
||||
generate_image = _load_image_core_generate_image()
|
||||
result = generate_image(prompt="sunset over city", provider_id="openai", runtime=runtime, size="1024x1024")
|
||||
assert result.get("ok") is True
|
||||
assert result.get("provider") == "openai"
|
||||
assert result.get("model") == "gpt-image-1"
|
||||
assert result.get("prompt") == "sunset over city"
|
||||
assert result.get("size") == "1024x1024"
|
||||
|
||||
741
tests/test_gateway_server_methods.py
Normal file
741
tests/test_gateway_server_methods.py
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from oclaw.gateway.server_methods.agent import agent_handlers
|
||||
from oclaw.gateway.server_methods.agents import agents_handlers
|
||||
from oclaw.gateway.server_methods.chat import chat_handlers
|
||||
from oclaw.gateway.server_methods.send import send_handlers
|
||||
from oclaw.gateway.server_methods.image import image_handlers
|
||||
from oclaw.gateway.server_methods.skills import skills_handlers
|
||||
from oclaw.gateway.server_methods.system import system_handlers
|
||||
from oclaw.gateway.server_methods.cron import cron_handlers
|
||||
from oclaw.gateway.server_methods.devices import device_handlers
|
||||
from oclaw.gateway.server_methods.models import models_handlers
|
||||
from oclaw.gateway.server_methods.models_auth_status import (
|
||||
invalidate_model_auth_status_cache,
|
||||
models_auth_status_handlers,
|
||||
)
|
||||
from oclaw.gateway.server_methods.push import push_handlers
|
||||
from oclaw.gateway.server_methods.channels import channels_handlers
|
||||
from oclaw.gateway.server_methods.update import update_handlers
|
||||
from oclaw.gateway.server_methods.voicewake import voicewake_handlers
|
||||
from oclaw.gateway.server_methods.wizard import wizard_handlers
|
||||
from oclaw.gateway.server_methods.tts import tts_handlers
|
||||
from oclaw.gateway.server_methods.web import web_handlers
|
||||
from oclaw.gateway.server_methods.tools_catalog import tools_catalog_handlers
|
||||
from oclaw.gateway.server_methods.tools_effective import tools_effective_handlers
|
||||
from oclaw.gateway.server_methods.talk import talk_handlers
|
||||
from oclaw.gateway.server_methods.usage import usage_handlers
|
||||
from oclaw.gateway.server_methods.exec_approvals import exec_approvals_handlers
|
||||
from oclaw.gateway.server_methods.nodes_pending import node_pending_handlers
|
||||
from oclaw.gateway.server_methods.nodes import node_handlers
|
||||
from oclaw.gateway.server_methods.sessions import sessions_handlers
|
||||
from oclaw.gateway.server_methods.doctor import doctor_handlers
|
||||
|
||||
|
||||
def _call(handler, *, params: dict[str, Any], context: dict[str, Any] | None = None, client: dict[str, Any] | None = None):
|
||||
out: dict[str, Any] = {}
|
||||
|
||||
def respond(ok, payload, error, meta): # noqa: ANN001
|
||||
out["ok"] = ok
|
||||
out["payload"] = payload
|
||||
out["error"] = error
|
||||
out["meta"] = meta
|
||||
|
||||
handler(
|
||||
{
|
||||
"params": params,
|
||||
"context": context if context is not None else {},
|
||||
"client": client if client is not None else {},
|
||||
"respond": respond,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def test_sessions_subscribe_and_unsubscribe_updates_subscriber_set() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
client = {"conn_id": "c-1"}
|
||||
res1 = _call(sessions_handlers["sessions.subscribe"], params={}, context=context, client=client)
|
||||
assert res1["ok"] is True
|
||||
assert "c-1" in context["session_event_subscribers"]
|
||||
|
||||
res2 = _call(sessions_handlers["sessions.unsubscribe"], params={}, context=context, client=client)
|
||||
assert res2["ok"] is True
|
||||
assert "c-1" not in context["session_event_subscribers"]
|
||||
|
||||
|
||||
def test_agents_list_and_files_set_get_shapes() -> None:
|
||||
listed = _call(agents_handlers["agents.list"], params={})
|
||||
assert listed["ok"] is True
|
||||
assert isinstance((listed["payload"] or {}).get("agents"), list)
|
||||
|
||||
set_out = _call(
|
||||
agents_handlers["agents.files.set"],
|
||||
params={"agentId": "main", "name": "SOUL.md", "content": "hello"},
|
||||
)
|
||||
assert set_out["ok"] is True
|
||||
file_payload = (set_out["payload"] or {}).get("file") or {}
|
||||
assert file_payload.get("name") == "SOUL.md"
|
||||
|
||||
get_out = _call(agents_handlers["agents.files.get"], params={"agentId": "main", "name": "SOUL.md"})
|
||||
assert get_out["ok"] is True
|
||||
|
||||
|
||||
def test_sessions_messages_subscribe_uses_key_alias() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
client = {"conn_id": "c-2"}
|
||||
res = _call(
|
||||
sessions_handlers["sessions.messages.subscribe"],
|
||||
params={"key": "sess-1"},
|
||||
context=context,
|
||||
client=client,
|
||||
)
|
||||
assert res["ok"] is True
|
||||
subscribers = context["session_message_subscribers"]["sess-1"]
|
||||
assert "c-2" in subscribers
|
||||
|
||||
|
||||
def test_chat_send_requires_session_key_or_key() -> None:
|
||||
bad = _call(chat_handlers["chat.send"], params={"message": "hi"})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
good = _call(chat_handlers["chat.send"], params={"key": "sess-x", "message": "hi", "idempotencyKey": "idem-x"})
|
||||
assert good["ok"] is True
|
||||
assert good["payload"]["status"] == "started"
|
||||
assert good["payload"]["runId"] == "idem-x"
|
||||
|
||||
|
||||
def test_chat_send_telegram_transport_fields_are_normalized() -> None:
|
||||
out = _call(
|
||||
chat_handlers["chat.send"],
|
||||
params={
|
||||
"key": "sess-telegram",
|
||||
"message": "hi",
|
||||
"idempotencyKey": "idem-chat-tg",
|
||||
"channel": "telegram",
|
||||
"to": "telegram:group:-100123456789:topic:9",
|
||||
"replyToId": "11",
|
||||
},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
payload = out["payload"] or {}
|
||||
assert payload.get("channel") == "telegram"
|
||||
assert payload.get("to") == "telegram:group:-100123456789:topic:9"
|
||||
assert payload.get("threadId") == 9
|
||||
assert payload.get("replyToMessageId") == 11
|
||||
target = payload.get("target") or {}
|
||||
assert target.get("chatId") == "-100123456789"
|
||||
assert target.get("chatType") == "group"
|
||||
|
||||
|
||||
def test_chat_send_forwards_normalized_telegram_fields_to_enqueue() -> None:
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
def _enqueue(session_key: str, message: str, run_id: str, forwarded_params: dict[str, Any]) -> bool:
|
||||
captured["session_key"] = session_key
|
||||
captured["message"] = message
|
||||
captured["run_id"] = run_id
|
||||
captured["params"] = dict(forwarded_params)
|
||||
return True
|
||||
|
||||
out = _call(
|
||||
chat_handlers["chat.send"],
|
||||
params={
|
||||
"key": "sess-enqueue",
|
||||
"message": "hello",
|
||||
"idempotencyKey": "idem-enqueue-tg",
|
||||
"channel": "telegram",
|
||||
"to": "telegram:group:-100999:topic:5",
|
||||
"replyToId": "14",
|
||||
},
|
||||
context={"enqueue_chat_send": _enqueue},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
forwarded = captured.get("params") or {}
|
||||
assert forwarded.get("to") == "telegram:group:-100999:topic:5"
|
||||
assert forwarded.get("threadId") == 5
|
||||
assert forwarded.get("replyToMessageId") == 14
|
||||
target = forwarded.get("target") or {}
|
||||
assert target.get("chatId") == "-100999"
|
||||
assert target.get("chatType") == "group"
|
||||
|
||||
|
||||
def test_agent_dedupe_then_wait_reads_cached_status() -> None:
|
||||
context: dict[str, Any] = {"dedupe": {}}
|
||||
res1 = _call(
|
||||
agent_handlers["agent"],
|
||||
params={"message": "hello", "idempotencyKey": "run-1"},
|
||||
context=context,
|
||||
)
|
||||
assert res1["ok"] is True
|
||||
|
||||
res2 = _call(
|
||||
agent_handlers["agent"],
|
||||
params={"message": "hello", "idempotencyKey": "run-1"},
|
||||
context=context,
|
||||
)
|
||||
assert res2["ok"] is True
|
||||
assert (res2["meta"] or {}).get("cached") is True
|
||||
|
||||
wait = _call(agent_handlers["agent.wait"], params={"runId": "run-1"}, context=context)
|
||||
assert wait["ok"] is True
|
||||
assert wait["payload"]["runId"] == "run-1"
|
||||
|
||||
|
||||
def test_send_dedupe_returns_cached_meta() -> None:
|
||||
context: dict[str, Any] = {"dedupe": {}}
|
||||
first = _call(
|
||||
send_handlers["send"],
|
||||
params={"to": "u1", "message": "hi", "idempotencyKey": "idem-s1", "channel": "telegram"},
|
||||
context=context,
|
||||
)
|
||||
assert first["ok"] is True
|
||||
assert (first["payload"] or {}).get("runId") == "idem-s1"
|
||||
|
||||
second = _call(
|
||||
send_handlers["send"],
|
||||
params={"to": "u1", "message": "hi", "idempotencyKey": "idem-s1", "channel": "telegram"},
|
||||
context=context,
|
||||
)
|
||||
assert second["ok"] is True
|
||||
assert (second["meta"] or {}).get("cached") is True
|
||||
|
||||
|
||||
def test_send_telegram_normalizes_target_and_thread_params() -> None:
|
||||
context: dict[str, Any] = {"dedupe": {}}
|
||||
out = _call(
|
||||
send_handlers["send"],
|
||||
params={
|
||||
"to": "telegram:group:-100987654321:topic:42",
|
||||
"message": "hello",
|
||||
"idempotencyKey": "idem-tg-1",
|
||||
"channel": "telegram",
|
||||
"replyToId": "77",
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
assert out["ok"] is True
|
||||
payload = out["payload"] or {}
|
||||
assert payload.get("to") == "telegram:group:-100987654321:topic:42"
|
||||
assert payload.get("threadId") == 42
|
||||
assert payload.get("replyToMessageId") == 77
|
||||
target = payload.get("target") or {}
|
||||
assert target.get("chatId") == "-100987654321"
|
||||
assert target.get("chatType") == "group"
|
||||
assert target.get("messageThreadId") == 42
|
||||
|
||||
|
||||
def test_sessions_send_telegram_target_is_normalized() -> None:
|
||||
out = _call(
|
||||
sessions_handlers["sessions.send"],
|
||||
params={
|
||||
"sessionKey": "sess-1",
|
||||
"message": "hello",
|
||||
"channel": "telegram",
|
||||
"to": "telegram:group:-100987654321:topic:7",
|
||||
"replyToId": "3",
|
||||
},
|
||||
)
|
||||
assert out["ok"] is True
|
||||
payload = out["payload"] or {}
|
||||
assert payload.get("channel") == "telegram"
|
||||
assert payload.get("to") == "telegram:group:-100987654321:topic:7"
|
||||
assert payload.get("threadId") == 7
|
||||
assert payload.get("replyToMessageId") == 3
|
||||
target = payload.get("target") or {}
|
||||
assert target.get("chatId") == "-100987654321"
|
||||
assert target.get("chatType") == "group"
|
||||
|
||||
|
||||
class _MemStore:
|
||||
def __init__(self) -> None:
|
||||
self._settings: dict[str, str] = {}
|
||||
|
||||
def get_setting(self, key: str) -> str | None: # noqa: ANN001
|
||||
return self._settings.get(str(key))
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None: # noqa: ANN001
|
||||
self._settings[str(key)] = str(value)
|
||||
|
||||
|
||||
def test_skills_status_requires_store_then_lists_skills() -> None:
|
||||
no_store = _call(skills_handlers["skills.status"], params={}, context={})
|
||||
assert no_store["ok"] is False
|
||||
assert no_store["error"]["code"] == "UNAVAILABLE"
|
||||
|
||||
store = _MemStore()
|
||||
ok = _call(skills_handlers["skills.status"], params={}, context={"store": store})
|
||||
assert ok["ok"] is True
|
||||
payload = ok["payload"] or {}
|
||||
assert isinstance(payload.get("skills"), list)
|
||||
# Repo ships oclaw/skills/{main,coding,social}
|
||||
names = {str(x.get("name")) for x in payload["skills"] if isinstance(x, dict)}
|
||||
assert "main" in names
|
||||
|
||||
|
||||
def test_system_set_heartbeats_requires_boolean() -> None:
|
||||
bad = _call(system_handlers["set-heartbeats"], params={"enabled": "yes"})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
ok = _call(system_handlers["set-heartbeats"], params={"enabled": True})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("enabled") is True
|
||||
|
||||
|
||||
def test_system_event_requires_text() -> None:
|
||||
bad = _call(system_handlers["system-event"], params={})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_cron_update_requires_id() -> None:
|
||||
bad = _call(cron_handlers["cron.update"], params={"patch": {"name": "x"}})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_cron_add_and_run_fallback() -> None:
|
||||
add = _call(cron_handlers["cron.add"], params={"name": "job-a", "schedule": "*/5 * * * *"})
|
||||
assert add["ok"] is True
|
||||
assert (add["payload"] or {}).get("id") is not None
|
||||
|
||||
run = _call(cron_handlers["cron.run"], params={"id": "job-a", "mode": "force"})
|
||||
assert run["ok"] is True
|
||||
assert (run["payload"] or {}).get("ran") is True
|
||||
|
||||
|
||||
def test_image_generate_falls_back_to_registered_openai_provider() -> None:
|
||||
# Get providers via plugin load, then inject into gateway context.
|
||||
from oclaw.gateway.server_plugins import load_gateway_plugins
|
||||
|
||||
plugin_out = load_gateway_plugins(
|
||||
cfg={"plugins": {"enabled": ["openai"]}},
|
||||
workspace_dir=".",
|
||||
log={},
|
||||
core_gateway_handlers={},
|
||||
base_methods=["ping"],
|
||||
)
|
||||
providers = plugin_out.plugin_registry.get("image_generation_providers") or []
|
||||
res = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "a cat", "provider": "openai", "size": "1024x1024"},
|
||||
context={"image_generation_providers": providers},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
payload = res["payload"] or {}
|
||||
assert payload.get("ok") is True
|
||||
assert payload.get("provider") == "openai"
|
||||
assert payload.get("model") == "gpt-image-1"
|
||||
assert payload.get("prompt") == "a cat"
|
||||
|
||||
|
||||
def test_image_generate_rejects_unknown_provider_with_not_found() -> None:
|
||||
res = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "a cat", "provider": "missing-provider"},
|
||||
context={"image_generation_providers": []},
|
||||
)
|
||||
assert res["ok"] is False
|
||||
assert (res["error"] or {}).get("code") == "NOT_FOUND"
|
||||
|
||||
|
||||
def test_image_generate_rejects_invalid_optional_params() -> None:
|
||||
bad_provider = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "x", "provider": " "},
|
||||
)
|
||||
assert bad_provider["ok"] is False
|
||||
assert (bad_provider["error"] or {}).get("code") == "INVALID_REQUEST"
|
||||
|
||||
bad_size = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "x", "size": 1024},
|
||||
)
|
||||
assert bad_size["ok"] is False
|
||||
assert (bad_size["error"] or {}).get("code") == "INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_image_generate_prefers_configured_default_provider() -> None:
|
||||
providers = [
|
||||
{"id": "openai", "generate": lambda **kw: {"model": "gpt-image-1", "prompt": kw.get("prompt")}},
|
||||
{"id": "alt", "generate": lambda **kw: {"model": "alt-image", "prompt": kw.get("prompt")}},
|
||||
]
|
||||
res = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "city skyline"},
|
||||
context={
|
||||
"image_generation_providers": providers,
|
||||
"config": {"image": {"defaultProvider": "alt"}},
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
payload = res["payload"] or {}
|
||||
assert payload.get("provider") == "alt"
|
||||
assert payload.get("model") == "alt-image"
|
||||
|
||||
|
||||
def test_image_generate_uses_priority_list_when_no_explicit_or_default() -> None:
|
||||
providers = [
|
||||
{"id": "openai", "generate": lambda **kw: {"model": "gpt-image-1", "prompt": kw.get("prompt")}},
|
||||
{"id": "foo", "generate": lambda **kw: {"model": "foo-image", "prompt": kw.get("prompt")}},
|
||||
]
|
||||
res = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "forest"},
|
||||
context={
|
||||
"image_generation_providers": providers,
|
||||
"config": {"image": {"providerPriority": ["bar", "foo", "openai"]}},
|
||||
},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
payload = res["payload"] or {}
|
||||
assert payload.get("provider") == "foo"
|
||||
assert payload.get("model") == "foo-image"
|
||||
|
||||
|
||||
def test_image_generate_filters_non_image_capability_providers() -> None:
|
||||
providers = [
|
||||
{
|
||||
"id": "text-only",
|
||||
"capabilities": {"image_generation": False},
|
||||
"generate": lambda **kw: {"model": "should-not-use", "prompt": kw.get("prompt")},
|
||||
},
|
||||
{
|
||||
"id": "image-ok",
|
||||
"capabilities": {"image_generation": True},
|
||||
"generate": lambda **kw: {"model": "ok-image", "prompt": kw.get("prompt")},
|
||||
},
|
||||
]
|
||||
res = _call(
|
||||
image_handlers["image.generate"],
|
||||
params={"prompt": "mountain"},
|
||||
context={"image_generation_providers": providers},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
payload = res["payload"] or {}
|
||||
assert payload.get("provider") == "image-ok"
|
||||
assert payload.get("model") == "ok-image"
|
||||
|
||||
|
||||
def test_channels_status_exposes_image_generation_providers_from_context() -> None:
|
||||
providers = [{"id": "openai", "label": "OpenAI Images"}, {"id": "x"}]
|
||||
res = _call(
|
||||
channels_handlers["channels.status"],
|
||||
params={"probe": False},
|
||||
context={"image_generation_providers": providers},
|
||||
)
|
||||
assert res["ok"] is True
|
||||
runtime = (res["payload"] or {}).get("runtime") or {}
|
||||
assert isinstance(runtime.get("image_generation_providers"), list)
|
||||
assert runtime["image_generation_providers"][0]["id"] == "openai"
|
||||
|
||||
|
||||
def test_doctor_memory_status_shape_and_hook() -> None:
|
||||
out = _call(doctor_handlers["doctor.memory.status"], params={})
|
||||
assert out["ok"] is True
|
||||
payload = out["payload"] or {}
|
||||
assert payload.get("agentId") == "main"
|
||||
assert isinstance(payload.get("dreaming"), dict)
|
||||
|
||||
ctx = {"doctor_memory_status": lambda: {"agentId": "custom", "embedding": {"ok": False}}}
|
||||
hooked = _call(doctor_handlers["doctor.memory.status"], params={}, context=ctx)
|
||||
assert hooked["ok"] is True
|
||||
assert (hooked["payload"] or {}).get("agentId") == "custom"
|
||||
|
||||
|
||||
def test_device_pair_remove_denied_for_cross_device_non_admin() -> None:
|
||||
res = _call(
|
||||
device_handlers["device.pair.remove"],
|
||||
params={"deviceId": "dev-b"},
|
||||
client={"connect": {"device": {"id": "dev-a"}, "scopes": ["operator.write"]}},
|
||||
)
|
||||
assert res["ok"] is False
|
||||
assert res["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
|
||||
def test_device_token_rotate_requires_fields() -> None:
|
||||
bad = _call(device_handlers["device.token.rotate"], params={"deviceId": "dev-a"})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
ok = _call(device_handlers["device.token.rotate"], params={"deviceId": "dev-a", "role": "worker"})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("deviceId") == "dev-a"
|
||||
|
||||
|
||||
def test_models_list_fallback_and_filtered() -> None:
|
||||
fallback = _call(models_handlers["models.list"], params={}, context={})
|
||||
assert fallback["ok"] is True
|
||||
assert (fallback["payload"] or {}).get("models") == []
|
||||
|
||||
context = {
|
||||
"loadGatewayModelCatalog": lambda: [{"provider": "openai", "model": "gpt-4.1"}, {"provider": "x", "model": "y"}],
|
||||
"filterAllowedModels": lambda rows: [r for r in rows if r.get("provider") == "openai"],
|
||||
}
|
||||
filtered = _call(models_handlers["models.list"], params={}, context=context)
|
||||
assert filtered["ok"] is True
|
||||
models = (filtered["payload"] or {}).get("models") or []
|
||||
assert len(models) == 1
|
||||
assert models[0]["provider"] == "openai"
|
||||
|
||||
|
||||
def test_models_auth_status_cache_and_refresh() -> None:
|
||||
invalidate_model_auth_status_cache()
|
||||
calls = {"n": 0}
|
||||
|
||||
def _loader(): # noqa: ANN001
|
||||
calls["n"] += 1
|
||||
return [
|
||||
{
|
||||
"provider": "openai",
|
||||
"displayName": "OpenAI",
|
||||
"status": "ok",
|
||||
"profiles": [{"profileId": "p1", "type": "oauth", "status": "ok"}],
|
||||
}
|
||||
]
|
||||
|
||||
ctx = {"load_models_auth_status": _loader}
|
||||
first = _call(models_auth_status_handlers["models.authStatus"], params={}, context=ctx)
|
||||
second = _call(models_auth_status_handlers["models.authStatus"], params={}, context=ctx)
|
||||
refreshed = _call(models_auth_status_handlers["models.authStatus"], params={"refresh": True}, context=ctx)
|
||||
|
||||
assert first["ok"] is True
|
||||
assert second["ok"] is True
|
||||
assert refreshed["ok"] is True
|
||||
assert calls["n"] == 2
|
||||
assert (second["meta"] or {}).get("cached") is True
|
||||
|
||||
|
||||
def test_push_test_requires_node_id() -> None:
|
||||
bad = _call(push_handlers["push.test"], params={})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
ok = _call(push_handlers["push.test"], params={"nodeId": "node-1"})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("nodeId") == "node-1"
|
||||
|
||||
|
||||
def test_update_run_returns_result_shape() -> None:
|
||||
out = _call(update_handlers["update.run"], params={"timeoutMs": 5000, "sessionKey": "main"})
|
||||
assert out["ok"] is True
|
||||
payload = out["payload"] or {}
|
||||
assert "result" in payload
|
||||
assert "sentinel" in payload
|
||||
|
||||
|
||||
def test_voicewake_set_requires_triggers_array() -> None:
|
||||
bad = _call(voicewake_handlers["voicewake.set"], params={"triggers": "hey"})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
ok = _call(voicewake_handlers["voicewake.set"], params={"triggers": ["Hey OpenClaw", " ", "hey openclaw"]})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("triggers") == ["Hey OpenClaw"]
|
||||
|
||||
|
||||
def test_wizard_start_next_status_cancel_flow() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
started = _call(wizard_handlers["wizard.start"], params={"mode": "quick"}, context=context)
|
||||
assert started["ok"] is True
|
||||
session_id = str((started["payload"] or {}).get("sessionId") or "")
|
||||
assert session_id != ""
|
||||
status = _call(wizard_handlers["wizard.status"], params={"sessionId": session_id}, context=context)
|
||||
assert status["ok"] is True
|
||||
assert (status["payload"] or {}).get("status") == "running"
|
||||
nxt = _call(
|
||||
wizard_handlers["wizard.next"],
|
||||
params={"sessionId": session_id, "answer": {"stepId": "step-1", "value": "x"}},
|
||||
context=context,
|
||||
)
|
||||
assert nxt["ok"] is True
|
||||
cancelled = _call(wizard_handlers["wizard.cancel"], params={"sessionId": session_id}, context=context)
|
||||
assert cancelled["ok"] is True
|
||||
assert (cancelled["payload"] or {}).get("status") == "cancelled"
|
||||
|
||||
|
||||
def test_tts_convert_requires_text() -> None:
|
||||
bad = _call(tts_handlers["tts.convert"], params={})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
ok = _call(tts_handlers["tts.convert"], params={"text": "hello"})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("audioPath")
|
||||
|
||||
|
||||
def test_web_login_provider_required() -> None:
|
||||
bad = _call(web_handlers["web.login.start"], params={})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
context = {
|
||||
"resolve_web_login_provider": lambda: {
|
||||
"id": "wechat",
|
||||
"loginWithQrStart": lambda _p: {"connected": False, "qrDataUrl": "data:image/png;base64,abc"},
|
||||
"loginWithQrWait": lambda _p: {"connected": True},
|
||||
}
|
||||
}
|
||||
started = _call(web_handlers["web.login.start"], params={}, context=context)
|
||||
assert started["ok"] is True
|
||||
waited = _call(web_handlers["web.login.wait"], params={}, context=context)
|
||||
assert waited["ok"] is True
|
||||
|
||||
|
||||
def test_tools_catalog_and_effective_basic_shapes() -> None:
|
||||
cat = _call(tools_catalog_handlers["tools.catalog"], params={"includePlugins": False})
|
||||
assert cat["ok"] is True
|
||||
assert (cat["payload"] or {}).get("agentId")
|
||||
assert isinstance((cat["payload"] or {}).get("groups"), list)
|
||||
|
||||
eff_bad = _call(tools_effective_handlers["tools.effective"], params={})
|
||||
assert eff_bad["ok"] is False
|
||||
assert eff_bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
eff = _call(tools_effective_handlers["tools.effective"], params={"sessionKey": "main"})
|
||||
assert eff["ok"] is True
|
||||
assert (eff["payload"] or {}).get("sessionKey") == "main"
|
||||
|
||||
|
||||
def test_talk_speak_requires_text_and_mode_shape() -> None:
|
||||
bad = _call(talk_handlers["talk.speak"], params={})
|
||||
assert bad["ok"] is False
|
||||
assert bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
ok = _call(talk_handlers["talk.speak"], params={"text": "hi"})
|
||||
assert ok["ok"] is True
|
||||
assert (ok["payload"] or {}).get("audioBase64")
|
||||
|
||||
mode = _call(talk_handlers["talk.mode"], params={"enabled": True})
|
||||
assert mode["ok"] is True
|
||||
assert (mode["payload"] or {}).get("enabled") is True
|
||||
|
||||
|
||||
def test_usage_handlers_basic_shapes_and_cache() -> None:
|
||||
status = _call(usage_handlers["usage.status"], params={})
|
||||
assert status["ok"] is True
|
||||
assert "providers" in (status["payload"] or {})
|
||||
|
||||
calls = {"n": 0}
|
||||
|
||||
def _cost_loader(req: dict[str, Any]) -> dict[str, Any]:
|
||||
calls["n"] += 1
|
||||
return {"startMs": req["startMs"], "endMs": req["endMs"], "totals": {"totalCost": 1}}
|
||||
|
||||
ctx = {"load_cost_usage_summary": _cost_loader}
|
||||
c1 = _call(usage_handlers["usage.cost"], params={"days": 1}, context=ctx)
|
||||
c2 = _call(usage_handlers["usage.cost"], params={"days": 1}, context=ctx)
|
||||
assert c1["ok"] is True
|
||||
assert c2["ok"] is True
|
||||
assert calls["n"] == 1
|
||||
assert (c2["meta"] or {}).get("cached") is True
|
||||
|
||||
sess_bad = _call(usage_handlers["sessions.usage"], params={"key": 123})
|
||||
assert sess_bad["ok"] is False
|
||||
assert sess_bad["error"]["code"] == "INVALID_REQUEST"
|
||||
sess_ok = _call(usage_handlers["sessions.usage"], params={"key": "sess-1", "limit": 5})
|
||||
assert sess_ok["ok"] is True
|
||||
assert isinstance((sess_ok["payload"] or {}).get("sessions"), list)
|
||||
|
||||
|
||||
def test_exec_approvals_base_hash_and_node_shapes() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
got = _call(exec_approvals_handlers["exec.approvals.get"], params={}, context=context)
|
||||
assert got["ok"] is True
|
||||
payload = got["payload"] or {}
|
||||
assert "path" in payload and "file" in payload
|
||||
|
||||
bad_set = _call(exec_approvals_handlers["exec.approvals.set"], params={}, context=context)
|
||||
assert bad_set["ok"] is False
|
||||
assert bad_set["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
set1 = _call(
|
||||
exec_approvals_handlers["exec.approvals.set"],
|
||||
params={"file": {"allow": ["echo"]}},
|
||||
context=context,
|
||||
)
|
||||
assert set1["ok"] is True
|
||||
h = str(((set1["payload"] or {}).get("hash") or "")).strip()
|
||||
assert h != ""
|
||||
|
||||
stale = _call(
|
||||
exec_approvals_handlers["exec.approvals.set"],
|
||||
params={"file": {"allow": ["ls"]}, "baseHash": "wrong-hash"},
|
||||
context=context,
|
||||
)
|
||||
assert stale["ok"] is False
|
||||
assert stale["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
node_bad = _call(exec_approvals_handlers["exec.approvals.node.get"], params={"nodeId": ""}, context=context)
|
||||
assert node_bad["ok"] is False
|
||||
assert node_bad["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
node_ok = _call(exec_approvals_handlers["exec.approvals.node.get"], params={"nodeId": "n1"}, context=context)
|
||||
assert node_ok["ok"] is True
|
||||
|
||||
|
||||
def test_node_pending_enqueue_then_drain() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
enqueue = _call(
|
||||
node_pending_handlers["node.pending.enqueue"],
|
||||
params={"nodeId": "n1", "type": "sync", "wake": True},
|
||||
context=context,
|
||||
)
|
||||
assert enqueue["ok"] is True
|
||||
assert (enqueue["payload"] or {}).get("wakeTriggered") is True
|
||||
|
||||
# drain requires connected client identity
|
||||
no_client = _call(node_pending_handlers["node.pending.drain"], params={}, context=context)
|
||||
assert no_client["ok"] is False
|
||||
assert no_client["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
drained = _call(
|
||||
node_pending_handlers["node.pending.drain"],
|
||||
params={"maxItems": 10},
|
||||
context=context,
|
||||
client={"connect": {"device": {"id": "n1"}}},
|
||||
)
|
||||
assert drained["ok"] is True
|
||||
payload = drained["payload"] or {}
|
||||
assert payload.get("nodeId") == "n1"
|
||||
assert int(payload.get("count") or 0) >= 1
|
||||
|
||||
|
||||
def test_node_pair_and_invoke_basics() -> None:
|
||||
context: dict[str, Any] = {}
|
||||
req = _call(
|
||||
node_handlers["node.pair.request"],
|
||||
params={"nodeId": "node-1", "displayName": "iPhone"},
|
||||
context=context,
|
||||
)
|
||||
assert req["ok"] is True
|
||||
request_id = str(((req["payload"] or {}).get("request") or {}).get("requestId") or "")
|
||||
assert request_id != ""
|
||||
|
||||
approved = _call(node_handlers["node.pair.approve"], params={"requestId": request_id}, context=context)
|
||||
assert approved["ok"] is True
|
||||
|
||||
listed = _call(node_handlers["node.list"], params={}, context=context)
|
||||
assert listed["ok"] is True
|
||||
assert isinstance((listed["payload"] or {}).get("nodes"), list)
|
||||
|
||||
bad_invoke = _call(
|
||||
node_handlers["node.invoke"],
|
||||
params={"nodeId": "node-1", "command": "system.execApprovals.get"},
|
||||
context=context,
|
||||
)
|
||||
assert bad_invoke["ok"] is False
|
||||
assert bad_invoke["error"]["code"] == "INVALID_REQUEST"
|
||||
|
||||
ok_invoke = _call(
|
||||
node_handlers["node.invoke"],
|
||||
params={"nodeId": "node-1", "command": "screen.capture"},
|
||||
context=context,
|
||||
)
|
||||
assert ok_invoke["ok"] is True
|
||||
|
||||
|
||||
|
||||
|
||||
55
tests/test_gateway_telegram_send_normalize.py
Normal file
55
tests/test_gateway_telegram_send_normalize.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.gateway.server_methods.telegram_send_normalize import normalize_transport_target_for_channel
|
||||
|
||||
|
||||
def test_normalize_transport_target_for_channel_non_telegram_passthrough() -> None:
|
||||
to, extra = normalize_transport_target_for_channel(
|
||||
channel="discord",
|
||||
to="room-1",
|
||||
params={},
|
||||
)
|
||||
assert to == "room-1"
|
||||
assert extra == {}
|
||||
|
||||
|
||||
def test_normalize_transport_target_for_channel_handles_topic_target() -> None:
|
||||
to, extra = normalize_transport_target_for_channel(
|
||||
channel="telegram",
|
||||
to="telegram:group:-100123:topic:88",
|
||||
params={"replyToId": "9"},
|
||||
)
|
||||
assert to == "telegram:group:-100123:topic:88"
|
||||
target = extra.get("target") or {}
|
||||
assert target.get("chatId") == "-100123"
|
||||
assert target.get("chatType") == "group"
|
||||
assert target.get("messageThreadId") == 88
|
||||
assert extra.get("threadId") == 88
|
||||
assert extra.get("replyToMessageId") == 9
|
||||
|
||||
|
||||
def test_normalize_transport_target_for_channel_supports_username_lookup_targets() -> None:
|
||||
to, extra = normalize_transport_target_for_channel(
|
||||
channel="telegram",
|
||||
to="@MyUser_01",
|
||||
params={},
|
||||
)
|
||||
assert to == "telegram:@myuser_01"
|
||||
target = extra.get("target") or {}
|
||||
assert target.get("chatId") == "@myuser_01"
|
||||
assert target.get("chatType") == "unknown"
|
||||
assert "threadId" not in extra
|
||||
|
||||
|
||||
def test_normalize_transport_target_for_channel_uses_explicit_thread_id_override() -> None:
|
||||
to, extra = normalize_transport_target_for_channel(
|
||||
channel="telegram",
|
||||
to="telegram:-100500:topic:2",
|
||||
params={"threadId": "99"},
|
||||
)
|
||||
assert to == "telegram:-100500:topic:2"
|
||||
assert extra.get("threadId") == 99
|
||||
target = extra.get("target") or {}
|
||||
# parsed target remains the source target
|
||||
assert target.get("messageThreadId") == 2
|
||||
|
||||
30
tests/test_gemini_sse_transport.py
Normal file
30
tests/test_gemini_sse_transport.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from oclaw.platform.llm.transports.google_gemini_sse import GoogleGeminiChatModel
|
||||
|
||||
|
||||
def test_gemini_tool_decl_uses_parameters_json_schema() -> None:
|
||||
m = GoogleGeminiChatModel(model="gemini-3.1-pro", api_key="x", base_url="http://gw")
|
||||
decls = m._tool_decls_from_openai_tools(
|
||||
[
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "query_route",
|
||||
"description": "d",
|
||||
"parameters": {"type": "object", "properties": {"destination": {"type": "string"}}},
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
assert decls and "parametersJsonSchema" in decls[0]
|
||||
assert "parameters" not in decls[0]
|
||||
|
||||
|
||||
def test_gemini_thinking_config_env_off() -> None:
|
||||
os.environ["AIA_GEMINI_THINKING"] = "off"
|
||||
cfg = GoogleGeminiChatModel._thinking_config_from_env()
|
||||
assert cfg == {"includeThoughts": False}
|
||||
|
||||
52
tests/test_get_messages_preserve_tool_pairing.py
Normal file
52
tests/test_get_messages_preserve_tool_pairing.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.chat.agent_messages import build_llm_messages
|
||||
from oclaw.platform.llm.chat_models import RuleBasedChatModel
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_get_messages_limit_preserves_tool_pairing(tmp_path: Path) -> None:
|
||||
db = tmp_path / "t.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
|
||||
store.add_message(session_id=sess.id, role="user", content="hi", turn_uuid="turn-1", event_type="user_text")
|
||||
assistant = store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
turn_uuid="turn-1",
|
||||
event_type="tool_call",
|
||||
tool_calls=[
|
||||
{
|
||||
"id": "call_abc",
|
||||
"name": "mcp__x__list_directory",
|
||||
"arguments": {"path": "C:\\dummy"},
|
||||
}
|
||||
],
|
||||
)
|
||||
# Tool result must keep tool_call_id + assistant_message_id for pairing.
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content=json.dumps({"ok": False, "error": "Access denied", "result": {"isError": True}}, ensure_ascii=False),
|
||||
turn_uuid="turn-1",
|
||||
event_type="tool_result",
|
||||
tool_calls={"tool_call_id": "call_abc", "name": "mcp__x__list_directory", "assistant_message_id": int(assistant.id)},
|
||||
)
|
||||
|
||||
# Boundary case: only ask for the last 1 message. Store must prepend the assistant row to keep pairing.
|
||||
rows = store.get_messages(session_id=sess.id, limit=1)
|
||||
assert len(rows) >= 2
|
||||
assert rows[-1].role == "tool"
|
||||
assert any(r.role == "assistant" and r.tool_calls for r in rows)
|
||||
assert all(str(getattr(r, "turn_uuid", "") or "") == "turn-1" for r in rows)
|
||||
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=RuleBasedChatModel(), lang="zh")
|
||||
tool_msgs = [m for m in msgs if m.get("role") == "tool"]
|
||||
assert tool_msgs, msgs
|
||||
assert tool_msgs[-1].get("tool_call_id") == "call_abc"
|
||||
|
||||
176
tests/test_mcp_adapter.py
Normal file
176
tests/test_mcp_adapter.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.mcp.adapter import materialize_mcp_tools, materialize_mcp_tools_for_specialist
|
||||
|
||||
|
||||
class McpAdapterTests(unittest.TestCase):
|
||||
def test_materialize_mcp_tools(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/echo",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "echo_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo",
|
||||
tools=[{"tool_name": "ping", "description": "Ping", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
specs = materialize_mcp_tools(store)
|
||||
self.assertEqual(len(specs), 1)
|
||||
self.assertEqual(specs[0].name, "mcp__echo__ping")
|
||||
|
||||
def test_specialist_filter(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/echo",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "echo_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo",
|
||||
tools=[{"tool_name": "ping", "description": "Ping", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.set_setting("mcp_allowed_specialists", "generalist")
|
||||
g_specs = materialize_mcp_tools_for_specialist(store, specialist="generalist")
|
||||
o_specs = materialize_mcp_tools_for_specialist(store, specialist="ops")
|
||||
self.assertEqual(len(g_specs), 1)
|
||||
self.assertEqual(len(o_specs), 0)
|
||||
|
||||
def test_specialist_binding_filter(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo-a",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/a",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "a_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo-b",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/b",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "b_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo-a",
|
||||
tools=[{"tool_name": "ping_a", "description": "Ping A", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo-b",
|
||||
tools=[{"tool_name": "ping_b", "description": "Ping B", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.set_setting("mcp_specialist_server_binding", '{"generalist":["echo-a"],"ops":["echo-b"]}')
|
||||
g_specs = materialize_mcp_tools_for_specialist(store, specialist="generalist")
|
||||
o_specs = materialize_mcp_tools_for_specialist(store, specialist="ops")
|
||||
g_names = {x.name for x in g_specs}
|
||||
o_names = {x.name for x in o_specs}
|
||||
self.assertIn("mcp__echo-a__ping_a", g_names)
|
||||
self.assertNotIn("mcp__echo-b__ping_b", g_names)
|
||||
self.assertIn("mcp__echo-b__ping_b", o_names)
|
||||
self.assertNotIn("mcp__echo-a__ping_a", o_names)
|
||||
|
||||
def test_manager_binding_filter(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo-a",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/a",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "a_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.upsert_mcp_server(
|
||||
server_id="echo-b",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/b",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "b_server"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo-a",
|
||||
tools=[{"tool_name": "ping_a", "description": "Ping A", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="echo-b",
|
||||
tools=[{"tool_name": "ping_b", "description": "Ping B", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.set_setting("mcp_specialist_server_binding", '{"manager":["echo-a"],"generalist":["echo-b"]}')
|
||||
m_specs = materialize_mcp_tools_for_specialist(store, specialist="manager")
|
||||
g_specs = materialize_mcp_tools_for_specialist(store, specialist="generalist")
|
||||
m_names = {x.name for x in m_specs}
|
||||
g_names = {x.name for x in g_specs}
|
||||
self.assertIn("mcp__echo-a__ping_a", m_names)
|
||||
self.assertNotIn("mcp__echo-b__ping_b", m_names)
|
||||
self.assertIn("mcp__echo-b__ping_b", g_names)
|
||||
|
||||
def test_binding_empty_json_object_falls_back_to_all_mcp_for_specialist(self) -> None:
|
||||
"""{} 不应把每个专家都当成「已绑定但列表为空」而屏蔽全部 MCP。"""
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
for sid in ("echo-a", "echo-b"):
|
||||
store.upsert_mcp_server(
|
||||
server_id=sid,
|
||||
source_type="github",
|
||||
source_ref=f"https://github.com/acme/{sid}",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "x"],
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id=sid,
|
||||
tools=[{"tool_name": "ping", "description": "P", "parameters": {"type": "object", "properties": {}}}],
|
||||
)
|
||||
store.set_setting("mcp_specialist_server_binding", "{}")
|
||||
g_specs = materialize_mcp_tools_for_specialist(store, specialist="generalist")
|
||||
names = {x.name for x in g_specs}
|
||||
self.assertIn("mcp__echo-a__ping", names)
|
||||
self.assertIn("mcp__echo-b__ping", names)
|
||||
|
||||
def test_mcp_local_env_file_path_prefers_src_local(self) -> None:
|
||||
from oclaw.ops import mcp_env
|
||||
|
||||
p = mcp_env.mcp_local_env_file_path()
|
||||
self.assertEqual(p.name, "mcp_local.env")
|
||||
parts = {x.replace("\\", "/") for x in p.parts}
|
||||
self.assertIn("oclaw", parts)
|
||||
self.assertIn("_local", parts)
|
||||
|
||||
def test_mcp_env_allowlist_keys_default_when_unset(self) -> None:
|
||||
from oclaw.ops import mcp_env
|
||||
|
||||
old = os.environ.pop("OPS_MCP_ENV_ALLOWLIST", None)
|
||||
try:
|
||||
keys = mcp_env.mcp_env_allowlist_keys()
|
||||
self.assertIn("GOOGLE_OAUTH_CREDENTIALS", keys)
|
||||
self.assertIn("BRAVE_API_KEY", keys)
|
||||
self.assertIn("GOOGLE_CALENDAR_MCP_TOKEN_PATH", keys)
|
||||
self.assertIn("GITHUB_PERSONAL_ACCESS_TOKEN", keys)
|
||||
self.assertIn("CONTEXT7_API_KEY", keys)
|
||||
finally:
|
||||
if old is not None:
|
||||
os.environ["OPS_MCP_ENV_ALLOWLIST"] = old
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
357
tests/test_mcp_admin_api.py
Normal file
357
tests/test_mcp_admin_api.py
Normal file
|
|
@ -0,0 +1,357 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class McpAdminApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
store = SqliteStore(str(self.db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
self.token = self._login()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={"tenant_id": self.tenant_id, "username": "administrator", "password": "test-admin-pass", "purpose": "console"},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def _write_mcp_server(self) -> str:
|
||||
p = Path(self._tmp.name) / "dummy_mcp_server.py"
|
||||
p.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
method = req.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": "2024-11-05", "capabilities": {}}}), flush=True)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"tools": [{"name": "ping", "description": "Ping", "inputSchema": {"type": "object"}}]}}), flush=True)
|
||||
continue
|
||||
if method == "tools/call":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": "pong"}]}}), flush=True)
|
||||
continue
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}}), flush=True)
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(p)
|
||||
|
||||
def test_install_toggle_and_failure_summary(self) -> None:
|
||||
pre = self.client.post(
|
||||
"/admin/api/mcp/preflight",
|
||||
json={"source_type": "npm", "source_ref": "demo-mcp", "entry_command": "python", "entry_args": ["-V"]},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(pre.status_code, 200)
|
||||
self.assertTrue(pre.json().get("ok"))
|
||||
|
||||
ins = self.client.post(
|
||||
"/admin/api/mcp/install",
|
||||
json={"source_type": "npm", "source_ref": "demo-mcp", "server_id": "demo-mcp", "entry_command": "python", "entry_args": ["-V"], "dry_run": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(ins.status_code, 200)
|
||||
self.assertTrue(ins.json().get("ok"), ins.json())
|
||||
|
||||
toggle = self.client.post(
|
||||
"/admin/api/mcp/toggle",
|
||||
json={"server_id": "demo-mcp", "enabled": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(toggle.status_code, 200)
|
||||
self.assertTrue(toggle.json().get("ok"))
|
||||
|
||||
store = SqliteStore(db_path())
|
||||
store.add_mcp_installation_log(
|
||||
server_id="demo-mcp",
|
||||
status="error",
|
||||
error_code="mcp_install_failed",
|
||||
detail={"error": "boom"},
|
||||
install_command="npm install -g demo-mcp",
|
||||
)
|
||||
failures = self.client.get("/admin/api/mcp/failures?limit=20", headers=self._headers())
|
||||
self.assertEqual(failures.status_code, 200)
|
||||
items = failures.json().get("items") or []
|
||||
self.assertTrue(any(str(x.get("server_id") or "") == "demo-mcp" for x in items))
|
||||
|
||||
def test_healthcheck_and_tools_sync(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=True,
|
||||
)
|
||||
health = self.client.post("/admin/api/mcp/healthcheck", json={"server_id": "dummy"}, headers=self._headers())
|
||||
self.assertEqual(health.status_code, 200)
|
||||
self.assertTrue(health.json().get("ok"), health.json())
|
||||
|
||||
sync = self.client.post("/admin/api/mcp/tools/sync", json={"server_id": "dummy"}, headers=self._headers())
|
||||
self.assertEqual(sync.status_code, 200)
|
||||
self.assertTrue(sync.json().get("ok"), sync.json())
|
||||
tools = sync.json().get("tools") or []
|
||||
self.assertTrue(any(str(t.get("tool_name") or "") == "ping" for t in tools))
|
||||
|
||||
def test_reinstall_from_saved_manifest(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy-reinstall",
|
||||
source_type="npm",
|
||||
source_ref="mcp-fetch-server",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=True,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/admin/api/mcp/reinstall",
|
||||
json={"server_id": "dummy-reinstall", "dry_run": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(str(data.get("server_id") or ""), "dummy-reinstall")
|
||||
|
||||
def test_check_all_enabled_servers(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy-check-all",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=True,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/admin/api/mcp/check-all",
|
||||
json={"enabled_only": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertGreaterEqual(int(data.get("total") or 0), 1)
|
||||
items = data.get("items") or []
|
||||
row = next((x for x in items if str(x.get("server_id") or "") == "dummy-check-all"), None)
|
||||
self.assertTrue(isinstance(row, dict))
|
||||
self.assertTrue(bool((row or {}).get("ok")), row)
|
||||
|
||||
def test_repair_weak_skips_healthy_and_fixes_empty_tools(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy-repair-weak",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=True,
|
||||
)
|
||||
self.assertEqual(len(store.list_mcp_server_tools(server_id="dummy-repair-weak")), 0)
|
||||
|
||||
r1 = self.client.post(
|
||||
"/admin/api/mcp/repair-weak",
|
||||
json={"enabled_only": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
d1 = r1.json()
|
||||
self.assertTrue(d1.get("ok"), d1)
|
||||
self.assertGreaterEqual(int(d1.get("selected") or 0), 1)
|
||||
items1 = d1.get("items") or []
|
||||
row1 = next((x for x in items1 if str(x.get("server_id") or "") == "dummy-repair-weak"), None)
|
||||
self.assertTrue(isinstance(row1, dict))
|
||||
self.assertTrue(bool((row1 or {}).get("ok")), row1)
|
||||
self.assertGreaterEqual(int((row1 or {}).get("tools_synced") or 0), 1)
|
||||
|
||||
r2 = self.client.post(
|
||||
"/admin/api/mcp/repair-weak",
|
||||
json={"enabled_only": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
d2 = r2.json()
|
||||
self.assertTrue(d2.get("ok"), d2)
|
||||
self.assertEqual(int(d2.get("selected") or 0), 0)
|
||||
self.assertGreaterEqual(int(d2.get("skipped_healthy") or 0), 1)
|
||||
|
||||
def test_repair_weak_include_disabled_server(self) -> None:
|
||||
script = self._write_mcp_server()
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="dummy-repair-disabled",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=[script],
|
||||
enabled=False,
|
||||
)
|
||||
r0 = self.client.post(
|
||||
"/admin/api/mcp/repair-weak",
|
||||
json={"enabled_only": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(r0.status_code, 200)
|
||||
self.assertEqual(int(r0.json().get("selected") or 0), 0)
|
||||
|
||||
r1 = self.client.post(
|
||||
"/admin/api/mcp/repair-weak",
|
||||
json={"enabled_only": False},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
d1 = r1.json()
|
||||
self.assertTrue(d1.get("ok"), d1)
|
||||
self.assertGreaterEqual(int(d1.get("selected") or 0), 1)
|
||||
items = d1.get("items") or []
|
||||
row = next((x for x in items if str(x.get("server_id") or "") == "dummy-repair-disabled"), None)
|
||||
self.assertTrue(isinstance(row, dict))
|
||||
self.assertTrue(bool((row or {}).get("ok")), row)
|
||||
|
||||
def test_mcp_specialists_config(self) -> None:
|
||||
get_resp = self.client.get("/admin/api/mcp/specialists", headers=self._headers())
|
||||
self.assertEqual(get_resp.status_code, 200)
|
||||
self.assertTrue(get_resp.json().get("ok"))
|
||||
save_resp = self.client.post(
|
||||
"/admin/api/mcp/specialists",
|
||||
json={"allowed_specialists": ["generalist", "ops"]},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(save_resp.status_code, 200)
|
||||
data = save_resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
self.assertEqual(data.get("allowed_specialists"), ["generalist", "ops"])
|
||||
|
||||
def test_mcp_binding_config_filters_invalid_servers(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="bind-s1",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=["-V"],
|
||||
enabled=True,
|
||||
)
|
||||
get_resp = self.client.get("/admin/api/mcp/binding", headers=self._headers())
|
||||
self.assertEqual(get_resp.status_code, 200)
|
||||
self.assertTrue(get_resp.json().get("ok"))
|
||||
save_resp = self.client.post(
|
||||
"/admin/api/mcp/binding",
|
||||
json={"mapping": {"generalist": ["bind-s1", "missing-sid"], "ops": ["missing-sid"]}},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(save_resp.status_code, 200)
|
||||
data = save_resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
mapping = data.get("mapping") or {}
|
||||
self.assertEqual(mapping.get("generalist"), ["bind-s1"])
|
||||
self.assertEqual(mapping.get("ops"), [])
|
||||
|
||||
def test_mcp_usage_and_delete(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="usage-s1",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=["-V"],
|
||||
enabled=True,
|
||||
)
|
||||
s = store.create_session("usage session")
|
||||
sid = str(s.id)
|
||||
store.add_tool_log(
|
||||
session_id=sid,
|
||||
tool_name="mcp__usage-s1__ping",
|
||||
specialist="generalist",
|
||||
args={"x": 1},
|
||||
result={"ok": True},
|
||||
)
|
||||
usage_resp = self.client.get("/admin/api/mcp/usage?limit=50", headers=self._headers())
|
||||
self.assertEqual(usage_resp.status_code, 200)
|
||||
usage_data = usage_resp.json()
|
||||
self.assertTrue(usage_data.get("ok"), usage_data)
|
||||
summary = usage_data.get("summary") or []
|
||||
self.assertTrue(any(str(x.get("server_id") or "") == "usage-s1" for x in summary), summary)
|
||||
calls = usage_data.get("calls") or []
|
||||
self.assertTrue(any(str(x.get("specialist") or "") == "generalist" for x in calls), calls)
|
||||
|
||||
del_resp = self.client.post("/admin/api/mcp/delete", json={"server_id": "usage-s1"}, headers=self._headers())
|
||||
self.assertEqual(del_resp.status_code, 200)
|
||||
self.assertTrue(del_resp.json().get("ok"), del_resp.json())
|
||||
left = [x for x in store.list_mcp_servers(enabled_only=False) if str(x.get("server_id") or "") == "usage-s1"]
|
||||
self.assertEqual(left, [])
|
||||
|
||||
def test_mcp_uninstall_remove_record(self) -> None:
|
||||
store = SqliteStore(db_path())
|
||||
store.upsert_mcp_server(
|
||||
server_id="uninstall-s1",
|
||||
source_type="github",
|
||||
source_ref="https://example.com/repo.git",
|
||||
entry_command="python",
|
||||
entry_args=["-V"],
|
||||
enabled=True,
|
||||
)
|
||||
resp = self.client.post(
|
||||
"/admin/api/mcp/uninstall",
|
||||
json={"server_id": "uninstall-s1", "remove_record": True},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
servers = store.list_mcp_servers(enabled_only=False)
|
||||
self.assertFalse(any(str(x.get("server_id") or "") == "uninstall-s1" for x in servers))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
63
tests/test_mcp_e2e_probe.py
Normal file
63
tests/test_mcp_e2e_probe.py
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from oclaw.admin.mcp_e2e_probe import build_mcp_e2e_probe_plans, parse_mcp_bound_tool_name
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
class McpE2eProbeTests(unittest.TestCase):
|
||||
def test_parse_mcp_bound_tool_name(self) -> None:
|
||||
self.assertEqual(parse_mcp_bound_tool_name("mcp__srv__ping"), ("srv", "ping"))
|
||||
self.assertIsNone(parse_mcp_bound_tool_name("read_file"))
|
||||
self.assertIsNone(parse_mcp_bound_tool_name("mcp__only"))
|
||||
|
||||
def test_build_plans_empty_registry(self) -> None:
|
||||
reg = ToolRegistry([])
|
||||
plans, skipped = build_mcp_e2e_probe_plans(reg, workspace_root="/tmp")
|
||||
self.assertEqual(plans, [])
|
||||
self.assertEqual(skipped, [])
|
||||
|
||||
def test_build_one_probe_per_server(self) -> None:
|
||||
def _h(_a):
|
||||
return {"ok": True}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="mcp__alpha__zebra",
|
||||
description="z",
|
||||
parameters={"type": "object", "properties": {}, "required": []},
|
||||
handler=_h,
|
||||
tags=frozenset({"mcp"}),
|
||||
),
|
||||
ToolSpec(
|
||||
name="mcp__alpha__apple",
|
||||
description="a",
|
||||
parameters={"type": "object", "properties": {}, "required": []},
|
||||
handler=_h,
|
||||
tags=frozenset({"mcp"}),
|
||||
),
|
||||
ToolSpec(
|
||||
name="mcp__beta__read_graph",
|
||||
description="g",
|
||||
parameters={"type": "object", "properties": {}, "required": []},
|
||||
handler=_h,
|
||||
tags=frozenset({"mcp"}),
|
||||
),
|
||||
]
|
||||
)
|
||||
plans, skipped = build_mcp_e2e_probe_plans(reg, workspace_root="/tmp")
|
||||
self.assertEqual(skipped, [])
|
||||
self.assertEqual(len(plans), 2)
|
||||
by_sid = {p[0]: p for p in plans}
|
||||
self.assertIn("alpha", by_sid)
|
||||
self.assertIn("beta", by_sid)
|
||||
# read_graph wins priority over apple/zebra for beta
|
||||
self.assertEqual(by_sid["beta"][1], "mcp__beta__read_graph")
|
||||
# alpha: apple before zebra lexicographically among equal priority
|
||||
self.assertEqual(by_sid["alpha"][1], "mcp__alpha__apple")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
91
tests/test_mcp_filesystem_argv.py
Normal file
91
tests/test_mcp_filesystem_argv.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.mcp.filesystem_argv import augment_filesystem_mcp_argv, build_mcp_process_command
|
||||
|
||||
|
||||
def _wincase_set(items: list[str]) -> set[str]:
|
||||
if os.name == "nt":
|
||||
return {os.path.normcase(x) for x in items}
|
||||
return set(items)
|
||||
|
||||
|
||||
class McpFilesystemArgvTests(unittest.TestCase):
|
||||
def test_non_filesystem_unchanged(self) -> None:
|
||||
cmd = ["python", "-c", "print(1)"]
|
||||
self.assertEqual(augment_filesystem_mcp_argv(cmd, store=None), cmd)
|
||||
|
||||
def test_appends_extra_from_env(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
tmp = Path(td)
|
||||
extra = tmp / "extra_side"
|
||||
extra.mkdir()
|
||||
base_cmd = ["npx", "-y", "@modelcontextprotocol/server-filesystem", str(tmp)]
|
||||
with mock.patch.dict(os.environ, {"OPS_WORKSPACE_EXTRA_ROOTS": str(extra)}, clear=False):
|
||||
out = augment_filesystem_mcp_argv(list(base_cmd), store=None)
|
||||
self.assertGreater(len(out), len(base_cmd))
|
||||
sset = _wincase_set(out)
|
||||
self.assertIn(os.path.normcase(str(extra.resolve())), sset)
|
||||
|
||||
def test_build_mcp_process_command(self) -> None:
|
||||
out = build_mcp_process_command("echo", ["a"], store=None)
|
||||
self.assertEqual(out, ["echo", "a"])
|
||||
|
||||
def test_policy_session_only_that_users_db_roots(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
dbf = Path(td) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T")
|
||||
tid = str(t["id"])
|
||||
ua = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="ua",
|
||||
display_name="A",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"a").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
ub = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="ub",
|
||||
display_name="B",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"b").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
sa = store.create_session("sa")
|
||||
sb = store.create_session("sb")
|
||||
store.ensure_ui_session_owner(session_id=str(sa.id), tenant_id=tid, user_id=str(ua["id"]))
|
||||
store.ensure_ui_session_owner(session_id=str(sb.id), tenant_id=tid, user_id=str(ub["id"]))
|
||||
root = Path(td) / "fsroot"
|
||||
root.mkdir()
|
||||
pa = root / "a_only"
|
||||
pa.mkdir()
|
||||
pb = root / "b_only"
|
||||
pb.mkdir()
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid, user_id=str(ua["id"]), extra_roots=str(pa), allow_any_path=False
|
||||
)
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid, user_id=str(ub["id"]), extra_roots=str(pb), allow_any_path=False
|
||||
)
|
||||
base = ["npx", "-y", "@modelcontextprotocol/server-filesystem", str(root)]
|
||||
out_a = augment_filesystem_mcp_argv(list(base), store=store, policy_session_id=str(sa.id))
|
||||
out_b = augment_filesystem_mcp_argv(list(base), store=store, policy_session_id=str(sb.id))
|
||||
a_set, b_set = _wincase_set(out_a), _wincase_set(out_b)
|
||||
self.assertIn(os.path.normcase(str(pa.resolve())), a_set)
|
||||
self.assertNotIn(os.path.normcase(str(pb.resolve())), a_set)
|
||||
self.assertIn(os.path.normcase(str(pb.resolve())), b_set)
|
||||
self.assertNotIn(os.path.normcase(str(pa.resolve())), b_set)
|
||||
del store
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
53
tests/test_mcp_installer.py
Normal file
53
tests/test_mcp_installer.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from oclaw.tools.mcp.installer import (
|
||||
_safe_server_id,
|
||||
detect_local_dependencies,
|
||||
install_mcp_server,
|
||||
preflight_mcp_server,
|
||||
uninstall_mcp_server,
|
||||
)
|
||||
from oclaw.tools.mcp.manifest import McpServerManifest
|
||||
|
||||
|
||||
class McpInstallerTests(unittest.TestCase):
|
||||
def test_safe_server_id(self) -> None:
|
||||
self.assertEqual(_safe_server_id("Hello World/Repo"), "hello-world-repo")
|
||||
|
||||
def test_install_dry_run(self) -> None:
|
||||
m = McpServerManifest(server_id="x", source_type="npm", source_ref="demo-server", version="1.0.0")
|
||||
res = install_mcp_server(m, dry_run=True)
|
||||
self.assertTrue(res.ok)
|
||||
self.assertIn(" install -g ", res.install_command)
|
||||
self.assertIn("demo-server@1.0.0", res.install_command)
|
||||
|
||||
def test_invalid_source(self) -> None:
|
||||
m = McpServerManifest(server_id="x", source_type="invalid", source_ref="x")
|
||||
res = install_mcp_server(m, dry_run=True)
|
||||
self.assertFalse(res.ok)
|
||||
self.assertEqual(res.error_code, "mcp_invalid_source")
|
||||
|
||||
def test_uninstall_dry_run(self) -> None:
|
||||
m = McpServerManifest(server_id="x", source_type="npm", source_ref="demo-server")
|
||||
res = uninstall_mcp_server(m, dry_run=True)
|
||||
self.assertTrue(res.ok)
|
||||
self.assertIn(" uninstall -g ", res.install_command)
|
||||
|
||||
def test_preflight_missing_entry(self) -> None:
|
||||
m = McpServerManifest(server_id="x", source_type="npm", source_ref="demo", entry_command="")
|
||||
res = preflight_mcp_server(m)
|
||||
self.assertFalse(res["ok"])
|
||||
self.assertEqual(res["error_code"], "mcp_entry_missing")
|
||||
self.assertTrue(isinstance(res.get("fix_suggestions"), list))
|
||||
|
||||
def test_detect_local_dependencies_shape(self) -> None:
|
||||
rows = detect_local_dependencies()
|
||||
self.assertTrue(isinstance(rows, list))
|
||||
self.assertTrue(all(isinstance(x, dict) for x in rows))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
42
tests/test_mcp_market.py
Normal file
42
tests/test_mcp_market.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from oclaw.tools.mcp.market import infer_install_template, search_mcp_market, trending_mcp_market
|
||||
|
||||
|
||||
class McpMarketTests(unittest.TestCase):
|
||||
@patch("oclaw.tools.mcp.market.search_github_repos")
|
||||
@patch("oclaw.tools.mcp.market.search_npm_packages")
|
||||
@patch("oclaw.tools.mcp.market.search_pypi_packages")
|
||||
def test_aggregate_results(self, pypi_mock, npm_mock, gh_mock) -> None:
|
||||
gh_mock.return_value = [{"source_type": "github", "name": "a"}]
|
||||
npm_mock.return_value = [{"source_type": "npm", "name": "b"}]
|
||||
pypi_mock.return_value = [{"source_type": "pypi", "name": "c"}]
|
||||
rows = search_mcp_market("demo", per_source_limit=3)
|
||||
self.assertEqual(len(rows), 3)
|
||||
self.assertEqual(rows[0]["source_type"], "github")
|
||||
self.assertEqual(rows[1]["source_type"], "npm")
|
||||
self.assertEqual(rows[2]["source_type"], "pypi")
|
||||
|
||||
def test_infer_install_template(self) -> None:
|
||||
t1 = infer_install_template("npm", "@acme/mcp-demo")
|
||||
self.assertEqual(t1["entry_command"], "npx")
|
||||
self.assertTrue(isinstance(t1["entry_args"], list))
|
||||
t2 = infer_install_template("pypi", "mcp-demo")
|
||||
self.assertEqual(t2["entry_command"], "python")
|
||||
self.assertEqual(t2["entry_args"][:1], ["-m"])
|
||||
|
||||
@patch("oclaw.tools.mcp.market.search_mcp_market")
|
||||
def test_trending_cache(self, search_mock) -> None:
|
||||
search_mock.return_value = [{"source_type": "github", "name": "a", "stars": 9}]
|
||||
rows = trending_mcp_market(force_refresh=True, per_source_limit=2)
|
||||
self.assertEqual(len(rows), 1)
|
||||
rows2 = trending_mcp_market(force_refresh=False, per_source_limit=2)
|
||||
self.assertEqual(len(rows2), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
34
tests/test_mcp_registry.py
Normal file
34
tests/test_mcp_registry.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.mcp.manifest import McpServerManifest
|
||||
from oclaw.tools.mcp.registry import McpRegistry
|
||||
|
||||
|
||||
class McpRegistryTests(unittest.TestCase):
|
||||
def test_upsert_and_snapshot(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
store = SqliteStore(str(Path(td) / "ops.sqlite"))
|
||||
reg = McpRegistry(store)
|
||||
reg.upsert_manifest(
|
||||
McpServerManifest(
|
||||
server_id="demo",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/demo",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "demo"],
|
||||
enabled=True,
|
||||
)
|
||||
)
|
||||
snap = reg.snapshot()
|
||||
self.assertEqual(int(snap["count"]), 1)
|
||||
self.assertEqual(str(snap["servers"][0]["server_id"]), "demo")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
131
tests/test_mcp_runtime.py
Normal file
131
tests/test_mcp_runtime.py
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import textwrap
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.tools.mcp.runtime import McpProcessRuntime
|
||||
|
||||
|
||||
class McpRuntimeTests(unittest.TestCase):
|
||||
def _write_server(self, body: str) -> str:
|
||||
td = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.addCleanup(td.cleanup)
|
||||
p = Path(td.name) / "mcp_server.py"
|
||||
p.write_text(textwrap.dedent(body), encoding="utf-8")
|
||||
return str(p)
|
||||
|
||||
def test_health_and_tools_list_jsonrpc(self) -> None:
|
||||
script = self._write_server(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
method = req.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": "2024-11-05", "capabilities": {}}}), flush=True)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"tools": [{"name": "ping", "description": "Ping", "inputSchema": {"type": "object", "properties": {}}}]}}), flush=True)
|
||||
continue
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}}), flush=True)
|
||||
"""
|
||||
)
|
||||
rt = McpProcessRuntime(["python", script], timeout_s=2)
|
||||
try:
|
||||
health = rt.health()
|
||||
self.assertTrue(health.get("ok"))
|
||||
self.assertEqual(health.get("status"), "ok")
|
||||
tools = rt.tools_list()
|
||||
self.assertTrue(tools.get("ok"))
|
||||
self.assertTrue(isinstance(tools.get("tools"), list))
|
||||
finally:
|
||||
rt.stop()
|
||||
|
||||
def test_timeout_returns_error_code(self) -> None:
|
||||
script = self._write_server(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
|
||||
for line in sys.stdin:
|
||||
_ = json.loads(line)
|
||||
time.sleep(0.3)
|
||||
print(json.dumps({"ok": True}), flush=True)
|
||||
"""
|
||||
)
|
||||
rt = McpProcessRuntime(["python", script], timeout_s=0.05)
|
||||
try:
|
||||
out = rt.request({"op": "health"})
|
||||
self.assertFalse(bool(out.get("ok")))
|
||||
self.assertEqual(str(out.get("error_code") or ""), "mcp_runtime_timeout")
|
||||
finally:
|
||||
rt.stop()
|
||||
|
||||
def test_empty_allowlist_keeps_path_for_subprocess(self) -> None:
|
||||
script = self._write_server(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
_ = json.loads(line)
|
||||
print(json.dumps({"ok": True, "status": "up"}), flush=True)
|
||||
"""
|
||||
)
|
||||
rt = McpProcessRuntime(["python", script], timeout_s=2, env_allowlist=[])
|
||||
try:
|
||||
out = rt.request({"op": "health"})
|
||||
self.assertTrue(bool(out.get("ok")), out)
|
||||
finally:
|
||||
rt.stop()
|
||||
|
||||
def test_jsonrpc_mcp_flow_tools_and_call(self) -> None:
|
||||
script = self._write_server(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
req = json.loads(line)
|
||||
rid = req.get("id")
|
||||
method = req.get("method")
|
||||
if method == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"protocolVersion": "2024-11-05", "capabilities": {}}}), flush=True)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
continue
|
||||
if method == "tools/list":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"tools": [{"name": "ping", "description": "Ping", "inputSchema": {"type": "object", "properties": {}}}]}}), flush=True)
|
||||
continue
|
||||
if method == "tools/call":
|
||||
params = req.get("params") or {}
|
||||
args = params.get("arguments") if isinstance(params, dict) else {}
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": str((args or {}).get("msg") or "")}]}}), flush=True)
|
||||
continue
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "method not found"}}), flush=True)
|
||||
"""
|
||||
)
|
||||
rt = McpProcessRuntime(["python", script], timeout_s=2)
|
||||
try:
|
||||
tools = rt.tools_list()
|
||||
self.assertTrue(bool(tools.get("ok")), tools)
|
||||
self.assertEqual(len(tools.get("tools") or []), 1)
|
||||
res = rt.call_tool("ping", {"msg": "hello"})
|
||||
self.assertTrue(bool(res.get("ok")), res)
|
||||
content = (((res.get("result") or {}).get("content")) or [{}])[0]
|
||||
self.assertEqual(str(content.get("text") or ""), "hello")
|
||||
finally:
|
||||
rt.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
30
tests/test_mcp_skill_adapter.py
Normal file
30
tests/test_mcp_skill_adapter.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.tools.mcp.adapter import materialize_mcp_skills_for_specialist
|
||||
|
||||
|
||||
class _Store:
|
||||
def get_setting(self, key: str) -> str: # noqa: ARG002
|
||||
return ""
|
||||
|
||||
def list_mcp_servers(self, enabled_only: bool = True): # noqa: ARG002
|
||||
return [
|
||||
{
|
||||
"server_id": "fs",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["-m", "x"],
|
||||
"timeout_s": 10.0,
|
||||
"required_permissions": [],
|
||||
}
|
||||
]
|
||||
|
||||
def list_mcp_server_tools(self, server_id: str): # noqa: ARG002
|
||||
return [{"tool_name": "read", "description": "read", "parameters": {"type": "object", "properties": {}}}]
|
||||
|
||||
|
||||
def test_materialize_mcp_skills_for_specialist() -> None:
|
||||
rows = materialize_mcp_skills_for_specialist(_Store(), specialist="generalist")
|
||||
assert len(rows) == 1
|
||||
assert rows[0].name == "mcp__fs__read"
|
||||
assert rows[0].origin == "mcp"
|
||||
|
||||
73
tests/test_mcp_store.py
Normal file
73
tests/test_mcp_store.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class McpStoreTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_upsert_list_and_tools(self) -> None:
|
||||
self.store.upsert_mcp_server(
|
||||
server_id="s1",
|
||||
source_type="github",
|
||||
source_ref="https://github.com/acme/mcp-a",
|
||||
version="v1",
|
||||
entry_command="python",
|
||||
entry_args=["-m", "mcp_a"],
|
||||
required_permissions=["admin:read"],
|
||||
enabled=True,
|
||||
)
|
||||
self.store.replace_mcp_server_tools(
|
||||
server_id="s1",
|
||||
tools=[
|
||||
{"tool_name": "ping", "description": "Ping tool", "parameters": {"type": "object", "properties": {}}},
|
||||
],
|
||||
)
|
||||
rows = self.store.list_mcp_servers(enabled_only=True)
|
||||
self.assertEqual(len(rows), 1)
|
||||
self.assertEqual(rows[0]["server_id"], "s1")
|
||||
tools = self.store.list_mcp_server_tools(server_id="s1")
|
||||
self.assertEqual(len(tools), 1)
|
||||
self.assertEqual(tools[0]["tool_name"], "ping")
|
||||
|
||||
def test_install_and_health_logs(self) -> None:
|
||||
self.store.add_mcp_installation_log(
|
||||
server_id="s1",
|
||||
status="error",
|
||||
error_code="mcp_install_failed",
|
||||
detail={"x": 1},
|
||||
install_command="npm i -g foo",
|
||||
)
|
||||
self.store.set_mcp_server_health(server_id="s1", status="ok", detail={"latency_ms": 2})
|
||||
logs = self.store.list_mcp_installation_logs(server_id="s1", limit=20)
|
||||
self.assertEqual(len(logs), 1)
|
||||
self.assertEqual(logs[0]["error_code"], "mcp_install_failed")
|
||||
health = self.store.list_mcp_server_health()
|
||||
self.assertEqual(len(health), 1)
|
||||
self.assertEqual(health[0]["server_id"], "s1")
|
||||
self.store.add_mcp_installation_log(
|
||||
server_id="s1",
|
||||
status="error",
|
||||
error_code="mcp_install_failed",
|
||||
detail={},
|
||||
install_command="npm i",
|
||||
)
|
||||
summary = self.store.list_mcp_install_failure_summary(limit=10)
|
||||
self.assertTrue(len(summary) >= 1)
|
||||
self.assertEqual(summary[0]["server_id"], "s1")
|
||||
self.assertEqual(summary[0]["error_code"], "mcp_install_failed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
239
tests/test_mcp_workspace_filesystem_e2e.py
Normal file
239
tests/test_mcp_workspace_filesystem_e2e.py
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
"""End-to-end style checks: DB allowlist → MCP server-filesystem argv augmentation.
|
||||
|
||||
Reproduces the case where the primary argv root is the repo (D:) while ``extra_roots``
|
||||
must be merged so ``list_directory`` on e.g. Desktop is allowed by the MCP subprocess.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.agents.network_ops_agent import NetworkOpsAgent
|
||||
from oclaw.platform.llm.chat_models import RuleBasedChatModel
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.mcp.adapter import materialize_mcp_tools_for_specialist
|
||||
from oclaw.tools.mcp.filesystem_argv import build_mcp_process_command, collect_filesystem_mcp_extra_roots
|
||||
from oclaw.tools.mcp.runtime import McpProcessRuntime
|
||||
|
||||
|
||||
class McpWorkspaceFilesystemE2ETests(unittest.TestCase):
|
||||
def test_collect_merges_allowlist_distinct_from_project_root(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
dbf = Path(td) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u1",
|
||||
display_name="U",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"x").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
sess = store.create_session("chat")
|
||||
store.ensure_ui_session_owner(session_id=str(sess.id), tenant_id=tid, user_id=uid)
|
||||
extra = Path(td) / "extra_outside_project"
|
||||
extra.mkdir()
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
roots = collect_filesystem_mcp_extra_roots(
|
||||
store=store,
|
||||
policy_session_id=str(sess.id),
|
||||
path_policy_tenant_id=tid,
|
||||
path_policy_user_id=uid,
|
||||
)
|
||||
keys = {os.path.normcase(x) for x in roots}
|
||||
self.assertIn(os.path.normcase(str(extra.resolve())), keys)
|
||||
|
||||
def test_build_mcp_process_command_appends_extra_root_to_npx_filesystem(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
dbf = Path(td) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T2")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u2",
|
||||
display_name="U2",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"y").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
sess = store.create_session("c2")
|
||||
store.ensure_ui_session_owner(session_id=str(sess.id), tenant_id=tid, user_id=uid)
|
||||
proj = Path(td) / "repo"
|
||||
proj.mkdir()
|
||||
extra = Path(td) / "allowed_extra"
|
||||
extra.mkdir()
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
cmd = build_mcp_process_command(
|
||||
"npx",
|
||||
["-y", "@modelcontextprotocol/server-filesystem", str(proj)],
|
||||
store=store,
|
||||
policy_session_id=str(sess.id),
|
||||
path_policy_tenant_id=tid,
|
||||
path_policy_user_id=uid,
|
||||
)
|
||||
norm = {os.path.normcase(x) for x in cmd}
|
||||
self.assertIn(os.path.normcase(str(extra.resolve())), norm)
|
||||
self.assertGreaterEqual(len([x for x in cmd if os.path.normcase(x) == os.path.normcase(str(extra.resolve()))]), 1)
|
||||
|
||||
def test_materialize_mcp_includes_augmented_argv_for_filesystem(self) -> None:
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
dbf = Path(td) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T3")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u3",
|
||||
display_name="U3",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"z").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
sess = store.create_session("c3")
|
||||
store.ensure_ui_session_owner(session_id=str(sess.id), tenant_id=tid, user_id=uid)
|
||||
proj = Path(td) / "wroot"
|
||||
proj.mkdir()
|
||||
extra = Path(td) / "mcp_extra"
|
||||
extra.mkdir()
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
store.upsert_mcp_server(
|
||||
server_id="mcp-server-filesystem",
|
||||
source_type="npm",
|
||||
source_ref="@modelcontextprotocol/server-filesystem",
|
||||
version="",
|
||||
entry_command="npx",
|
||||
entry_args=["-y", "@modelcontextprotocol/server-filesystem", str(proj)],
|
||||
env_schema={},
|
||||
required_permissions=[],
|
||||
risk_level="high",
|
||||
timeout_s=60.0,
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="mcp-server-filesystem",
|
||||
tools=[
|
||||
{
|
||||
"tool_name": "list_directory",
|
||||
"description": "list",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
tools = materialize_mcp_tools_for_specialist(
|
||||
store,
|
||||
specialist="generalist",
|
||||
policy_session_id=str(sess.id),
|
||||
path_policy_tenant_id=tid,
|
||||
path_policy_user_id=uid,
|
||||
)
|
||||
fs = [x for x in tools if "mcp__mcp-server-filesystem__" in (x.name or "")]
|
||||
self.assertTrue(fs, msg="expected filesystem MCP tools")
|
||||
rebuilt = build_mcp_process_command(
|
||||
"npx",
|
||||
["-y", "@modelcontextprotocol/server-filesystem", str(proj)],
|
||||
store=store,
|
||||
policy_session_id=str(sess.id),
|
||||
path_policy_tenant_id=tid,
|
||||
path_policy_user_id=uid,
|
||||
)
|
||||
self.assertIn(os.path.normcase(str(extra.resolve())), {os.path.normcase(x) for x in rebuilt})
|
||||
|
||||
def test_network_ops_agent_mcp_filesystem_argv_includes_user_extra_roots(self) -> None:
|
||||
"""Ops specialist uses NetworkOpsAgent; it must still merge per-user extra_roots into MCP argv."""
|
||||
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as td:
|
||||
dbf = Path(td) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T4")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u4",
|
||||
display_name="U4",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"w").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
sess = store.create_session("c4")
|
||||
store.ensure_ui_session_owner(session_id=str(sess.id), tenant_id=tid, user_id=uid)
|
||||
proj = Path(td) / "ops_wroot"
|
||||
proj.mkdir()
|
||||
extra = Path(td) / "ops_mcp_extra"
|
||||
extra.mkdir()
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
store.upsert_mcp_server(
|
||||
server_id="mcp-server-filesystem",
|
||||
source_type="npm",
|
||||
source_ref="@modelcontextprotocol/server-filesystem",
|
||||
version="",
|
||||
entry_command="npx",
|
||||
entry_args=["-y", "@modelcontextprotocol/server-filesystem", str(proj)],
|
||||
env_schema={},
|
||||
required_permissions=[],
|
||||
risk_level="high",
|
||||
timeout_s=60.0,
|
||||
enabled=True,
|
||||
)
|
||||
store.replace_mcp_server_tools(
|
||||
server_id="mcp-server-filesystem",
|
||||
tools=[
|
||||
{
|
||||
"tool_name": "list_directory",
|
||||
"description": "list",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
}
|
||||
],
|
||||
)
|
||||
agent = NetworkOpsAgent(
|
||||
store=store,
|
||||
model=RuleBasedChatModel(),
|
||||
policy_session_id=str(sess.id),
|
||||
path_policy_tenant_id=tid,
|
||||
path_policy_user_id=uid,
|
||||
)
|
||||
fs_tools = [x for x in agent.tools.list() if "mcp__mcp-server-filesystem__" in (x.name or "")]
|
||||
self.assertTrue(fs_tools, msg="expected filesystem MCP tools on NetworkOpsAgent")
|
||||
h = fs_tools[0].handler
|
||||
rt = None
|
||||
for cell in getattr(h, "__closure__", None) or ():
|
||||
v = cell.cell_contents
|
||||
if isinstance(v, McpProcessRuntime):
|
||||
rt = v
|
||||
break
|
||||
self.assertIsNotNone(rt)
|
||||
assert rt is not None
|
||||
cmd = list(rt.command)
|
||||
self.assertIn(os.path.normcase(str(extra.resolve())), {os.path.normcase(x) for x in cmd})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
76
tests/test_memory_vector.py
Normal file
76
tests/test_memory_vector.py
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.orchestration.memory import maybe_write_turn_memory, semantic_retrieve
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class MemoryVectorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
db = Path(self._tmp.name) / "test.sqlite"
|
||||
self.store = SqliteStore(str(db))
|
||||
self.store.set_setting("MEMORY_VECTOR_ENABLED", "1")
|
||||
self.store.set_setting("MEMORY_VECTOR_BACKEND", "sqlite")
|
||||
self.store.set_setting("MEMORY_WRITE_ENABLED", "1")
|
||||
self.store.set_setting("MEMORY_WRITE_MIN_CONFIDENCE", "0.5")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_write_and_semantic_retrieve_same_tenant_user(self) -> None:
|
||||
res = maybe_write_turn_memory(
|
||||
self.store,
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
session_id="s1",
|
||||
user_text="记住我喜欢喝黑咖啡,不加糖。",
|
||||
assistant_text="好的,我记住了你的偏好。",
|
||||
)
|
||||
self.assertTrue(res.get("ok"))
|
||||
hits = semantic_retrieve(
|
||||
self.store,
|
||||
query="我喝什么咖啡",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
session_id="s2",
|
||||
top_k=3,
|
||||
)
|
||||
self.assertGreaterEqual(len(hits), 1)
|
||||
|
||||
def test_tenant_isolation(self) -> None:
|
||||
maybe_write_turn_memory(
|
||||
self.store,
|
||||
tenant_id="tA",
|
||||
user_id="uA",
|
||||
session_id="sA",
|
||||
user_text="记住我的常住城市是上海。",
|
||||
assistant_text="已记录。",
|
||||
)
|
||||
hits = semantic_retrieve(
|
||||
self.store,
|
||||
query="我的城市",
|
||||
tenant_id="tB",
|
||||
user_id="uA",
|
||||
session_id="sB",
|
||||
top_k=3,
|
||||
)
|
||||
self.assertEqual(hits, [])
|
||||
|
||||
def test_vector_disabled_fallback(self) -> None:
|
||||
self.store.set_setting("MEMORY_VECTOR_ENABLED", "0")
|
||||
hits = semantic_retrieve(
|
||||
self.store,
|
||||
query="anything",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
top_k=3,
|
||||
)
|
||||
self.assertEqual(hits, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
89
tests/test_memory_wiki_plugin.py
Normal file
89
tests/test_memory_wiki_plugin.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
from oclaw.gateway.python_extensions_loader import build_python_extensions_registry
|
||||
from oclaw.gateway.server_plugins import load_gateway_plugins
|
||||
from oclaw.tools.catalog import materialize_tool_specs
|
||||
|
||||
|
||||
def _load_memory_wiki_api_module():
|
||||
p = Path("d:/project/chatgpt/oclaw/extensions/memory-wiki/api.py")
|
||||
module_name = "test_memory_wiki_api"
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(p))
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_memory_wiki_tool_handlers_basic_flow(tmp_path: Path) -> None:
|
||||
mod = _load_memory_wiki_api_module()
|
||||
wiki_root = tmp_path / "wiki"
|
||||
|
||||
class _Api:
|
||||
plugin_config = {"wiki_root": str(wiki_root)}
|
||||
|
||||
specs = {x["name"]: x for x in mod.build_wiki_tool_specs(_Api())}
|
||||
assert {"wiki_status", "wiki_lint", "wiki_apply", "wiki_search", "wiki_get"} <= set(specs.keys())
|
||||
|
||||
out_write = specs["wiki_apply"]["handler"]({"action": "write", "path": "notes/a.md", "content": "# A\n\nhello"})
|
||||
assert out_write.get("ok") is True
|
||||
|
||||
out_get = specs["wiki_get"]["handler"]({"path": "notes/a.md"})
|
||||
assert out_get.get("ok") is True
|
||||
assert "hello" in str(out_get.get("content") or "")
|
||||
|
||||
out_search = specs["wiki_search"]["handler"]({"query": "hello"})
|
||||
assert out_search.get("ok") is True
|
||||
assert len(out_search.get("hits") or []) >= 1
|
||||
|
||||
out_lint = specs["wiki_lint"]["handler"]({})
|
||||
assert out_lint.get("ok") is True
|
||||
|
||||
out_status = specs["wiki_status"]["handler"]({})
|
||||
assert out_status.get("ok") is True
|
||||
assert int(out_status.get("file_count") or 0) >= 1
|
||||
|
||||
|
||||
def test_memory_wiki_plugin_registry_and_catalog_wiring(tmp_path: Path, monkeypatch) -> None:
|
||||
wiki_root = tmp_path / "wiki"
|
||||
app_cfg = {"plugins": {"entries": {"memory-wiki": {"wiki_root": str(wiki_root)}}}}
|
||||
reg = build_python_extensions_registry(
|
||||
app_config=app_cfg,
|
||||
workspace_dir="d:/project/chatgpt",
|
||||
only_plugin_ids=["memory-wiki"],
|
||||
)
|
||||
tools = [x for x in (reg.get("tools") or []) if isinstance(x, dict)]
|
||||
names = {str(t.get("name") or "") for t in tools}
|
||||
assert {"wiki_status", "wiki_lint", "wiki_apply", "wiki_search", "wiki_get"} <= names
|
||||
assert all(callable(t.get("handler")) for t in tools if str(t.get("name") or "").startswith("wiki_"))
|
||||
|
||||
monkeypatch.setenv("AIA_PLUGIN_TOOLS_ENABLED", "1")
|
||||
monkeypatch.setenv("AIA_PLUGIN_TOOL_IDS", "memory-wiki")
|
||||
specs = materialize_tool_specs()
|
||||
spec_names = {s.name for s in specs}
|
||||
assert "wiki_status" in spec_names
|
||||
|
||||
|
||||
def test_server_plugins_can_load_memory_wiki_only() -> None:
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"slots": {"memory": "memory-wiki"},
|
||||
}
|
||||
}
|
||||
got = load_gateway_plugins(
|
||||
cfg=cfg,
|
||||
workspace_dir="d:/project/chatgpt",
|
||||
log={},
|
||||
core_gateway_handlers={},
|
||||
base_methods=[],
|
||||
plugin_ids=["memory-wiki"],
|
||||
)
|
||||
plugin_ids = {str(x.get("id") or "") for x in (got.plugin_registry.get("plugins") or [])}
|
||||
assert "memory-wiki" in plugin_ids
|
||||
|
||||
125
tests/test_oclaw_gateway_optimizations.py
Normal file
125
tests/test_oclaw_gateway_optimizations.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
def test_wait_for_agent_job_is_non_blocking_pending(monkeypatch) -> None:
|
||||
from oclaw.interfaces.ws import server_methods_bridge as bridge
|
||||
|
||||
class DummyLock:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class DummyStore:
|
||||
def openclaw_run_get(self, run_id: str) -> None:
|
||||
return None
|
||||
|
||||
def get_messages(self, session_id: str, limit: int) -> list[Any]:
|
||||
return []
|
||||
|
||||
def list_sessions(self, limit: int, offset: int) -> list[Any]:
|
||||
return []
|
||||
|
||||
def get_session(self, session_id: str) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(bridge, "SqliteStore", lambda _p: DummyStore())
|
||||
monkeypatch.setattr(bridge, "db_path", lambda: "dummy.sqlite")
|
||||
|
||||
ctx = bridge.build_gateway_context(
|
||||
conn_id="c1",
|
||||
subscribed_sessions_changed=False,
|
||||
subscribed_message_keys=set(),
|
||||
abort_lock=DummyLock(),
|
||||
active_run_session={},
|
||||
aborted_run_ids=set(),
|
||||
run_agent_turn=lambda *a, **k: None,
|
||||
normalize_ws_attachments=lambda _a: [],
|
||||
validate_relay_share_envelope=lambda _e: (True, "", {}),
|
||||
now_ms=lambda: 0,
|
||||
)
|
||||
|
||||
out = ctx["wait_for_agent_job"]("rid-1", {})
|
||||
assert out["status"] == "pending"
|
||||
assert out["runId"] == "rid-1"
|
||||
assert out["pollAfterMs"] == 250
|
||||
|
||||
|
||||
def test_turn_runner_emits_delta_only(monkeypatch) -> None:
|
||||
from oclaw.interfaces.ws import turn_runner
|
||||
|
||||
@dataclass
|
||||
class DummyResult:
|
||||
run_id: str = "run-1"
|
||||
reply_text: str = ""
|
||||
elapsed_ms: int = 10
|
||||
|
||||
class DummyGateway:
|
||||
def handle_turn(self, **kwargs: Any) -> DummyResult:
|
||||
kwargs["on_token"]("A")
|
||||
kwargs["on_token"]("B")
|
||||
return DummyResult()
|
||||
|
||||
class DummyStore:
|
||||
def get_messages(self, session_id: str, limit: int) -> list[Any]:
|
||||
return []
|
||||
|
||||
class DummyLock:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
class DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.auth_ctx = {"tenant_id": "t", "user_id": "u", "username": "n"}
|
||||
self._abort_lock = DummyLock()
|
||||
self._active_run_session: dict[str, str] = {}
|
||||
self._aborted_run_ids: set[str] = set()
|
||||
self._is_webchat_client = False
|
||||
self._subscribed_sessions_changed = False
|
||||
self.chat_calls: list[dict[str, Any]] = []
|
||||
|
||||
async def emit_agent_event(self, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
async def emit_chat_event(self, **kwargs: Any) -> None:
|
||||
self.chat_calls.append(dict(kwargs))
|
||||
|
||||
async def send_event(self, event: str, payload: Any) -> None:
|
||||
return None
|
||||
|
||||
async def send_res(self, *args: Any, **kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(turn_runner, "SqliteStore", lambda _p: DummyStore())
|
||||
monkeypatch.setattr(turn_runner, "db_path", lambda: "dummy.sqlite")
|
||||
monkeypatch.setattr(turn_runner, "OpenClawGateway", lambda store: DummyGateway())
|
||||
monkeypatch.setattr(turn_runner, "build_gateway_executor", lambda *a, **k: object())
|
||||
|
||||
conn = DummyConn()
|
||||
|
||||
asyncio.run(
|
||||
turn_runner.run_agent_turn_via_bridge(
|
||||
conn=conn,
|
||||
req_id="req-1",
|
||||
p={"message": "hello"},
|
||||
session_id="s1",
|
||||
send_response=False,
|
||||
normalize_ws_attachments=lambda _a: [],
|
||||
validate_relay_share_envelope=lambda _e: (True, "", {}),
|
||||
now_ms=lambda: 1,
|
||||
error_shape=lambda c, m: {"code": c, "message": m},
|
||||
)
|
||||
)
|
||||
|
||||
delta_calls = [c for c in conn.chat_calls if c.get("state") == "delta"]
|
||||
assert [c.get("delta") for c in delta_calls] == ["A", "B"]
|
||||
assert all("message" not in c for c in delta_calls)
|
||||
|
||||
36
tests/test_openai_responses_transport.py
Normal file
36
tests/test_openai_responses_transport.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.transports.openai_responses import parse_openai_responses_stream_events
|
||||
|
||||
|
||||
def test_openai_responses_stream_parses_text_deltas_and_final_response_tool_calls() -> None:
|
||||
events = [
|
||||
{"type": "response.created"},
|
||||
{"type": "response.output_text.delta", "delta": "Hel"},
|
||||
{"type": "response.output_text.delta", "delta": "lo"},
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_1",
|
||||
"output_text": "Hello",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"call_id": "call_1",
|
||||
"name": "query_route",
|
||||
"arguments": {"destination": "1.1.1.1"},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
buf: list[str] = []
|
||||
text, tool_calls, final_resp = parse_openai_responses_stream_events(events, on_token=buf.append)
|
||||
assert text == "Hello"
|
||||
assert "".join(buf) == "Hello"
|
||||
assert final_resp and final_resp.get("id") == "resp_1"
|
||||
assert len(tool_calls) == 1
|
||||
assert tool_calls[0].id == "call_1"
|
||||
assert tool_calls[0].name == "query_route"
|
||||
assert tool_calls[0].arguments.get("destination") == "1.1.1.1"
|
||||
|
||||
32
tests/test_openai_tools_shrink.py
Normal file
32
tests/test_openai_tools_shrink.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from oclaw.platform.llm.chat_models import shrink_openai_tools_payload_for_api, _openai_tools_json_byte_length
|
||||
|
||||
|
||||
class OpenAiToolsShrinkTests(unittest.TestCase):
|
||||
def test_shrink_fits_under_cap(self) -> None:
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "mcp__demo__ping",
|
||||
"description": "x" * 2000,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {f"f{i}": {"type": "string", "description": "y" * 500} for i in range(40)},
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
self.assertGreater(_openai_tools_json_byte_length(tools), 5000)
|
||||
out = shrink_openai_tools_payload_for_api(tools, max_json_bytes=4000)
|
||||
self.assertLessEqual(_openai_tools_json_byte_length(out), 4000)
|
||||
self.assertEqual(out[0]["function"]["name"], "mcp__demo__ping")
|
||||
self.assertEqual(out[0]["function"]["parameters"].get("type"), "object")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
57
tests/test_openclaw_acp_pointer_relay.py
Normal file
57
tests/test_openclaw_acp_pointer_relay.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.openclaw_runtime.relay_pointer import build_acp_relay_result, validate_relay_share_envelope
|
||||
|
||||
|
||||
def test_validate_relay_share_envelope_ok() -> None:
|
||||
ok, err, env = validate_relay_share_envelope(
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"attachments": {
|
||||
"scope_id": "s1",
|
||||
"count": 1,
|
||||
"total_bytes": 8,
|
||||
"pointers": [
|
||||
{
|
||||
"schema_version": "v1",
|
||||
"pointer_uri": "relay://attachments/s1/abcdef123456",
|
||||
"rel_path": "attachments/a.txt",
|
||||
"mime_type": "text/plain",
|
||||
"bytes": 8,
|
||||
"sha256": "f" * 64,
|
||||
"source_agent": "generalist",
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
assert ok is True
|
||||
assert err == ""
|
||||
assert isinstance(env.get("attachments"), dict)
|
||||
|
||||
|
||||
def test_validate_relay_share_envelope_bad_version() -> None:
|
||||
ok, err, env = validate_relay_share_envelope({"schema_version": "v2", "attachments": {}})
|
||||
assert ok is False
|
||||
assert err == "relay_envelope_unsupported_version"
|
||||
assert env == {}
|
||||
|
||||
|
||||
def test_build_acp_relay_result_includes_ids_and_error() -> None:
|
||||
out = build_acp_relay_result(
|
||||
parent_run_id="parent-1",
|
||||
child_run_id="child-1",
|
||||
relay_envelope={"schema_version": "v2", "attachments": {}},
|
||||
)
|
||||
assert out["acp_parent_run_id"] == "parent-1"
|
||||
assert out["acp_child_run_id"] == "child-1"
|
||||
assert out["relay_ok"] is False
|
||||
assert out["relay_error_code"] == "relay_envelope_unsupported_version"
|
||||
assert out["relay_pointer_count"] == 0
|
||||
|
||||
|
||||
def test_validate_relay_share_envelope_missing_attachments() -> None:
|
||||
ok, err, env = validate_relay_share_envelope({"schema_version": "v1"})
|
||||
assert ok is False
|
||||
assert err == "relay_envelope_invalid"
|
||||
assert env == {}
|
||||
176
tests/test_openclaw_agent_core.py
Normal file
176
tests/test_openclaw_agent_core.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.agent_core_run import AgentCoreRunInput, run_agent_core
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
from oclaw.platform.llm.chat_models import LLMResponse, StaticTextChatModel
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry
|
||||
|
||||
|
||||
class _FailOnceModel:
|
||||
def __init__(self) -> None:
|
||||
self._n = 0
|
||||
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
self._n += 1
|
||||
if self._n == 1:
|
||||
raise RuntimeError("temporary_model_error")
|
||||
if on_token:
|
||||
on_token("ok")
|
||||
return LLMResponse(content="ok", tool_calls=[])
|
||||
|
||||
|
||||
def _msg(session_id: str) -> StandardMessage:
|
||||
return StandardMessage(
|
||||
session_id=session_id,
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="member",
|
||||
channel="admin_chat",
|
||||
text="hello",
|
||||
attachments=[],
|
||||
metadata={"tenant_id": "t1", "user_id": "u1"},
|
||||
)
|
||||
|
||||
|
||||
def test_agent_core_run_retries_then_succeeds(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "a.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
model = _FailOnceModel()
|
||||
out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=model,
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
max_attempts=2,
|
||||
),
|
||||
)
|
||||
assert out.run_state.status == "success"
|
||||
assert len(out.run_state.attempts) == 2
|
||||
assert out.run_state.attempts[0].status == "retry"
|
||||
assert out.run_state.attempts[1].status == "success"
|
||||
|
||||
rows = store.openclaw_attempt_list(run_id=out.run_state.run_id)
|
||||
assert len(rows) == 2
|
||||
|
||||
|
||||
class _AlwaysFailModel:
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
raise RuntimeError("bad_request_invalid_input")
|
||||
|
||||
|
||||
class _AuthFailModel:
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
raise RuntimeError("401 unauthorized invalid api key")
|
||||
|
||||
|
||||
class _RelayEnvelopeFailModel:
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
raise RuntimeError("relay_envelope_invalid")
|
||||
|
||||
|
||||
def test_agent_core_non_retryable_stops_early(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "d.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=_AlwaysFailModel(),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
max_attempts=3,
|
||||
),
|
||||
)
|
||||
assert out.run_state.status == "failed"
|
||||
assert out.run_state.stop_reason == "non_retryable_error"
|
||||
assert len(out.run_state.attempts) == 1
|
||||
assert out.run_state.attempts[0].status == "failed"
|
||||
|
||||
|
||||
def test_agent_core_auth_error_not_retryable(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "e.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=_AuthFailModel(),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
max_attempts=3,
|
||||
),
|
||||
)
|
||||
assert out.run_state.status == "failed"
|
||||
assert out.run_state.last_error_code == "auth_invalid_credentials"
|
||||
assert len(out.run_state.attempts) == 1
|
||||
assert out.run_state.attempts[0].error_code == "auth_invalid_credentials"
|
||||
|
||||
|
||||
def test_agent_core_relay_envelope_error_not_retryable(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "f.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=_RelayEnvelopeFailModel(),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
max_attempts=3,
|
||||
),
|
||||
)
|
||||
assert out.run_state.status == "failed"
|
||||
assert out.run_state.last_error_code == "relay_envelope_invalid"
|
||||
assert len(out.run_state.attempts) == 1
|
||||
assert out.run_state.attempts[0].error_code == "relay_envelope_invalid"
|
||||
|
||||
|
||||
def test_gateway_fail_closed_when_executor_missing_model_tools(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "b.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
gw = OpenClawGateway(store=store)
|
||||
|
||||
class _BadExecutor:
|
||||
pass
|
||||
|
||||
res = gw.handle_turn(msg=_msg(sess.id), lang="zh", executor=_BadExecutor())
|
||||
assert "缺少 model/tools" in res.reply_text
|
||||
|
||||
|
||||
def test_agent_core_single_attempt_success(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "c.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
out = run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=StaticTextChatModel("done"),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=None,
|
||||
parent_span_id=None,
|
||||
max_attempts=2,
|
||||
),
|
||||
)
|
||||
assert out.run_state.status == "success"
|
||||
assert out.outcome.final_text == "done"
|
||||
|
||||
119
tests/test_openclaw_agent_core_trace.py
Normal file
119
tests/test_openclaw_agent_core_trace.py
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.openclaw_runtime.agent_core_run import AgentCoreRunInput, run_agent_core
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
from oclaw.platform.llm.chat_models import StaticTextChatModel
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry
|
||||
|
||||
|
||||
def _msg(session_id: str, *, metadata: dict | None = None) -> StandardMessage:
|
||||
return StandardMessage(
|
||||
session_id=session_id,
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="member",
|
||||
channel="admin_chat",
|
||||
text="hello",
|
||||
attachments=[],
|
||||
metadata=metadata if isinstance(metadata, dict) else {"tenant_id": "t1", "user_id": "u1"},
|
||||
)
|
||||
|
||||
|
||||
def test_agent_core_run_started_trace_payload_keys(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = SqliteStore(str(tmp_path / "trace.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
events: list[dict] = []
|
||||
real_add = store.add_trace_event
|
||||
|
||||
def _wrap(**kwargs: object) -> None:
|
||||
events.append(dict(kwargs))
|
||||
return real_add(**kwargs)
|
||||
|
||||
monkeypatch.setattr(store, "add_trace_event", _wrap)
|
||||
|
||||
trace_id = "trace-abc"
|
||||
run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(sess.id),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=StaticTextChatModel("done"),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id=trace_id,
|
||||
parent_span_id=None,
|
||||
max_attempts=1,
|
||||
openclaw_task_id="task-99",
|
||||
openclaw_worker_id="worker-zz",
|
||||
),
|
||||
)
|
||||
|
||||
started = [e for e in events if e.get("event_type") == "run_started"]
|
||||
assert started, events
|
||||
pl = started[0].get("payload") or {}
|
||||
assert pl.get("pipeline") == "openclaw_agent_core"
|
||||
assert pl.get("oc_stage") == "run_start"
|
||||
assert pl.get("trace_id") == trace_id
|
||||
assert pl.get("lang") == "zh"
|
||||
assert "run_id" in pl
|
||||
assert pl.get("openclaw_task_id") == "task-99"
|
||||
assert pl.get("openclaw_worker_id") == "worker-zz"
|
||||
assert pl.get("relay_envelope_present") is False
|
||||
assert pl.get("relay_envelope_pointer_count") == 0
|
||||
|
||||
att = [e for e in events if e.get("event_type") == "attempt_started"]
|
||||
assert att
|
||||
apl = att[0].get("payload") or {}
|
||||
assert apl.get("oc_stage") == "attempt"
|
||||
assert apl.get("attempt_no") == 1
|
||||
|
||||
|
||||
def test_agent_core_trace_includes_relay_envelope_stats(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = SqliteStore(str(tmp_path / "trace2.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
events: list[dict] = []
|
||||
real_add = store.add_trace_event
|
||||
|
||||
def _wrap(**kwargs: object) -> None:
|
||||
events.append(dict(kwargs))
|
||||
return real_add(**kwargs)
|
||||
|
||||
monkeypatch.setattr(store, "add_trace_event", _wrap)
|
||||
run_agent_core(
|
||||
store=store,
|
||||
data=AgentCoreRunInput(
|
||||
msg=_msg(
|
||||
sess.id,
|
||||
metadata={
|
||||
"tenant_id": "t1",
|
||||
"user_id": "u1",
|
||||
"relay_share_envelope": {
|
||||
"schema_version": "v1",
|
||||
"attachments": {"pointers": [{"pointer_uri": "relay://attachments/scope_1/abcdef123456"}]},
|
||||
},
|
||||
},
|
||||
),
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=StaticTextChatModel("done"),
|
||||
tools=ToolRegistry([]),
|
||||
trace_id="trace-relay",
|
||||
parent_span_id=None,
|
||||
max_attempts=1,
|
||||
),
|
||||
)
|
||||
started = [e for e in events if e.get("event_type") == "run_started"]
|
||||
assert started
|
||||
sp = started[0].get("payload") or {}
|
||||
assert sp.get("relay_envelope_present") is True
|
||||
assert sp.get("relay_envelope_pointer_count") == 1
|
||||
att = [e for e in events if e.get("event_type") == "attempt_started"]
|
||||
assert att
|
||||
ap = att[0].get("payload") or {}
|
||||
assert ap.get("relay_envelope_present") is True
|
||||
assert ap.get("relay_envelope_pointer_count") == 1
|
||||
28
tests/test_openclaw_command_parser.py
Normal file
28
tests/test_openclaw_command_parser.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.openclaw_runtime.command_parser import parse_internal_command
|
||||
|
||||
|
||||
def test_parse_internal_command_basic() -> None:
|
||||
got = parse_internal_command("/new")
|
||||
assert got is not None
|
||||
assert got.action == "new"
|
||||
assert got.command == "new"
|
||||
assert got.args == ()
|
||||
|
||||
|
||||
def test_parse_internal_command_alias_and_args() -> None:
|
||||
got = parse_internal_command("reset now force")
|
||||
assert got is not None
|
||||
assert got.action == "reset"
|
||||
assert got.command == "reset"
|
||||
assert got.args == ("now", "force")
|
||||
|
||||
|
||||
def test_parse_internal_command_localized_alias() -> None:
|
||||
got = parse_internal_command("/重置 会话")
|
||||
assert got is not None
|
||||
assert got.action == "reset"
|
||||
assert got.command == "重置"
|
||||
assert got.args == ("会话",)
|
||||
|
||||
38
tests/test_openclaw_dynamic_expert.py
Normal file
38
tests/test_openclaw_dynamic_expert.py
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.tools.base import ToolSpec
|
||||
from oclaw.tools.catalog import _apply_declared_tool_policy
|
||||
|
||||
|
||||
def _tool(name: str, tags: set[str]) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=name,
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler=lambda _args: {"ok": True},
|
||||
tags=frozenset(tags),
|
||||
)
|
||||
|
||||
|
||||
def test_declared_tool_policy_filters_by_tags_and_names() -> None:
|
||||
tools = [
|
||||
_tool("search_workspace", {"search", "read"}),
|
||||
_tool("run_command", {"shell", "exec"}),
|
||||
_tool("read_file", {"read", "fs"}),
|
||||
]
|
||||
out = _apply_declared_tool_policy(
|
||||
tools,
|
||||
allow_tags=["read"],
|
||||
allow_tools=["run_command"],
|
||||
)
|
||||
names = {t.name for t in out}
|
||||
assert "search_workspace" in names
|
||||
assert "read_file" in names
|
||||
assert "run_command" in names
|
||||
|
||||
|
||||
def test_declared_tool_policy_empty_rules_returns_original() -> None:
|
||||
tools = [_tool("a", {"x"}), _tool("b", {"y"})]
|
||||
out = _apply_declared_tool_policy(tools, allow_tags=[], allow_tools=[])
|
||||
assert [t.name for t in out] == ["a", "b"]
|
||||
|
||||
211
tests/test_openclaw_extensions_webhooks_py.py
Normal file
211
tests/test_openclaw_extensions_webhooks_py.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module(module_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load module: {module_name} from {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _mount_webhooks_package():
|
||||
root = Path(__file__).resolve().parents[1] / "extensions" / "webhooks"
|
||||
pkg_root = types.ModuleType("ocx")
|
||||
pkg_root.__path__ = [str(root.parent)]
|
||||
sys.modules["ocx"] = pkg_root
|
||||
pkg = types.ModuleType("ocx.webhooks")
|
||||
pkg.__path__ = [str(root)]
|
||||
sys.modules["ocx.webhooks"] = pkg
|
||||
|
||||
runtime_api = _load_module("ocx.webhooks.runtime_api", root / "runtime_api.py")
|
||||
config = _load_module("ocx.webhooks.config", root / "config.py")
|
||||
http = _load_module("ocx.webhooks.http", root / "http.py")
|
||||
return runtime_api, config, http
|
||||
|
||||
|
||||
def test_webhooks_config_resolve_and_conflict() -> None:
|
||||
_, config, _ = _mount_webhooks_package()
|
||||
rows = config.resolve_webhooks_plugin_config(
|
||||
plugin_config={
|
||||
"routes": {
|
||||
"r1": {
|
||||
"sessionKey": "s1",
|
||||
"secret": "abc",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].path == "/plugins/webhooks/r1"
|
||||
assert rows[0].controller_id == "webhooks/r1"
|
||||
|
||||
try:
|
||||
config.resolve_webhooks_plugin_config(
|
||||
plugin_config={
|
||||
"routes": {
|
||||
"a": {"path": "/x", "sessionKey": "s1", "secret": "k1"},
|
||||
"b": {"path": "/x", "sessionKey": "s2", "secret": "k2"},
|
||||
}
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "conflicts" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected conflict error")
|
||||
|
||||
|
||||
class _TaskFlow:
|
||||
def create_managed(self, **kwargs):
|
||||
return {"flowId": "f1", "goal": kwargs.get("goal"), "revision": 0, "status": "queued", "createdAt": 1, "updatedAt": 1}
|
||||
|
||||
def get(self, flow_id: str):
|
||||
return {"flowId": flow_id, "revision": 1, "status": "running", "createdAt": 1, "updatedAt": 2}
|
||||
|
||||
def list(self):
|
||||
return [self.get("f1")]
|
||||
|
||||
def request_cancel(self, **kwargs):
|
||||
if kwargs.get("flow_id") == "missing":
|
||||
return {"applied": False, "code": "not_found"}
|
||||
return {"applied": True, "flow": self.get(kwargs.get("flow_id", "f1"))}
|
||||
|
||||
def resume(self, **kwargs):
|
||||
flow_id = kwargs.get("flow_id")
|
||||
if flow_id == "conflict":
|
||||
return {"applied": False, "code": "revision_conflict", "current": self.get("f1")}
|
||||
return {"applied": True, "flow": self.get(flow_id or "f1")}
|
||||
|
||||
def finish(self, **kwargs):
|
||||
return {"applied": True, "flow": self.get(kwargs.get("flow_id", "f1"))}
|
||||
|
||||
def fail(self, **kwargs):
|
||||
return {"applied": True, "flow": self.get(kwargs.get("flow_id", "f1"))}
|
||||
|
||||
def set_waiting(self, **kwargs):
|
||||
return {"applied": True, "flow": self.get(kwargs.get("flow_id", "f1"))}
|
||||
|
||||
def cancel(self, **kwargs):
|
||||
flow_id = kwargs.get("flow_id")
|
||||
if flow_id == "pending":
|
||||
return {"found": True, "cancelled": False, "reason": "One or more child tasks are still active."}
|
||||
return {"found": True, "cancelled": True, "flow": self.get(flow_id or "f1"), "tasks": []}
|
||||
|
||||
def run_task(self, **kwargs):
|
||||
flow_id = kwargs.get("flow_id")
|
||||
if flow_id == "missing":
|
||||
return {"created": False, "found": False, "reason": "not found"}
|
||||
if flow_id == "terminal":
|
||||
return {"created": False, "found": True, "reason": "Flow is already completed."}
|
||||
return {
|
||||
"created": True,
|
||||
"flow": self.get(flow_id or "f1"),
|
||||
"task": {
|
||||
"taskId": "t1",
|
||||
"runtime": kwargs.get("runtime", "subagent"),
|
||||
"scopeKind": "flow",
|
||||
"task": kwargs.get("task", ""),
|
||||
"status": "queued",
|
||||
"deliveryStatus": "queued",
|
||||
"notifyPolicy": "done_only",
|
||||
"createdAt": 1,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_webhooks_handler_auth_and_actions() -> None:
|
||||
_, _, http = _mount_webhooks_package()
|
||||
target = http.TaskFlowWebhookTarget(
|
||||
route_id="r1",
|
||||
path="/hooks/a",
|
||||
secret_input="sekret",
|
||||
secret_config_path="plugins.entries.webhooks.routes.r1.secret",
|
||||
default_controller_id="webhooks/r1",
|
||||
task_flow=_TaskFlow(),
|
||||
)
|
||||
handler = http.create_taskflow_webhook_request_handler(cfg={}, targets_by_path={"/hooks/a": [target]})
|
||||
|
||||
unauthorized = handler({"path": "/hooks/a", "headers": {}, "json": {"action": "list_flows"}})
|
||||
assert unauthorized["ok"] is False
|
||||
assert unauthorized["code"] == "unauthorized"
|
||||
|
||||
created = handler(
|
||||
{
|
||||
"path": "/hooks/a",
|
||||
"headers": {"authorization": "Bearer sekret"},
|
||||
"json": {"action": "create_flow", "goal": "ship it"},
|
||||
}
|
||||
)
|
||||
assert created["ok"] is True
|
||||
assert created["statusCode"] == 200
|
||||
assert created["result"]["flow"]["flowId"] == "f1"
|
||||
|
||||
missing = handler(
|
||||
{
|
||||
"path": "/hooks/a",
|
||||
"headers": {"x-openclaw-webhook-secret": "sekret"},
|
||||
"json": {"action": "request_cancel", "flowId": "missing", "expectedRevision": 1},
|
||||
}
|
||||
)
|
||||
assert missing["ok"] is False
|
||||
assert missing["statusCode"] == 404
|
||||
assert missing["code"] == "not_found"
|
||||
|
||||
|
||||
def test_webhooks_handler_action_status_mappings() -> None:
|
||||
_, _, http = _mount_webhooks_package()
|
||||
target = http.TaskFlowWebhookTarget(
|
||||
route_id="r2",
|
||||
path="/hooks/b",
|
||||
secret_input="sekret2",
|
||||
secret_config_path="plugins.entries.webhooks.routes.r2.secret",
|
||||
default_controller_id="webhooks/r2",
|
||||
task_flow=_TaskFlow(),
|
||||
)
|
||||
handler = http.create_taskflow_webhook_request_handler(cfg={}, targets_by_path={"/hooks/b": [target]})
|
||||
|
||||
def _call(payload: dict):
|
||||
return handler(
|
||||
{
|
||||
"path": "/hooks/b",
|
||||
"headers": {"authorization": "Bearer sekret2"},
|
||||
"json": payload,
|
||||
}
|
||||
)
|
||||
|
||||
resume_conflict = _call({"action": "resume_flow", "flowId": "conflict", "expectedRevision": 1})
|
||||
assert resume_conflict["ok"] is False
|
||||
assert resume_conflict["statusCode"] == 409
|
||||
assert resume_conflict["code"] == "revision_conflict"
|
||||
|
||||
cancel_pending = _call({"action": "cancel_flow", "flowId": "pending"})
|
||||
assert cancel_pending["ok"] is True
|
||||
assert cancel_pending["statusCode"] == 202
|
||||
assert cancel_pending["code"] == "cancel_pending"
|
||||
|
||||
run_task_missing = _call(
|
||||
{"action": "run_task", "flowId": "missing", "runtime": "subagent", "task": "ping"}
|
||||
)
|
||||
assert run_task_missing["ok"] is False
|
||||
assert run_task_missing["statusCode"] == 404
|
||||
assert run_task_missing["code"] == "not_found"
|
||||
|
||||
run_task_terminal = _call(
|
||||
{"action": "run_task", "flowId": "terminal", "runtime": "subagent", "task": "ping"}
|
||||
)
|
||||
assert run_task_terminal["ok"] is False
|
||||
assert run_task_terminal["statusCode"] == 409
|
||||
assert run_task_terminal["code"] == "terminal"
|
||||
|
||||
run_task_ok = _call({"action": "run_task", "flowId": "f1", "runtime": "subagent", "task": "ping"})
|
||||
assert run_task_ok["ok"] is True
|
||||
assert run_task_ok["statusCode"] == 200
|
||||
assert run_task_ok["result"]["created"] is True
|
||||
|
||||
65
tests/test_openclaw_extensions_whatsapp_py.py
Normal file
65
tests/test_openclaw_extensions_whatsapp_py.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_module(module_name: str, path: Path):
|
||||
spec = importlib.util.spec_from_file_location(module_name, str(path))
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"cannot load module: {module_name} from {path}")
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _mount_whatsapp_module():
|
||||
root = Path(__file__).resolve().parents[1] / "extensions" / "whatsapp"
|
||||
pkg_root = types.ModuleType("ocxw")
|
||||
pkg_root.__path__ = [str(root.parent)]
|
||||
sys.modules["ocxw"] = pkg_root
|
||||
pkg = types.ModuleType("ocxw.whatsapp")
|
||||
pkg.__path__ = [str(root)]
|
||||
sys.modules["ocxw.whatsapp"] = pkg
|
||||
return _load_module("ocxw.whatsapp.api", root / "api.py")
|
||||
|
||||
|
||||
def test_whatsapp_target_predicates() -> None:
|
||||
api = _mount_whatsapp_module()
|
||||
assert api.is_whatsapp_group_jid("12345@g.us") is True
|
||||
assert api.is_whatsapp_group_jid("12345@s.whatsapp.net") is False
|
||||
|
||||
assert api.is_whatsapp_user_target("13800138000") is True
|
||||
assert api.is_whatsapp_user_target("+8613800138000") is True
|
||||
assert api.is_whatsapp_user_target("13800138000@s.whatsapp.net") is True
|
||||
assert api.is_whatsapp_user_target("bad_target") is False
|
||||
|
||||
assert api.looks_like_whatsapp_target_id("12345@g.us") is True
|
||||
assert api.looks_like_whatsapp_target_id("13800138000") is True
|
||||
assert api.looks_like_whatsapp_target_id("not-a-target") is False
|
||||
|
||||
|
||||
def test_whatsapp_target_normalization() -> None:
|
||||
api = _mount_whatsapp_module()
|
||||
assert api.normalize_whatsapp_target("12345@g.us") == "12345@g.us"
|
||||
assert api.normalize_whatsapp_target("+86 138-0013-8000") == "8613800138000@s.whatsapp.net"
|
||||
assert api.normalize_whatsapp_target("13800138000@s.whatsapp.net") == "13800138000@s.whatsapp.net"
|
||||
|
||||
try:
|
||||
api.normalize_whatsapp_target("")
|
||||
except ValueError as exc:
|
||||
assert "required" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ValueError for empty target")
|
||||
|
||||
|
||||
def test_whatsapp_allow_from_entries_normalize_and_deduplicate() -> None:
|
||||
api = _mount_whatsapp_module()
|
||||
out = api.normalize_whatsapp_allow_from_entries(
|
||||
["+86 13800138000", "13800138000@s.whatsapp.net", "12345@g.us", "bad@@@", "12345@g.us"]
|
||||
)
|
||||
assert out == ("12345@g.us", "13800138000@s.whatsapp.net", "8613800138000@s.whatsapp.net")
|
||||
|
||||
429
tests/test_openclaw_gateway_trace.py
Normal file
429
tests/test_openclaw_gateway_trace.py
Normal file
|
|
@ -0,0 +1,429 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from oclaw.platform.llm.chat_models import LLMResponse
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGateway
|
||||
from oclaw.openclaw_runtime.types import StandardMessage
|
||||
|
||||
|
||||
def test_gateway_async_trace_payload_has_pipeline_and_oc_stage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
events: list[dict] = []
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **kwargs: object) -> None:
|
||||
events.append(dict(kwargs))
|
||||
|
||||
def openclaw_task_create(self, **_kwargs: object) -> object:
|
||||
class T:
|
||||
id = "task-1"
|
||||
task_type = "async_turn"
|
||||
status = "queued"
|
||||
|
||||
return T()
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.ensure_worker_started", lambda store: "worker-1")
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="please send to channel after summarize",
|
||||
attachments=[],
|
||||
metadata={},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="zh", executor=object())
|
||||
assert out.task_id == "task-1"
|
||||
sent = [e for e in events if e.get("event_type") == "response_sent"]
|
||||
assert sent
|
||||
payload = sent[-1].get("payload") or {}
|
||||
assert payload.get("pipeline") == "openclaw_gateway"
|
||||
assert payload.get("oc_stage") == "response"
|
||||
assert "elapsed_ms_since_gateway_start" in payload
|
||||
|
||||
|
||||
def test_gateway_received_trace_includes_relay_pointer_stats(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
events: list[dict] = []
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **kwargs: object) -> None:
|
||||
events.append(dict(kwargs))
|
||||
|
||||
def openclaw_task_create(self, **_kwargs: object) -> object:
|
||||
class T:
|
||||
id = "task-2"
|
||||
task_type = "async_turn"
|
||||
status = "queued"
|
||||
|
||||
return T()
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.ensure_worker_started", lambda store: "worker-1")
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-2",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="async with relay",
|
||||
attachments=[{"type": "relay_pointer", "pointer_uri": "relay://attachments/scope_1/abcdef123456"}],
|
||||
metadata={
|
||||
"relay_share_envelope": {
|
||||
"schema_version": "v1",
|
||||
"attachments": {"pointers": [{"pointer_uri": "relay://attachments/scope_1/abcdef123456"}]},
|
||||
}
|
||||
},
|
||||
)
|
||||
_ = gw.handle_turn(msg=msg, lang="zh", executor=object())
|
||||
rec = [e for e in events if e.get("event_type") == "gateway_received"]
|
||||
assert rec
|
||||
payload = rec[-1].get("payload") or {}
|
||||
assert payload.get("relay_pointer_count") == 1
|
||||
assert payload.get("relay_envelope_present") is True
|
||||
assert payload.get("relay_envelope_pointer_count") == 1
|
||||
|
||||
|
||||
def test_gateway_async_task_payload_preserves_relay_envelope(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def openclaw_task_create(self, **kwargs: object) -> object:
|
||||
captured.update(kwargs)
|
||||
|
||||
class T:
|
||||
id = "task-3"
|
||||
task_type = "async_turn"
|
||||
status = "queued"
|
||||
|
||||
return T()
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.ensure_worker_started", lambda store: "worker-1")
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-3",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="please summarize and schedule follow-up tasks with attachment context " * 3,
|
||||
attachments=[{"type": "relay_pointer", "pointer_uri": "relay://attachments/scope_1/abcdef123456"}],
|
||||
metadata={
|
||||
"relay_share_envelope": {
|
||||
"schema_version": "v1",
|
||||
"attachments": {"pointers": [{"pointer_uri": "relay://attachments/scope_1/abcdef123456"}]},
|
||||
}
|
||||
},
|
||||
)
|
||||
_ = gw.handle_turn(msg=msg, lang="zh", executor=object())
|
||||
payload = captured.get("payload") or {}
|
||||
assert isinstance(payload, dict)
|
||||
assert payload.get("relay_pointer_count") == 1
|
||||
env = payload.get("relay_share_envelope") or {}
|
||||
assert isinstance(env, dict)
|
||||
assert env.get("schema_version") == "v1"
|
||||
|
||||
|
||||
def test_gateway_async_task_payload_preserves_acp_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def openclaw_task_create(self, **kwargs: object) -> object:
|
||||
captured.update(kwargs)
|
||||
|
||||
class T:
|
||||
id = "task-4"
|
||||
task_type = "async_turn"
|
||||
status = "queued"
|
||||
|
||||
return T()
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.ensure_worker_started", lambda store: "worker-1")
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-4",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="please summarize and schedule follow-up tasks with attachment context " * 3,
|
||||
attachments=[{"type": "relay_pointer", "pointer_uri": "relay://attachments/scope_1/abcdef123456"}],
|
||||
metadata={"acp_parent_run_id": "parent-1", "acp_child_run_id": "child-1"},
|
||||
)
|
||||
_ = gw.handle_turn(msg=msg, lang="zh", executor=object())
|
||||
payload = captured.get("payload") or {}
|
||||
assert payload.get("acp_parent_run_id") == "parent-1"
|
||||
assert payload.get("acp_child_run_id") == "child-1"
|
||||
|
||||
|
||||
def test_gateway_expert_mode_uses_requested_specialist() -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
class _Exec:
|
||||
model = None
|
||||
tools = None
|
||||
|
||||
chosen: dict[str, str] = {}
|
||||
|
||||
def _factory(sid: str) -> object:
|
||||
chosen["sid"] = sid
|
||||
return _Exec()
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-e1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="hello",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "expert", "selected_specialist": "ops"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="en", executor=_Exec(), specialist_executor_factory=_factory)
|
||||
assert out.interaction_mode == "expert"
|
||||
assert out.selected_specialist == "ops"
|
||||
assert chosen.get("sid") == "ops"
|
||||
|
||||
|
||||
def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
class _ManagerModel:
|
||||
def chat(self, _messages, _tools, *, on_token=None):
|
||||
return LLMResponse(content='{"route":{"specialist":"image","reason":"needs image edits"}}', tool_calls=[])
|
||||
|
||||
class _Exec:
|
||||
def __init__(self, model=None):
|
||||
self.model = model
|
||||
self.tools = None
|
||||
|
||||
chosen: dict[str, str] = {}
|
||||
|
||||
def _factory(sid: str) -> object:
|
||||
chosen["sid"] = sid
|
||||
return _Exec()
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-c1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="edit this image background",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "comprehensive", "selected_specialist": "ops"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="en", executor=_Exec(model=_ManagerModel()), specialist_executor_factory=_factory)
|
||||
assert out.interaction_mode == "comprehensive"
|
||||
assert out.selected_specialist == "image"
|
||||
assert chosen.get("sid") == "image"
|
||||
|
||||
|
||||
def test_gateway_comprehensive_mode_dynamic_agent_fallback_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
class _ManagerModel:
|
||||
def chat(self, _messages, _tools, *, on_token=None):
|
||||
return LLMResponse(
|
||||
content=(
|
||||
'{"route":{"specialist":"finance","reason":"requires finance domain"},'
|
||||
'"dynamic_agent":{"name":"finance","system_prompt":"You are a finance specialist.","tool_policy":{"allow_tags":["read"],"allow_tools":["read_file"]},"reason":"dynamic_finance"}}'
|
||||
),
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
class _Exec:
|
||||
def __init__(self, model=None):
|
||||
self.model = model
|
||||
self.tools = None
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.build_ephemeral_executor", lambda *args, **kwargs: _Exec())
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-d1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="analyze finance report",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "comprehensive", "selected_specialist": "ops"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="en", executor=_Exec(model=_ManagerModel()))
|
||||
assert out.interaction_mode == "comprehensive"
|
||||
assert out.selected_specialist == "finance"
|
||||
|
||||
|
||||
def test_gateway_comprehensive_mode_dynamic_agent_invalid_falls_back_generalist() -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
class _ManagerModel:
|
||||
def chat(self, _messages, _tools, *, on_token=None):
|
||||
return LLMResponse(
|
||||
content=(
|
||||
'{"route":{"specialist":"finance","reason":"requires finance domain"},'
|
||||
'"dynamic_agent":{"name":"finance","system_prompt":"<tool_call>bad</tool_call>","tool_policy":{"allow_tags":["read"]},"reason":"bad_prompt"}}'
|
||||
),
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
class _Exec:
|
||||
def __init__(self, model=None):
|
||||
self.model = model
|
||||
self.tools = None
|
||||
|
||||
picked: dict[str, str] = {}
|
||||
|
||||
def _factory(sid: str) -> object:
|
||||
picked["sid"] = sid
|
||||
return _Exec()
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-d2",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="analyze finance report",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "comprehensive", "selected_specialist": "ops"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="en", executor=_Exec(model=_ManagerModel()), specialist_executor_factory=_factory)
|
||||
assert out.interaction_mode == "comprehensive"
|
||||
assert out.selected_specialist == "generalist"
|
||||
assert picked.get("sid") == "generalist"
|
||||
|
||||
|
||||
def test_gateway_command_hook_uses_parsed_command_and_context(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict] = []
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def openclaw_task_create(self, **_kwargs: object) -> object:
|
||||
class T:
|
||||
id = "task-cmd-1"
|
||||
task_type = "async_turn"
|
||||
status = "queued"
|
||||
|
||||
return T()
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.ensure_worker_started", lambda store: "worker-1")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.initialize_hooks_runtime", lambda **kwargs: 0)
|
||||
monkeypatch.setattr(
|
||||
"oclaw.openclaw_runtime.gateway.get_active_hooks_config",
|
||||
lambda: {"hooks": {"internal": {"enabled": True, "entries": {"session-memory": {"messages": 33}}}}},
|
||||
)
|
||||
|
||||
def _capture(**kwargs):
|
||||
calls.append(dict(kwargs))
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.gateway.trigger_hook_event", _capture)
|
||||
|
||||
gw = OpenClawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-cmd-1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="/重置 now",
|
||||
attachments=[],
|
||||
metadata={"workspace_dir": "D:/ws", "source": "webchat"},
|
||||
)
|
||||
_ = gw.handle_turn(msg=msg, lang="zh", executor=object())
|
||||
|
||||
assert calls
|
||||
command_events = [c for c in calls if c.get("event_type") == "command" and c.get("action") == "reset"]
|
||||
assert command_events
|
||||
event = command_events[-1]
|
||||
assert event.get("session_key") == "sid-cmd-1"
|
||||
ctx = event.get("context") or {}
|
||||
assert ctx.get("workspaceDir") == "D:/ws"
|
||||
assert ctx.get("commandSource") == "webchat"
|
||||
cfg = ctx.get("cfg") or {}
|
||||
assert ((cfg.get("hooks") or {}).get("internal") or {}).get("entries", {}).get("session-memory", {}).get("messages") == 33
|
||||
sess = ctx.get("sessionEntry") or {}
|
||||
assert sess.get("sessionId") == "sid-cmd-1"
|
||||
|
||||
|
||||
def test_tabular_system_hint_uses_configured_preview_and_rows_read(
|
||||
tmp_path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
cfg = {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"auto": {
|
||||
"attachments": {
|
||||
"tabular": {
|
||||
"large_table_preview_rows": 30,
|
||||
"max_rows_read": 5000,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cfg_path = tmp_path / "oclaw.json"
|
||||
cfg_path.write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8")
|
||||
monkeypatch.setenv("AIA_OCLAW_CONFIG_PATH", str(cfg_path))
|
||||
|
||||
zh_hint = OpenClawGateway._tabular_query_system_hint("zh")
|
||||
en_hint = OpenClawGateway._tabular_query_system_hint("en")
|
||||
|
||||
assert "前30行" in zh_hint
|
||||
assert "5000行" in zh_hint
|
||||
assert "first 30 preview rows" in en_hint
|
||||
assert "capped at 5000 rows" in en_hint
|
||||
28
tests/test_openclaw_generalist_executor.py
Normal file
28
tests/test_openclaw_generalist_executor.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.agents.factory import build_gateway_executor
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_build_gateway_executor_defaults_to_generalist(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
ex = build_gateway_executor(store, lang="zh")
|
||||
assert type(ex).__name__ == "Agent"
|
||||
assert hasattr(ex, "tools")
|
||||
assert hasattr(ex, "model")
|
||||
|
||||
|
||||
def test_build_gateway_executor_generalist_run_command_is_disabled_by_default(tmp_path: Path) -> None:
|
||||
os.environ.pop("AIA_ENABLE_RUN_COMMAND", None)
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
ex = build_gateway_executor(store, lang="zh", specialist="generalist")
|
||||
run_tool = next((t for t in ex.tools.list() if t.name == "run_command"), None)
|
||||
assert run_tool is not None
|
||||
out = run_tool.handler({"command": "echo hi"})
|
||||
assert isinstance(out, dict)
|
||||
assert out.get("ok") is False
|
||||
assert out.get("error") == "disabled"
|
||||
|
||||
34
tests/test_openclaw_hooks_runtime.py
Normal file
34
tests/test_openclaw_hooks_runtime.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.hooks_runtime import resolve_runtime_config
|
||||
|
||||
|
||||
def test_resolve_runtime_config_from_env_json(monkeypatch) -> None:
|
||||
monkeypatch.setenv(
|
||||
"OPENCLAW_RUNTIME_CONFIG_JSON",
|
||||
json.dumps({"hooks": {"internal": {"enabled": False, "entries": {"x": {"enabled": False}}}}}),
|
||||
)
|
||||
monkeypatch.delenv("OPENCLAW_CONFIG_PATH", raising=False)
|
||||
cfg = resolve_runtime_config()
|
||||
assert ((cfg.get("hooks") or {}).get("internal") or {}).get("enabled") is False
|
||||
assert ((cfg.get("hooks") or {}).get("internal") or {}).get("entries", {}).get("x", {}).get("enabled") is False
|
||||
|
||||
|
||||
def test_resolve_runtime_config_from_config_path(monkeypatch, tmp_path: Path) -> None:
|
||||
cfg_path = tmp_path / "openclaw.custom.json"
|
||||
cfg_path.write_text(
|
||||
json.dumps({"hooks": {"internal": {"enabled": True, "entries": {"session-memory": {"messages": 99}}}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.delenv("OPENCLAW_RUNTIME_CONFIG_JSON", raising=False)
|
||||
monkeypatch.setenv("OPENCLAW_CONFIG_PATH", str(cfg_path))
|
||||
cfg = resolve_runtime_config()
|
||||
assert ((cfg.get("hooks") or {}).get("internal") or {}).get("enabled") is True
|
||||
assert (
|
||||
((cfg.get("hooks") or {}).get("internal") or {}).get("entries", {}).get("session-memory", {}).get("messages")
|
||||
== 99
|
||||
)
|
||||
|
||||
46
tests/test_openclaw_memory_stage.py
Normal file
46
tests/test_openclaw_memory_stage.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.openclaw_runtime.memory_stage import after_turn_memory, render_memory_context_block
|
||||
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_render_memory_context_block_contains_sections() -> None:
|
||||
text = render_memory_context_block(
|
||||
OpenClawMemoryContext(
|
||||
short_term=("user prefers concise summary",),
|
||||
semantic_hits=({"content": "meeting summary style: bullets only", "score": 0.77},),
|
||||
)
|
||||
)
|
||||
assert "[short_term_digest]" in text
|
||||
assert "[semantic_memory_hits]" in text
|
||||
assert "0.7700" in text
|
||||
|
||||
|
||||
def test_after_turn_memory_invokes_maybe_write_turn_memory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
calls: list[dict] = []
|
||||
|
||||
def _fake_maybe_write(store: SqliteStore, **kwargs: object) -> dict:
|
||||
calls.append({"store": store, **kwargs})
|
||||
return {"ok": True, "written": 0, "reason": "stub"}
|
||||
|
||||
monkeypatch.setattr("oclaw.orchestration.memory.maybe_write_turn_memory", _fake_maybe_write)
|
||||
|
||||
store = SqliteStore(str(tmp_path / "m.sqlite"))
|
||||
store.create_session("s1")
|
||||
after_turn_memory(
|
||||
store=store,
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
user_text="hello",
|
||||
assistant_text="world",
|
||||
)
|
||||
assert len(calls) == 1
|
||||
assert calls[0]["tenant_id"] == "t1"
|
||||
assert calls[0]["user_id"] == "u1"
|
||||
|
||||
121
tests/test_openclaw_project_context_prompt.py
Normal file
121
tests/test_openclaw_project_context_prompt.py
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.project_context_prompt import build_project_context_block
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self, values: dict[str, str] | None = None) -> None:
|
||||
self._v = dict(values or {})
|
||||
|
||||
def get_setting(self, key: str) -> str:
|
||||
return str(self._v.get(key) or "")
|
||||
|
||||
|
||||
def test_build_project_context_block_reads_workspace_bootstrap(monkeypatch, tmp_path: Path) -> None:
|
||||
ws = tmp_path / "oclaw" / "agent" / "workspace-main"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
(ws / "AGENTS.md").write_text("agents rules", encoding="utf-8")
|
||||
(ws / "SOUL.md").write_text("soul voice", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
out = build_project_context_block(store=_Store())
|
||||
assert "[project_context]" in out
|
||||
assert "[AGENTS.md]" in out
|
||||
assert "[SOUL.md]" in out
|
||||
|
||||
|
||||
def test_build_project_context_block_empty_when_no_files(monkeypatch, tmp_path: Path) -> None:
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
out = build_project_context_block(store=_Store())
|
||||
assert out == ""
|
||||
|
||||
|
||||
def test_build_project_context_block_fallback_to_project_root(monkeypatch, tmp_path: Path) -> None:
|
||||
(tmp_path / "AGENTS.md").write_text("legacy root agents", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
out = build_project_context_block(store=_Store())
|
||||
assert "[AGENTS.md]" in out
|
||||
assert "legacy root agents" in out
|
||||
|
||||
|
||||
def test_build_project_context_block_legacy_workspace_fallback(monkeypatch, tmp_path: Path) -> None:
|
||||
ws = tmp_path / "oclaw" / "workspace"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
(ws / "AGENTS.md").write_text("legacy workspace agents", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
out = build_project_context_block(store=_Store())
|
||||
assert "[AGENTS.md]" in out
|
||||
assert "legacy workspace agents" in out
|
||||
|
||||
|
||||
def test_build_project_context_block_workspace_main_fallback(monkeypatch, tmp_path: Path) -> None:
|
||||
ws = tmp_path / "oclaw" / "workspace-main"
|
||||
ws.mkdir(parents=True, exist_ok=True)
|
||||
(ws / "AGENTS.md").write_text("legacy workspace-main agents", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
out = build_project_context_block(store=_Store())
|
||||
assert "[AGENTS.md]" in out
|
||||
assert "legacy workspace-main agents" in out
|
||||
|
||||
|
||||
def test_build_project_context_block_triggers_bootstrap_for_each_root(monkeypatch, tmp_path: Path) -> None:
|
||||
ws_agent = tmp_path / "oclaw" / "agent" / "workspace-main"
|
||||
ws_main = tmp_path / "oclaw" / "workspace-main"
|
||||
ws_agent.mkdir(parents=True, exist_ok=True)
|
||||
ws_main.mkdir(parents=True, exist_ok=True)
|
||||
(ws_agent / "AGENTS.md").write_text("agent root", encoding="utf-8")
|
||||
(ws_main / "AGENTS.md").write_text("main root", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def _capture(**kwargs):
|
||||
calls.append(dict(kwargs))
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.trigger_hook_event", _capture)
|
||||
monkeypatch.setattr(
|
||||
"oclaw.openclaw_runtime.project_context_prompt.get_active_hooks_config",
|
||||
lambda: {"hooks": {"internal": {"enabled": True}}},
|
||||
)
|
||||
_ = build_project_context_block(store=_Store())
|
||||
|
||||
workspaces = [str((c.get("context") or {}).get("workspaceDir") or "") for c in calls]
|
||||
norm = {w.replace("\\", "/") for w in workspaces}
|
||||
assert str(ws_agent).replace("\\", "/") in norm
|
||||
assert str(ws_main).replace("\\", "/") in norm
|
||||
|
||||
|
||||
def test_project_context_bootstrap_includes_agent_id_when_config_matches(monkeypatch, tmp_path: Path) -> None:
|
||||
ws_agent = tmp_path / "oclaw" / "agent" / "workspace-main"
|
||||
ws_social = tmp_path / "oclaw" / "workspace-social"
|
||||
ws_agent.mkdir(parents=True, exist_ok=True)
|
||||
ws_social.mkdir(parents=True, exist_ok=True)
|
||||
(ws_agent / "AGENTS.md").write_text("agent root", encoding="utf-8")
|
||||
(ws_social / "AGENTS.md").write_text("social root", encoding="utf-8")
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.PROJECT_ROOT", tmp_path)
|
||||
|
||||
calls: list[dict] = []
|
||||
|
||||
def _capture(**kwargs):
|
||||
calls.append(dict(kwargs))
|
||||
|
||||
monkeypatch.setattr("oclaw.openclaw_runtime.project_context_prompt.trigger_hook_event", _capture)
|
||||
monkeypatch.setattr(
|
||||
"oclaw.openclaw_runtime.project_context_prompt.get_active_hooks_config",
|
||||
lambda: {
|
||||
"hooks": {"internal": {"enabled": True}},
|
||||
"agents": {
|
||||
"list": [
|
||||
{"id": "main", "default": True, "workspace": str(ws_agent)},
|
||||
{"id": "social", "workspace": str(ws_social)},
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
_ = build_project_context_block(store=_Store())
|
||||
|
||||
contexts = [c.get("context") or {} for c in calls]
|
||||
got = {(str(ctx.get("workspaceDir") or ""), str(ctx.get("agentId") or "")) for ctx in contexts}
|
||||
assert (str(ws_agent), "main") in got
|
||||
assert (str(ws_social), "social") in got
|
||||
66
tests/test_openclaw_relay_pointer_protocol.py
Normal file
66
tests/test_openclaw_relay_pointer_protocol.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.openclaw_runtime.relay_pointer import build_manifest_from_attachment_refs
|
||||
from oclaw.openclaw_runtime.types import RelayShareEnvelope, SharedAttachmentManifest, SharedFilePointer
|
||||
|
||||
|
||||
def test_shared_pointer_roundtrip_with_extra_fields() -> None:
|
||||
raw = {
|
||||
"schema_version": "v1",
|
||||
"pointer_uri": "relay://attachments/scope-1/abcdef1234",
|
||||
"rel_path": "attachments/a.txt",
|
||||
"mime_type": "text/plain",
|
||||
"bytes": 12,
|
||||
"sha256": "x" * 64,
|
||||
"source_agent": "generalist",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"ttl_policy": "session",
|
||||
"custom_k": "custom_v",
|
||||
}
|
||||
p = SharedFilePointer.from_dict(raw)
|
||||
out = p.to_dict()
|
||||
assert out["pointer_uri"] == raw["pointer_uri"]
|
||||
assert out["ttl_policy"] == "session"
|
||||
assert out["custom_k"] == "custom_v"
|
||||
|
||||
|
||||
def test_relay_share_envelope_roundtrip() -> None:
|
||||
p = SharedFilePointer(
|
||||
pointer_uri="relay://attachments/scope-2/abcdef1234",
|
||||
rel_path="attachments/x.bin",
|
||||
mime_type="application/octet-stream",
|
||||
bytes=7,
|
||||
sha256="f" * 64,
|
||||
source_agent="ops",
|
||||
ttl_policy="turn",
|
||||
)
|
||||
m = SharedAttachmentManifest(scope_id="scope-2", count=1, total_bytes=7, pointers=(p,))
|
||||
e = RelayShareEnvelope(schema_version="v1", trace_id="t1", run_id="r1", attempt_no=1, attachments=m)
|
||||
restored = RelayShareEnvelope.from_dict(e.to_dict())
|
||||
assert restored.schema_version == "v1"
|
||||
assert restored.trace_id == "t1"
|
||||
assert restored.attachments.scope_id == "scope-2"
|
||||
assert len(restored.attachments.pointers) == 1
|
||||
assert restored.attachments.pointers[0].pointer_uri.endswith("/abcdef1234")
|
||||
|
||||
|
||||
def test_build_manifest_from_attachment_refs() -> None:
|
||||
manifest = build_manifest_from_attachment_refs(
|
||||
[
|
||||
{
|
||||
"type": "image_ref",
|
||||
"attachment_id": "abcdef123456",
|
||||
"name": "demo.png",
|
||||
"mime": "image/png",
|
||||
"bytes": 123,
|
||||
}
|
||||
],
|
||||
scope_id="scope_a",
|
||||
source_agent="generalist",
|
||||
)
|
||||
assert manifest.scope_id == "scope_a"
|
||||
assert manifest.count == 1
|
||||
assert manifest.total_bytes == 123
|
||||
ptr = manifest.pointers[0]
|
||||
assert ptr.pointer_uri == "relay://attachments/scope_a/abcdef123456"
|
||||
assert ptr.extra.get("name") == "demo.png"
|
||||
53
tests/test_openclaw_relay_pointer_security.py
Normal file
53
tests/test_openclaw_relay_pointer_security.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.openclaw_runtime.relay_pointer import (
|
||||
RelayPointerError,
|
||||
build_pointer_uri,
|
||||
decode_base64_payload,
|
||||
normalize_rel_path,
|
||||
parse_pointer_uri,
|
||||
safe_join_under_root,
|
||||
sha256_hex,
|
||||
validate_file_name,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_file_name_rejects_path_injection() -> None:
|
||||
with pytest.raises(RelayPointerError):
|
||||
validate_file_name("../a.txt")
|
||||
with pytest.raises(RelayPointerError):
|
||||
validate_file_name("a/b.txt")
|
||||
|
||||
|
||||
def test_normalize_rel_path_rejects_traversal() -> None:
|
||||
with pytest.raises(RelayPointerError):
|
||||
normalize_rel_path("../../etc/passwd")
|
||||
|
||||
|
||||
def test_safe_join_under_root_stays_inside(tmp_path: Path) -> None:
|
||||
p = safe_join_under_root(tmp_path, "a/b.txt")
|
||||
assert str(p).startswith(str(tmp_path.resolve()))
|
||||
|
||||
|
||||
def test_decode_base64_payload_respects_size_limit() -> None:
|
||||
raw = base64.b64encode(b"hello").decode("ascii")
|
||||
assert decode_base64_payload(raw, max_bytes=5) == b"hello"
|
||||
with pytest.raises(RelayPointerError):
|
||||
decode_base64_payload(raw, max_bytes=4)
|
||||
|
||||
|
||||
def test_pointer_uri_build_and_parse_roundtrip() -> None:
|
||||
uri = build_pointer_uri("scope_1", "abcdef123456")
|
||||
assert uri == "relay://attachments/scope_1/abcdef123456"
|
||||
scope, file_id = parse_pointer_uri(uri)
|
||||
assert scope == "scope_1"
|
||||
assert file_id == "abcdef123456"
|
||||
|
||||
|
||||
def test_sha256_hex_is_stable() -> None:
|
||||
assert sha256_hex(b"abc") == "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
|
||||
12
tests/test_openclaw_role_prompt_paths.py
Normal file
12
tests/test_openclaw_role_prompt_paths.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.agents.specialists import default_system_prefix_for_specialist
|
||||
|
||||
|
||||
def test_specialist_prompts_loaded_from_openclaw_tree() -> None:
|
||||
ops = default_system_prefix_for_specialist("ops", "zh")
|
||||
gen = default_system_prefix_for_specialist("generalist", "zh")
|
||||
img = default_system_prefix_for_specialist("image", "zh")
|
||||
assert "网络运维专家" in ops
|
||||
assert "通识专家" in gen
|
||||
assert "图像处理专家" in img
|
||||
129
tests/test_openclaw_router.py
Normal file
129
tests/test_openclaw_router.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.chat_models import LLMResponse
|
||||
from oclaw.openclaw_runtime.router import decide_route
|
||||
from oclaw.openclaw_runtime.types import StandardMessage, normalize_interaction_mode, normalize_requested_specialist
|
||||
|
||||
|
||||
def _msg(text: str, attachments: list[dict] | None = None) -> StandardMessage:
|
||||
return StandardMessage(
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text=text,
|
||||
attachments=attachments or [],
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_router_async_for_multi_step_send_flow() -> None:
|
||||
d = decide_route(_msg("帮我总结并发送到项目群"))
|
||||
assert d.mode == "async_task"
|
||||
assert d.reason == "multi_step_send_flow"
|
||||
|
||||
|
||||
def test_router_sync_for_short_default_message() -> None:
|
||||
d = decide_route(_msg("你好,今天怎么样"))
|
||||
assert d.mode == "sync_direct"
|
||||
assert d.reason == "default_sync"
|
||||
|
||||
|
||||
class _RouterJsonModel:
|
||||
def __init__(self, content: str) -> None:
|
||||
self._content = content
|
||||
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
return LLMResponse(content=self._content, tool_calls=[])
|
||||
|
||||
|
||||
def test_router_llm_json_mode_uses_model() -> None:
|
||||
class Store:
|
||||
def get_setting(self, key: str) -> str:
|
||||
if key == "AIA_OPENCLAW_ROUTER_MODE":
|
||||
return "llm_json"
|
||||
return ""
|
||||
|
||||
d = decide_route(
|
||||
_msg("short"),
|
||||
store=Store(),
|
||||
model=_RouterJsonModel('{"mode":"async_task","reason":"user asked for batch"}'),
|
||||
)
|
||||
assert d.mode == "async_task"
|
||||
assert "batch" in d.reason
|
||||
|
||||
|
||||
def test_router_llm_json_invalid_json_falls_back_to_rule() -> None:
|
||||
class Store:
|
||||
def get_setting(self, key: str) -> str:
|
||||
if key == "AIA_OPENCLAW_ROUTER_MODE":
|
||||
return "llm_json"
|
||||
return ""
|
||||
|
||||
d = decide_route(_msg("你好,今天怎么样"), store=Store(), model=_RouterJsonModel("not json"))
|
||||
assert d.mode == "sync_direct"
|
||||
assert d.reason == "default_sync"
|
||||
|
||||
|
||||
def test_router_llm_json_tolerates_extra_dynamic_agent_fields() -> None:
|
||||
class Store:
|
||||
def get_setting(self, key: str) -> str:
|
||||
if key == "AIA_OPENCLAW_ROUTER_MODE":
|
||||
return "llm_json"
|
||||
return ""
|
||||
|
||||
d = decide_route(
|
||||
_msg("plan this"),
|
||||
store=Store(),
|
||||
model=_RouterJsonModel('{"mode":"sync_direct","reason":"ok","dynamic_agent":{"name":"x"}}'),
|
||||
)
|
||||
assert d.mode == "sync_direct"
|
||||
assert d.reason == "ok"
|
||||
|
||||
|
||||
def test_router_skill_signal_from_metadata() -> None:
|
||||
msg = StandardMessage(
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="你好",
|
||||
attachments=[],
|
||||
metadata={"skills_total": 4},
|
||||
)
|
||||
d = decide_route(msg)
|
||||
assert d.skill_signal == "skills=4"
|
||||
|
||||
|
||||
def test_interaction_mode_normalization_supports_legacy_values() -> None:
|
||||
assert normalize_interaction_mode("comprehensive") == "comprehensive"
|
||||
assert normalize_interaction_mode("expert") == "expert"
|
||||
assert normalize_interaction_mode("specialist") == "expert"
|
||||
assert normalize_interaction_mode("composite") == "comprehensive"
|
||||
|
||||
|
||||
def test_requested_specialist_normalization_defaults_to_generalist() -> None:
|
||||
assert normalize_requested_specialist("ops") == "ops"
|
||||
assert normalize_requested_specialist("image") == "image"
|
||||
assert normalize_requested_specialist("memory_curator") == "memory_curator"
|
||||
assert normalize_requested_specialist("memory-curator") == "memory_curator"
|
||||
assert normalize_requested_specialist("unknown") == "generalist"
|
||||
|
||||
|
||||
def test_router_carries_interaction_mode_and_requested_specialist() -> None:
|
||||
msg = StandardMessage(
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="你好",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "expert", "selected_specialist": "ops"},
|
||||
)
|
||||
d = decide_route(msg)
|
||||
assert d.interaction_mode == "expert"
|
||||
assert d.requested_specialist == "ops"
|
||||
|
||||
58
tests/test_openclaw_skill_manifest.py
Normal file
58
tests/test_openclaw_skill_manifest.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.skills import build_skill_manifest, discover_workspace_skill_manifests, load_skill_manifest
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
def test_load_skill_manifest_with_openclaw_metadata(tmp_path: Path) -> None:
|
||||
d = tmp_path / "skills" / "demo"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
"""---
|
||||
name: demo
|
||||
description: demo skill
|
||||
user-invocable: true
|
||||
disable-model-invocation: false
|
||||
metadata: {"openclaw":{"install":[{"id":"i1","kind":"node","package":"foo"}]}}
|
||||
---
|
||||
|
||||
# Demo
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
m = load_skill_manifest(d)
|
||||
assert m is not None
|
||||
assert m.name == "demo"
|
||||
assert m.description == "demo skill"
|
||||
assert len(m.install) == 1
|
||||
assert m.install[0].kind == "node"
|
||||
|
||||
|
||||
def test_discover_workspace_skill_manifests(tmp_path: Path) -> None:
|
||||
base = tmp_path / "skills"
|
||||
(base / "a").mkdir(parents=True, exist_ok=True)
|
||||
(base / "a" / "SKILL.md").write_text("name: a\ndescription: A", encoding="utf-8")
|
||||
rows = discover_workspace_skill_manifests(base)
|
||||
assert len(rows) == 1
|
||||
assert rows[0].name == "a"
|
||||
|
||||
|
||||
def test_skill_manifest_is_deterministic_and_sorted() -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(name="z_tool", description="z", parameters={"type": "object"}, handler=lambda _a: {"ok": True}),
|
||||
ToolSpec(name="a_tool", description="a", parameters={"type": "object"}, handler=lambda _a: {"ok": True}),
|
||||
]
|
||||
)
|
||||
skills1, stats1 = build_skill_manifest(registry=reg, store=Store(), base_url="")
|
||||
skills2, stats2 = build_skill_manifest(registry=reg, store=Store(), base_url="")
|
||||
assert [s.name for s in skills1] == ["a_tool", "z_tool"]
|
||||
assert [s.name for s in skills2] == ["a_tool", "z_tool"]
|
||||
assert stats1.get("visible_names_preview") == stats2.get("visible_names_preview")
|
||||
|
||||
32
tests/test_openclaw_startup_workspace_resolution.py
Normal file
32
tests/test_openclaw_startup_workspace_resolution.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import _resolve_startup_workspace_dir, _resolve_startup_workspace_dirs
|
||||
|
||||
|
||||
def test_resolve_startup_workspace_dir_from_agents_default() -> None:
|
||||
cfg = {
|
||||
"agents": {
|
||||
"list": [
|
||||
{"id": "main", "default": True, "workspace": "D:/project/chatgpt/oclaw/workspace-main"},
|
||||
{"id": "social", "workspace": "D:/project/chatgpt/oclaw/workspace-social"},
|
||||
]
|
||||
}
|
||||
}
|
||||
got = _resolve_startup_workspace_dir(cfg)
|
||||
assert got.replace("\\", "/").endswith("/oclaw/workspace-main")
|
||||
|
||||
|
||||
def test_resolve_startup_workspace_dirs_all_agents() -> None:
|
||||
cfg = {
|
||||
"agents": {
|
||||
"list": [
|
||||
{"id": "main", "default": True, "workspace": "D:/project/chatgpt/oclaw/workspace-main"},
|
||||
{"id": "social", "workspace": "D:/project/chatgpt/oclaw/workspace-social"},
|
||||
]
|
||||
}
|
||||
}
|
||||
got = _resolve_startup_workspace_dirs(cfg)
|
||||
got_norm = [(aid, ws.replace("\\", "/")) for aid, ws in got]
|
||||
assert ("main", "D:/project/chatgpt/oclaw/workspace-main") in got_norm
|
||||
assert ("social", "D:/project/chatgpt/oclaw/workspace-social") in got_norm
|
||||
|
||||
62
tests/test_openclaw_system_prompt.py
Normal file
62
tests/test_openclaw_system_prompt.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.system_prompt import build_openclaw_executor_system_prompt
|
||||
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
def _spec(name: str) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description="d",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler=lambda _a: {"ok": True},
|
||||
read_only=True,
|
||||
)
|
||||
|
||||
|
||||
def test_build_openclaw_executor_system_prompt_includes_skills_block(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "sp.sqlite"))
|
||||
store.create_session("s")
|
||||
reg = ToolRegistry([_spec("demo_tool")])
|
||||
out = build_openclaw_executor_system_prompt(
|
||||
store=store,
|
||||
tools=reg,
|
||||
base_url="",
|
||||
base_system="You are helpful.",
|
||||
memory_context=OpenClawMemoryContext(),
|
||||
lang="zh",
|
||||
)
|
||||
assert "<available_skills>" in out
|
||||
|
||||
|
||||
def test_build_openclaw_executor_system_prompt_without_tools_has_no_skills_block(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "sp1.sqlite"))
|
||||
store.create_session("s")
|
||||
out = build_openclaw_executor_system_prompt(
|
||||
store=store,
|
||||
tools=None,
|
||||
base_url="",
|
||||
base_system=" plain ",
|
||||
memory_context=OpenClawMemoryContext(),
|
||||
lang="zh",
|
||||
)
|
||||
assert "<available_skills>" not in out
|
||||
|
||||
|
||||
def test_build_openclaw_executor_system_prompt_with_tools_has_skills(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "sp2.sqlite"))
|
||||
store.create_session("s")
|
||||
reg = ToolRegistry([_spec("x")])
|
||||
out = build_openclaw_executor_system_prompt(
|
||||
store=store,
|
||||
tools=reg,
|
||||
base_url="",
|
||||
base_system="base",
|
||||
memory_context=OpenClawMemoryContext(),
|
||||
lang="zh",
|
||||
)
|
||||
assert "<available_skills>" in out
|
||||
30
tests/test_openclaw_task_queue.py
Normal file
30
tests/test_openclaw_task_queue.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_openclaw_task_status_flow(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "task.sqlite"))
|
||||
task = store.openclaw_task_create(
|
||||
tenant_id="tenant-a",
|
||||
session_id="session-a",
|
||||
payload={"text": "hello"},
|
||||
)
|
||||
assert task.status == "queued"
|
||||
|
||||
claimed = store.openclaw_task_claim(worker_id="worker-1")
|
||||
assert claimed is not None
|
||||
assert claimed.id == task.id
|
||||
assert claimed.status == "claimed"
|
||||
assert claimed.claimed_by == "worker-1"
|
||||
assert claimed.attempt_count >= 1
|
||||
|
||||
ok = store.openclaw_task_finish(task_id=task.id, result={"reply_text": "done"})
|
||||
assert ok
|
||||
done = store.openclaw_task_get(task_id=task.id)
|
||||
assert done is not None
|
||||
assert done.status == "done"
|
||||
assert "done" in done.result
|
||||
|
||||
43
tests/test_openclaw_tool_result_guard.py
Normal file
43
tests/test_openclaw_tool_result_guard.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.direct_loop import _OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS, _build_model_context
|
||||
from oclaw.platform.llm.chat_models import RuleBasedChatModel
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_openclaw_tool_result_context_guard_truncates_large_tool_message(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
# Ensure the tool message is not a dangling orphan (some providers require pairing).
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": "c1", "name": "echo", "arguments": {"x": 1}}],
|
||||
)
|
||||
huge = "X" * (_OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS + 5000)
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content='{"ok": true, "blob": "' + huge + '"}',
|
||||
tool_calls={"tool_call_id": "c1", "name": "echo", "assistant_message_id": 1},
|
||||
)
|
||||
msgs = _build_model_context(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
max_messages=50,
|
||||
system_prompt="sys",
|
||||
model=RuleBasedChatModel(),
|
||||
lang="zh",
|
||||
memory_context=None,
|
||||
trace_id="t1",
|
||||
parent_span_id=None,
|
||||
)
|
||||
tool_msgs = [m for m in msgs if m.get("role") == "tool"]
|
||||
assert tool_msgs, msgs
|
||||
guarded = str(tool_msgs[-1].get("content") or "")
|
||||
assert len(guarded) <= _OPENCLAW_TOOL_RESULT_HARD_CAP_CHARS + 2000
|
||||
assert "_tool_result_guarded" in guarded
|
||||
|
||||
71
tests/test_ops_cli.py
Normal file
71
tests/test_ops_cli.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import oclaw.ops.main as ops_main
|
||||
|
||||
|
||||
def test_gateway_start_uses_fastapi_only(monkeypatch) -> None:
|
||||
called: dict[str, bool] = {"ok": False}
|
||||
|
||||
def fake_fastapi_main() -> int:
|
||||
called["ok"] = True
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr("oclaw.interfaces.http.fastapi_app.main", fake_fastapi_main)
|
||||
rc = ops_main(["gateway", "start", "--host", "127.0.0.1", "--port", "8799"])
|
||||
assert rc == 0
|
||||
assert called["ok"] is True
|
||||
|
||||
|
||||
def test_gateway_start_rejects_removed_impl_flag() -> None:
|
||||
try:
|
||||
ops_main(["gateway", "start", "--impl", "httpserver"])
|
||||
except SystemExit as exc:
|
||||
assert int(exc.code) != 0
|
||||
else:
|
||||
raise AssertionError("expected argparse to reject removed --impl flag")
|
||||
|
||||
|
||||
def test_memory_daily_generates_run_file(tmp_path: Path) -> None:
|
||||
base = tmp_path / "memory-system"
|
||||
rc = ops_main(
|
||||
[
|
||||
"memory",
|
||||
"daily",
|
||||
"--base-dir",
|
||||
str(base),
|
||||
"--date",
|
||||
"2026-04-21",
|
||||
"--review-backlog",
|
||||
"35",
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
out = base / "runs" / "daily-2026-04-21.md"
|
||||
assert out.exists()
|
||||
text = out.read_text(encoding="utf-8")
|
||||
assert "Daily Memory Run (2026-04-21)" in text
|
||||
assert "Suggested new cards today" in text
|
||||
|
||||
|
||||
def test_memory_weekly_generates_run_file(tmp_path: Path) -> None:
|
||||
base = tmp_path / "memory-system"
|
||||
rc = ops_main(
|
||||
[
|
||||
"memory",
|
||||
"weekly",
|
||||
"--base-dir",
|
||||
str(base),
|
||||
"--date",
|
||||
"2026-04-21",
|
||||
"--review-backlog",
|
||||
"0",
|
||||
]
|
||||
)
|
||||
assert rc == 0
|
||||
out = base / "runs" / "weekly-2026-W17.md"
|
||||
assert out.exists()
|
||||
text = out.read_text(encoding="utf-8")
|
||||
assert "Weekly Memory Review (2026-W17)" in text
|
||||
assert "Auto Focus for Next Week" in text
|
||||
66
tests/test_paths_db_migration.py
Normal file
66
tests/test_paths_db_migration.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Regression: default DB migration must not overwrite canonical DB when session count drops after deletes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import oclaw.platform.config.paths as paths_mod
|
||||
|
||||
|
||||
def _legacy_dir(tmp_path: Path) -> Path:
|
||||
d = tmp_path / "src" / "platform" / "data"
|
||||
d.mkdir(parents=True)
|
||||
return d
|
||||
|
||||
|
||||
def test_no_overwrite_when_legacy_has_more_sessions_than_canonical(tmp_path, monkeypatch) -> None:
|
||||
"""Previously leg_n > can_n triggered a full replace — wrong after user deletes sessions."""
|
||||
leg_root = _legacy_dir(tmp_path)
|
||||
monkeypatch.setattr(paths_mod, "PROJECT_ROOT", tmp_path)
|
||||
canonical = tmp_path / "oclaw" / "data" / "ai_ops.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("current_db", encoding="utf-8")
|
||||
legacy = leg_root / "ai_ops.sqlite"
|
||||
legacy.write_text("stale_db", encoding="utf-8")
|
||||
monkeypatch.delenv("OPS_LEGACY_DB_FORCE_PREMERGE", raising=False)
|
||||
|
||||
with patch.object(paths_mod, "_copy_sqlite_bundle") as cp:
|
||||
with patch.object(paths_mod, "_sqlite_chat_session_count", side_effect=[50, 2]):
|
||||
paths_mod._run_default_data_migration(canonical.resolve())
|
||||
cp.assert_not_called()
|
||||
assert canonical.read_text(encoding="utf-8") == "current_db"
|
||||
|
||||
|
||||
def test_force_premerege_still_overwrites_when_opt_in(tmp_path, monkeypatch) -> None:
|
||||
leg_root = _legacy_dir(tmp_path)
|
||||
monkeypatch.setattr(paths_mod, "PROJECT_ROOT", tmp_path)
|
||||
canonical = tmp_path / "oclaw" / "data" / "ai_ops.sqlite"
|
||||
canonical.parent.mkdir(parents=True)
|
||||
canonical.write_text("current_db", encoding="utf-8")
|
||||
legacy = leg_root / "ai_ops.sqlite"
|
||||
legacy.write_text("stale_db", encoding="utf-8")
|
||||
monkeypatch.setenv("OPS_LEGACY_DB_FORCE_PREMERGE", "1")
|
||||
|
||||
with patch.object(paths_mod, "_backup_sqlite_bundle_copy"):
|
||||
with patch.object(paths_mod, "_checkpoint_wal_best_effort"):
|
||||
with patch.object(paths_mod, "_copy_sqlite_bundle") as cp:
|
||||
with patch.object(paths_mod, "_sqlite_chat_session_count", side_effect=[50, 2]):
|
||||
paths_mod._run_default_data_migration(canonical.resolve())
|
||||
cp.assert_called_once()
|
||||
|
||||
|
||||
def test_seed_from_legacy_when_canonical_missing(tmp_path, monkeypatch) -> None:
|
||||
leg_root = _legacy_dir(tmp_path)
|
||||
monkeypatch.setattr(paths_mod, "PROJECT_ROOT", tmp_path)
|
||||
canonical = tmp_path / "oclaw" / "data" / "ai_ops.sqlite"
|
||||
canonical.parent.mkdir(parents=True, exist_ok=True)
|
||||
assert not canonical.exists()
|
||||
legacy = leg_root / "ai_ops.sqlite"
|
||||
legacy.write_text("from_legacy", encoding="utf-8")
|
||||
|
||||
with patch.object(paths_mod, "_sqlite_chat_session_count", return_value=3):
|
||||
with patch.object(paths_mod, "_merge_attachment_tree"):
|
||||
with patch.object(paths_mod, "_cleanup_pre_merge_backups"):
|
||||
paths_mod._run_default_data_migration(canonical.resolve())
|
||||
assert canonical.read_text(encoding="utf-8") == "from_legacy"
|
||||
53
tests/test_prompt_ci_guard.py
Normal file
53
tests/test_prompt_ci_guard.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
_PROMPT_CRITICAL_FILES = (
|
||||
"oclaw/chat/agent.py",
|
||||
"oclaw/agents/network_ops_agent.py",
|
||||
"oclaw/agents/factory.py",
|
||||
"oclaw/chat/agent_messages.py",
|
||||
"oclaw/openclaw_runtime/system_prompt.py",
|
||||
"oclaw/openclaw_runtime/memory_stage.py",
|
||||
"oclaw/openclaw_runtime/project_context_prompt.py",
|
||||
"oclaw/chat/agent_errors.py",
|
||||
"oclaw/platform/llm/image_message_client.py",
|
||||
"oclaw/openclaw_runtime/gateway.py",
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_critical_paths_use_prompt_templates() -> None:
|
||||
for rel in _PROMPT_CRITICAL_FILES:
|
||||
text = (PROJECT_ROOT / rel).read_text(encoding="utf-8")
|
||||
assert (
|
||||
"render_prompt(" in text
|
||||
or "render_prompt_for_lang(" in text
|
||||
or "render_openclaw_prompt(" in text
|
||||
), (
|
||||
f"{rel} must render prompts from oclaw/prompts*.md"
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_markdown_frontmatter_keys_present() -> None:
|
||||
roots = [
|
||||
PROJECT_ROOT / "oclaw" / "prompts",
|
||||
PROJECT_ROOT / "oclaw" / "prompts_openclaw",
|
||||
]
|
||||
for prompts_root in roots:
|
||||
for p in prompts_root.rglob("*.md"):
|
||||
if p.name == "MAPPING.md":
|
||||
continue
|
||||
raw = p.read_text(encoding="utf-8-sig")
|
||||
# Some legacy prompt bodies (not templates) are intentionally frontmatter-free.
|
||||
# CI only enforces frontmatter keys when frontmatter exists.
|
||||
if not raw.startswith("---\n"):
|
||||
continue
|
||||
assert "\n---\n" in raw, f"{p} missing frontmatter end"
|
||||
fm = raw.split("\n---\n", 1)[0]
|
||||
assert "title:" in fm, f"{p} missing title"
|
||||
assert "summary:" in fm, f"{p} missing summary"
|
||||
assert "read_when:" in fm, f"{p} missing read_when"
|
||||
|
||||
29
tests/test_prompt_hardcode_guard.py
Normal file
29
tests/test_prompt_hardcode_guard.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.config.paths import PROJECT_ROOT
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (PROJECT_ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_no_legacy_inline_manager_prompts() -> None:
|
||||
# Manager orchestrator was removed; keep this test as a smoke-guard for any new inline
|
||||
# manager-style prompt leakage into the runtime entrypoints.
|
||||
content = _read("oclaw/openclaw_runtime/gateway.py")
|
||||
assert "【重要:只返回合法 JSON】" not in content
|
||||
assert "你是 AI 助手的路由(Routing)智能体" not in content
|
||||
|
||||
|
||||
def test_no_legacy_inline_specialist_prompts() -> None:
|
||||
content = _read("oclaw/agents/specialists.py")
|
||||
assert "【最高优先级执行规则(必须遵守)】" not in content
|
||||
assert "【职责范围】" not in content
|
||||
|
||||
|
||||
def test_no_legacy_inline_runtime_prompt() -> None:
|
||||
content = _read("oclaw/chat/agent.py")
|
||||
assert "你是一个通用 AI 助手。" not in content
|
||||
|
||||
43
tests/test_prompt_loader.py
Normal file
43
tests/test_prompt_loader.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.prompts.loader import load_openclaw_prompt_doc, load_prompt_doc, render_openclaw_prompt, render_prompt
|
||||
|
||||
|
||||
def test_load_prompt_doc_with_frontmatter() -> None:
|
||||
doc = load_prompt_doc("runtime/default_system.zh.md")
|
||||
assert doc.frontmatter.get("title") == "default_runtime_system_zh"
|
||||
assert "你是一个通用 AI 助手" in doc.body
|
||||
|
||||
|
||||
def test_render_prompt_strict_missing_variable_raises() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
render_prompt("tools/tool_result_unpaired.md", variables={"tag": "x"}, strict=True)
|
||||
|
||||
|
||||
def test_render_prompt_success() -> None:
|
||||
out = render_prompt(
|
||||
"tools/tool_result_unpaired.md",
|
||||
variables={"tag": "tool_use_result:unpaired", "payload": "ok=true"},
|
||||
strict=True,
|
||||
)
|
||||
assert "[tool_use_result:unpaired]" in out
|
||||
assert "ok=true" in out
|
||||
|
||||
|
||||
def test_load_openclaw_prompt_doc_with_frontmatter() -> None:
|
||||
doc = load_openclaw_prompt_doc("roles/specialists/ops/system.md")
|
||||
assert doc.frontmatter.get("title") == "oclaw_role_specialist_ops_system"
|
||||
assert "你是网络运维专家" in doc.body
|
||||
|
||||
|
||||
def test_render_openclaw_prompt_success() -> None:
|
||||
out = render_openclaw_prompt(
|
||||
"runtime/project_context_block.md",
|
||||
variables={"project_context": "[AGENTS.md]\nfoo"},
|
||||
strict=True,
|
||||
)
|
||||
assert "[project_context]" in out
|
||||
assert "[AGENTS.md]" in out
|
||||
|
||||
27
tests/test_prompt_runtime_paths.py
Normal file
27
tests/test_prompt_runtime_paths.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.openclaw_runtime.memory_stage import render_memory_context_block
|
||||
from oclaw.openclaw_runtime.types import OpenClawMemoryContext
|
||||
from oclaw.prompts.loader import render_openclaw_prompt
|
||||
|
||||
|
||||
def test_router_prompt_renders() -> None:
|
||||
txt = render_openclaw_prompt(
|
||||
"router/decide_route.md",
|
||||
variables={"user_text": "hi", "has_attachments": "no"},
|
||||
strict=True,
|
||||
)
|
||||
assert "json" in txt.lower()
|
||||
assert "sync_direct" in txt
|
||||
assert "async_task" in txt
|
||||
|
||||
|
||||
def test_memory_context_markdown_block() -> None:
|
||||
ctx = OpenClawMemoryContext(
|
||||
short_term=("用户想排查网络问题",),
|
||||
semantic_hits=({"content": "上次 DNS 配置异常", "score": 0.91},),
|
||||
)
|
||||
out = render_memory_context_block(ctx)
|
||||
assert "[short_term_digest]" in out
|
||||
assert "[semantic_memory_hits]" in out
|
||||
|
||||
50
tests/test_replay_tool_call_id.py
Normal file
50
tests/test_replay_tool_call_id.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Regression tests for OpenClaw-style tool_call_id replay (see src/platform/llm/tool_call_id.py)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from oclaw.platform.llm.replay_policy import apply_replay_policy_to_messages, resolve_replay_policy
|
||||
from oclaw.platform.llm.tool_call_id import repair_orphan_tool_messages, rewrite_openai_chat_messages_tool_ids
|
||||
|
||||
|
||||
def test_rewrite_pairs_assistant_and_tool_ids() -> None:
|
||||
msgs = [
|
||||
{"role": "user", "content": "list files"},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_function_2aij35g1tffl_1",
|
||||
"type": "function",
|
||||
"function": {"name": "get_system_time", "arguments": "{}"},
|
||||
}
|
||||
],
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_function_2aij35g1tffl_1", "content": '{"ok":true}'},
|
||||
]
|
||||
out = rewrite_openai_chat_messages_tool_ids(msgs, max_len=40)
|
||||
assert out[1]["tool_calls"][0]["id"]
|
||||
tid = out[1]["tool_calls"][0]["id"]
|
||||
assert out[2]["tool_call_id"] == tid
|
||||
assert tid.isalnum() or all(c.isalnum() for c in tid if c)
|
||||
|
||||
|
||||
def test_repair_strips_dangling_tool_call_id() -> None:
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "x", "tool_calls": [{"id": "onlyhere", "type": "function", "function": {"name": "a", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "unknown", "content": "{}"},
|
||||
]
|
||||
out = repair_orphan_tool_messages(msgs)
|
||||
assert "tool_call_id" not in out[1] or out[1].get("tool_call_id") in ("",)
|
||||
|
||||
|
||||
def test_apply_replay_policy_full_pipeline() -> None:
|
||||
policy = resolve_replay_policy("https://example.com/v1", "mimo-v2-omni")
|
||||
assert policy.enabled
|
||||
assert policy.sanitize_tool_call_ids
|
||||
msgs = [
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"id": "weird|id:here", "type": "function", "function": {"name": "t", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "weird|id:here", "content": "{}"},
|
||||
]
|
||||
out = apply_replay_policy_to_messages(msgs, policy)
|
||||
assert out[0]["tool_calls"][0]["id"] == out[1]["tool_call_id"]
|
||||
82
tests/test_runtime_streaming_contract.py
Normal file
82
tests/test_runtime_streaming_contract.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.direct_loop import run_openclaw_direct_loop
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
from oclaw.platform.llm.transports.base import LLMResponse, LLMToolCall
|
||||
|
||||
|
||||
class _TwoChunkModel:
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
if on_token:
|
||||
on_token("A")
|
||||
on_token("B")
|
||||
return LLMResponse(content="AB", tool_calls=[])
|
||||
|
||||
|
||||
class _OneToolThenFinalModel:
|
||||
def __init__(self) -> None:
|
||||
self._n = 0
|
||||
|
||||
def chat(self, messages, tools, *, on_token=None):
|
||||
self._n += 1
|
||||
if self._n == 1:
|
||||
return LLMResponse(
|
||||
content="",
|
||||
tool_calls=[LLMToolCall(id="call_1", name="echo_tool", arguments={"x": "ok"})],
|
||||
)
|
||||
if on_token:
|
||||
on_token("done")
|
||||
return LLMResponse(content="done", tool_calls=[])
|
||||
|
||||
|
||||
def test_direct_loop_propagates_on_token_streaming(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "s.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
seen: list[str] = []
|
||||
out = run_openclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=_TwoChunkModel(),
|
||||
tools=ToolRegistry([]),
|
||||
user_text="hi",
|
||||
on_token=seen.append,
|
||||
)
|
||||
assert out.final_text == "AB"
|
||||
assert "".join(seen) == "AB"
|
||||
|
||||
|
||||
def test_direct_loop_supports_multi_round_tool_then_final(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "t.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
|
||||
def _echo(args):
|
||||
return {"ok": True, "echo": args.get("x")}
|
||||
|
||||
tools = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="echo_tool",
|
||||
description="echo",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}, "required": ["x"]},
|
||||
handler=_echo,
|
||||
tags=frozenset({"test"}),
|
||||
)
|
||||
]
|
||||
)
|
||||
out = run_openclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="sys",
|
||||
model=_OneToolThenFinalModel(),
|
||||
tools=tools,
|
||||
user_text="hi",
|
||||
max_tool_rounds=3,
|
||||
)
|
||||
assert out.final_text == "done"
|
||||
|
||||
72
tests/test_secret_migration.py
Normal file
72
tests/test_secret_migration.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class SecretMigrationTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_MASTER_KEY"] = "unit-test-master-key"
|
||||
self.store = SqliteStore(str(self.db))
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_migrate_b64_secrets_to_fernet_is_idempotent(self) -> None:
|
||||
# Insert legacy b64 secret into app_setting
|
||||
plain1 = "secret-one"
|
||||
enc1 = "b64:" + base64.b64encode(plain1.encode("utf-8")).decode("ascii")
|
||||
with self.store._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO app_setting (key, value, is_secret, updated_at)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value, is_secret = 1, updated_at = excluded.updated_at
|
||||
""",
|
||||
("legacy1", enc1, "2026-01-01T00:00:00+00:00"),
|
||||
)
|
||||
|
||||
# Insert legacy b64 secret into llm_profile.api_key
|
||||
pid = self.store.create_llm_profile(name="p1", mode="openai", model="gpt-4o-mini", base_url=None, owner_user_id=None)
|
||||
plain2 = "sk-test-123"
|
||||
enc2 = "b64:" + base64.b64encode(plain2.encode("utf-8")).decode("ascii")
|
||||
with self.store._connect() as conn:
|
||||
conn.execute("UPDATE llm_profile SET api_key = ? WHERE id = ?", (enc2, pid))
|
||||
|
||||
res1 = self.store.migrate_secrets_to_fernet()
|
||||
self.assertEqual(int(res1.get("migrated_app_settings") or 0), 1)
|
||||
self.assertEqual(int(res1.get("migrated_llm_profiles") or 0), 1)
|
||||
|
||||
# Ensure underlying storage no longer uses b64: (dpapi on Windows, fernet elsewhere)
|
||||
with self.store._connect() as conn:
|
||||
row = conn.execute("SELECT value FROM app_setting WHERE key = ? AND is_secret = 1", ("legacy1",)).fetchone()
|
||||
self.assertIsNotNone(row)
|
||||
val = str(row["value"] or "")
|
||||
self.assertFalse(val.startswith("b64:"))
|
||||
if sys.platform == "win32":
|
||||
self.assertTrue(val.startswith("dpapi:") or val.startswith("fernet:"))
|
||||
else:
|
||||
self.assertTrue(val.startswith("fernet:"))
|
||||
|
||||
# Values should still be readable
|
||||
self.assertEqual(self.store.get_secret("legacy1"), plain1)
|
||||
self.assertEqual(self.store.get_llm_profile_secret(pid), plain2)
|
||||
|
||||
# Second run should migrate nothing
|
||||
res2 = self.store.migrate_secrets_to_fernet()
|
||||
self.assertEqual(int(res2.get("migrated_app_settings") or 0), 0)
|
||||
self.assertEqual(int(res2.get("migrated_llm_profiles") or 0), 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
49
tests/test_skill_executor_trace.py
Normal file
49
tests/test_skill_executor_trace.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from oclaw.openclaw_runtime.skill_executor import SkillExecutionContext, SkillExecutor
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
class _DummyStore:
|
||||
def __init__(self) -> None:
|
||||
self.events: list[dict] = []
|
||||
self.logs: list[dict] = []
|
||||
self.messages: list[dict] = []
|
||||
|
||||
def add_trace_event(self, **kwargs): # noqa: ANN003
|
||||
self.events.append(dict(kwargs))
|
||||
|
||||
def add_tool_log(self, **kwargs): # noqa: ANN003
|
||||
self.logs.append(dict(kwargs))
|
||||
|
||||
def add_message(self, **kwargs): # noqa: ANN003
|
||||
self.messages.append(dict(kwargs))
|
||||
return SimpleNamespace(id=len(self.messages))
|
||||
|
||||
|
||||
def test_skill_executor_emits_skill_trace_events() -> None:
|
||||
store = _DummyStore()
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="echo_skill",
|
||||
description="echo",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "string"}}},
|
||||
handler=lambda a: {"ok": True, "x": a.get("x")},
|
||||
read_only=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
ex = SkillExecutor()
|
||||
uses = [SimpleNamespace(id="call_1", name="echo_skill", arguments={"x": "1"})]
|
||||
ex.execute_skill_uses(
|
||||
ctx=SkillExecutionContext(store=store, tools=reg, session_id="s1", trace_id="t1"),
|
||||
assistant_msg_id=1,
|
||||
skill_uses=uses,
|
||||
)
|
||||
et = [str((e.get("event_type") or "")) for e in store.events]
|
||||
assert "skill_selected" in et
|
||||
assert "skill_executed" in et
|
||||
|
||||
125
tests/test_skill_installer.py
Normal file
125
tests/test_skill_installer.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
|
||||
from oclaw.openclaw_runtime.skill_installer import (
|
||||
auto_install_skill_from_payload,
|
||||
create_skill_from_template,
|
||||
install_skill_from_local_dir,
|
||||
install_skill_from_registry_archive,
|
||||
list_skills_with_status,
|
||||
set_skill_enabled,
|
||||
)
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
def test_create_list_and_disable_skill(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
out = create_skill_from_template(
|
||||
store=store,
|
||||
name="demo_skill",
|
||||
description="demo",
|
||||
body_markdown="hello",
|
||||
skills_root=tmp_path / "skills",
|
||||
)
|
||||
assert out.ok
|
||||
rows = list_skills_with_status(store=store, skills_root=tmp_path / "skills")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["name"] == "demo_skill"
|
||||
assert rows[0]["enabled"] is True
|
||||
set_skill_enabled(store=store, skill_name="demo_skill", enabled=False)
|
||||
rows2 = list_skills_with_status(store=store, skills_root=tmp_path / "skills")
|
||||
assert rows2[0]["enabled"] is False
|
||||
|
||||
|
||||
def test_auto_install_rollback_on_forced_error(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
root = tmp_path / "skills"
|
||||
out = auto_install_skill_from_payload(
|
||||
store=store,
|
||||
payload={
|
||||
"name": "roll_me_back",
|
||||
"description": "demo",
|
||||
"body_markdown": "x",
|
||||
"force_error_for_test": True,
|
||||
},
|
||||
skills_root=root,
|
||||
)
|
||||
assert out.ok is False
|
||||
assert out.retryable is True
|
||||
assert out.error_code == "runtime_error"
|
||||
assert not (root / "roll_me_back").exists()
|
||||
|
||||
|
||||
def test_install_skill_from_registry_archive_file_url(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
pkg_dir = tmp_path / "pkg"
|
||||
inner = pkg_dir / "demo"
|
||||
inner.mkdir(parents=True, exist_ok=True)
|
||||
(inner / "SKILL.md").write_text(
|
||||
"---\nname: reg_demo\ndescription: x\nmetadata: {\"openclaw\":{}}\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
archive = tmp_path / "reg.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.write(inner / "SKILL.md", arcname="demo/SKILL.md")
|
||||
out = install_skill_from_registry_archive(
|
||||
store=store,
|
||||
archive_url=archive.resolve().as_uri(),
|
||||
skills_root=tmp_path / "skills",
|
||||
)
|
||||
assert out.ok
|
||||
assert out.name == "reg_demo"
|
||||
|
||||
|
||||
def test_install_skill_from_clawhub_page_url(tmp_path: Path, monkeypatch) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
pkg_dir = tmp_path / "pkg_clawhub"
|
||||
inner = pkg_dir / "demo"
|
||||
inner.mkdir(parents=True, exist_ok=True)
|
||||
(inner / "SKILL.md").write_text(
|
||||
"---\nname: reg_clawhub_demo\ndescription: x\nmetadata: {\"openclaw\":{}}\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
archive = tmp_path / "reg_clawhub.zip"
|
||||
with zipfile.ZipFile(archive, "w") as zf:
|
||||
zf.write(inner / "SKILL.md", arcname="demo/SKILL.md")
|
||||
|
||||
captured: dict[str, str] = {}
|
||||
|
||||
def _mock_get_detail(slug: str, *, cfg=None): # noqa: ANN001,ARG001
|
||||
captured["slug"] = slug
|
||||
return {"archiveUrl": archive.resolve().as_uri()}
|
||||
|
||||
monkeypatch.setattr("oclaw.tools.skills.clawhub_client.get_skill_detail", _mock_get_detail)
|
||||
|
||||
out = install_skill_from_registry_archive(
|
||||
store=store,
|
||||
archive_url="https://clawhub.ai/pskoett/self-improving-agent",
|
||||
skills_root=tmp_path / "skills",
|
||||
)
|
||||
assert captured["slug"] == "self-improving-agent"
|
||||
assert out.ok
|
||||
assert out.name == "reg_clawhub_demo"
|
||||
|
||||
|
||||
def test_install_local_allows_sh_files(tmp_path: Path) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
src = tmp_path / "local_skill"
|
||||
src.mkdir(parents=True, exist_ok=True)
|
||||
(src / "SKILL.md").write_text(
|
||||
"---\nname: local_with_sh\ndescription: x\nmetadata: {\"openclaw\":{}}\n---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(src / "activator.sh").write_text("#!/usr/bin/env bash\necho ok\n", encoding="utf-8")
|
||||
|
||||
out = install_skill_from_local_dir(store=store, source_dir=src, skills_root=tmp_path / "skills")
|
||||
assert out.ok
|
||||
assert out.name == "local_with_sh"
|
||||
|
||||
82
tests/test_skill_role_binding_catalog.py
Normal file
82
tests/test_skill_role_binding_catalog.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.skill_role_binding import (
|
||||
SKILL_ROLE_BINDING_ENABLED_SETTING,
|
||||
SKILL_ROLE_BINDING_KEY,
|
||||
normalize_skill_role_binding,
|
||||
ordered_binding_roles,
|
||||
)
|
||||
from oclaw.openclaw_runtime.skills_prompt import collect_skill_catalog_entries
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.catalog import default_registry
|
||||
|
||||
|
||||
def _write_skill(root: Path, name: str) -> None:
|
||||
d = root / name
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "SKILL.md").write_text(
|
||||
f"---\nname: {name}\ndescription: test {name}\nmetadata: {{}}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_collect_respects_role_binding_union(tmp_path: Path, monkeypatch) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
skills_root = tmp_path / "skills"
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
_write_skill(skills_root, "skill-alpha")
|
||||
_write_skill(skills_root, "skill-beta")
|
||||
|
||||
monkeypatch.setenv("AIA_SKILLS_ROOT", str(skills_root))
|
||||
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "1")
|
||||
mapping = {r: [] for r in ordered_binding_roles()}
|
||||
mapping["generalist"] = ["skill-alpha"]
|
||||
mapping["manager"] = ["skill-beta"]
|
||||
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps(mapping))
|
||||
|
||||
reg = default_registry(store=store)
|
||||
entries = collect_skill_catalog_entries(
|
||||
store=store,
|
||||
registry=reg,
|
||||
base_url="",
|
||||
skill_binding_role="generalist",
|
||||
)
|
||||
names = {e[0] for e in entries}
|
||||
assert "skill-alpha" in names
|
||||
assert "skill-beta" in names
|
||||
|
||||
|
||||
def test_collect_unfiltered_when_binding_disabled(tmp_path: Path, monkeypatch) -> None:
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
skills_root = tmp_path / "skills2"
|
||||
skills_root.mkdir(parents=True, exist_ok=True)
|
||||
_write_skill(skills_root, "solo-skill")
|
||||
monkeypatch.setenv("AIA_SKILLS_ROOT", str(skills_root))
|
||||
store.set_setting(SKILL_ROLE_BINDING_ENABLED_SETTING, "0")
|
||||
store.set_setting(SKILL_ROLE_BINDING_KEY, json.dumps({"generalist": ["solo-skill"]}))
|
||||
|
||||
reg = default_registry(store=store)
|
||||
entries = collect_skill_catalog_entries(
|
||||
store=store,
|
||||
registry=reg,
|
||||
base_url="",
|
||||
skill_binding_role="generalist",
|
||||
)
|
||||
names = {e[0] for e in entries}
|
||||
assert "solo-skill" not in names
|
||||
|
||||
|
||||
def test_normalize_drops_unknown_skills(tmp_path: Path) -> None:
|
||||
roles = ["manager", "generalist"]
|
||||
out = normalize_skill_role_binding(
|
||||
mapping_raw={"manager": ["nope", "skill-x"], "generalist": ["skill-x"]},
|
||||
valid_skill_names={"skill-x"},
|
||||
available_roles=roles,
|
||||
)
|
||||
assert out["manager"] == ["skill-x"]
|
||||
assert out["generalist"] == ["skill-x"]
|
||||
79
tests/test_skill_runtime_metadata_and_tools.py
Normal file
79
tests/test_skill_runtime_metadata_and_tools.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.openclaw_runtime.skills import discover_workspace_skill_manifests
|
||||
from oclaw.tools.skills_runtime.materialize_skill_tools import materialize_executable_skill_tools
|
||||
|
||||
|
||||
class SkillRuntimeMetadataAndToolsTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.skills_root = Path(self._tmp.name) / "skills"
|
||||
self.skills_root.mkdir(parents=True, exist_ok=True)
|
||||
os.environ["AIA_SKILLS_ROOT"] = str(self.skills_root)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
os.environ.pop("AIA_SKILLS_ROOT", None)
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_manifest_parses_runtime_spec(self) -> None:
|
||||
d = self.skills_root / "demo_runtime"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
(d / "scripts" / "run.py").write_text("print('{\"ok\": true}')\n", encoding="utf-8")
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: demo_runtime\n"
|
||||
"description: demo\n"
|
||||
"metadata:\n"
|
||||
" openclaw:\n"
|
||||
" runtime:\n"
|
||||
" type: python\n"
|
||||
" entry: scripts/run.py\n"
|
||||
" schema:\n"
|
||||
" type: object\n"
|
||||
" properties:\n"
|
||||
" input_path: { type: string }\n"
|
||||
" additionalProperties: false\n"
|
||||
"---\n"
|
||||
"\n"
|
||||
"# demo\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
ms = discover_workspace_skill_manifests()
|
||||
m = next((x for x in ms if x.name == "demo_runtime"), None)
|
||||
assert m is not None
|
||||
assert isinstance(m.runtime, dict)
|
||||
assert m.runtime.get("type") == "python"
|
||||
assert m.runtime.get("entry") == "scripts/run.py"
|
||||
assert isinstance(m.runtime.get("schema"), dict)
|
||||
|
||||
def test_materialize_creates_tool_for_runtime_skill(self) -> None:
|
||||
d = self.skills_root / "demo_runtime_tool"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
(d / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
(d / "scripts" / "run.py").write_text("print('{\"ok\": true}')\n", encoding="utf-8")
|
||||
(d / "SKILL.md").write_text(
|
||||
"---\n"
|
||||
"name: demo_runtime_tool\n"
|
||||
"description: demo\n"
|
||||
"metadata:\n"
|
||||
" openclaw:\n"
|
||||
" runtime:\n"
|
||||
" type: python\n"
|
||||
" entry: scripts/run.py\n"
|
||||
"---\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
tools = materialize_executable_skill_tools(store=None)
|
||||
names = {t.name for t in tools}
|
||||
assert "demo_runtime_tool" in names
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
36
tests/test_skill_runtime_restricted_fs_permissions.py
Normal file
36
tests/test_skill_runtime_restricted_fs_permissions.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.tools.skills_runtime.subprocess_exec import run_skill_runtime_entry
|
||||
|
||||
|
||||
class SkillRuntimeRestrictedFsPermissionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.skill_dir = Path(self._tmp.name) / "skill"
|
||||
self.skill_dir.mkdir(parents=True, exist_ok=True)
|
||||
(self.skill_dir / "scripts").mkdir(parents=True, exist_ok=True)
|
||||
(self.skill_dir / "scripts" / "ok.py").write_text("print('{\"ok\": true}')\n", encoding="utf-8")
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_fs_write_disabled_blocks_output_path(self) -> None:
|
||||
res = run_skill_runtime_entry(
|
||||
skill_name="demo",
|
||||
skill_dir=str(self.skill_dir),
|
||||
runtime={"type": "python", "entry": "scripts/ok.py", "permissions": {"fs_write": False}},
|
||||
args={"output_path": "out.txt"},
|
||||
)
|
||||
self.assertFalse(res.get("ok"))
|
||||
self.assertEqual(str(res.get("error_code")), "path_restricted")
|
||||
self.assertIn("fs_write_disabled", str(res.get("error") or ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
20
tests/test_skills_prompt.py
Normal file
20
tests/test_skills_prompt.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.openclaw_runtime.skills_prompt import format_skills_for_prompt
|
||||
|
||||
|
||||
def test_format_skills_for_prompt_xml_shape() -> None:
|
||||
out = format_skills_for_prompt(
|
||||
[("demo", "desc", "/tmp/skills/demo/SKILL.md")],
|
||||
max_chars=50_000,
|
||||
)
|
||||
assert "<available_skills>" in out
|
||||
assert "</available_skills>" in out
|
||||
assert "<name>demo</name>" in out
|
||||
assert "read_file" in out
|
||||
|
||||
|
||||
def test_format_skills_for_prompt_truncates_by_budget() -> None:
|
||||
entries = [(f"s{i}", "d", f"/p/{i}") for i in range(30)]
|
||||
out = format_skills_for_prompt(entries, max_chars=800)
|
||||
assert len(out) <= 800
|
||||
39
tests/test_tool_batch_partition.py
Normal file
39
tests/test_tool_batch_partition.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.chat.tool_runtime import partition_tool_use_batches
|
||||
from oclaw.platform.llm.chat_models import LLMToolCall
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
def _spec(name: str, *, read_only: bool) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description="t",
|
||||
parameters={"type": "object", "properties": {}},
|
||||
handler=lambda _a: {"ok": True},
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
|
||||
def test_partition_merges_consecutive_read_only() -> None:
|
||||
reg = ToolRegistry([_spec("a", read_only=True), _spec("b", read_only=True), _spec("c", read_only=False)])
|
||||
calls = [
|
||||
LLMToolCall(id="1", name="a", arguments={}),
|
||||
LLMToolCall(id="2", name="b", arguments={}),
|
||||
LLMToolCall(id="3", name="c", arguments={}),
|
||||
LLMToolCall(id="4", name="a", arguments={}),
|
||||
]
|
||||
batches = partition_tool_use_batches(calls, reg)
|
||||
assert [len(b) for b in batches] == [2, 1, 1]
|
||||
assert [tc.id for b in batches for tc in b] == ["1", "2", "3", "4"]
|
||||
|
||||
|
||||
def test_partition_unknown_tool_is_sequential_only() -> None:
|
||||
reg = ToolRegistry([_spec("a", read_only=True)])
|
||||
calls = [
|
||||
LLMToolCall(id="1", name="a", arguments={}),
|
||||
LLMToolCall(id="2", name="missing", arguments={}),
|
||||
LLMToolCall(id="3", name="a", arguments={}),
|
||||
]
|
||||
batches = partition_tool_use_batches(calls, reg)
|
||||
assert [len(b) for b in batches] == [1, 1, 1]
|
||||
33
tests/test_tool_llm_truncation.py
Normal file
33
tests/test_tool_llm_truncation.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from oclaw.chat.tool_runtime import truncate_tool_result_for_llm_messages, tool_llm_message_max_chars
|
||||
|
||||
|
||||
class ToolLlmTruncationTests(unittest.TestCase):
|
||||
def test_small_payload_unchanged(self) -> None:
|
||||
d = {"ok": True, "files": ["a.txt", "b.txt"], "count": 2}
|
||||
self.assertEqual(truncate_tool_result_for_llm_messages(d), d)
|
||||
|
||||
def test_huge_files_truncated(self) -> None:
|
||||
cap = 8000
|
||||
files = [f"f{i:05d}.txt" for i in range(5000)]
|
||||
d = {"ok": True, "files": files, "root": "C:\\\\test"}
|
||||
out = truncate_tool_result_for_llm_messages(d, max_chars=cap)
|
||||
self.assertTrue(out.get("_truncated_for_llm"))
|
||||
self.assertLessEqual(len(json.dumps(out, ensure_ascii=False)), cap)
|
||||
self.assertIn("files_total", out)
|
||||
self.assertGreater(out["files_total"], len(out.get("files") or []))
|
||||
|
||||
def test_tool_llm_max_chars_env(self) -> None:
|
||||
import os
|
||||
from unittest import mock
|
||||
|
||||
with mock.patch.dict(os.environ, {"AIA_TOOL_LLM_MESSAGE_MAX_CHARS": "9000"}, clear=False):
|
||||
self.assertEqual(tool_llm_message_max_chars(), 9000)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
125
tests/test_tool_loop_guard.py
Normal file
125
tests/test_tool_loop_guard.py
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.chat.tool_runtime import ToolExecutionContext, ToolExecutor
|
||||
from oclaw.platform.llm.chat_models import LLMToolCall
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
def test_tool_loop_guard_blocks_repeated_signature(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "g.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
calls = {"n": 0}
|
||||
|
||||
def _handler(args):
|
||||
calls["n"] += 1
|
||||
return {"ok": True, "echo": args}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="echo",
|
||||
description="echo",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
|
||||
handler=_handler,
|
||||
read_only=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
tool_uses = [
|
||||
LLMToolCall(id="c1", name="echo", arguments={"x": 1}),
|
||||
LLMToolCall(id="c2", name="echo", arguments={"x": 1}),
|
||||
LLMToolCall(id="c3", name="echo", arguments={"x": 1}),
|
||||
]
|
||||
_, results = ToolExecutor().execute_tool_uses(
|
||||
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id),
|
||||
assistant_msg_id=1,
|
||||
tool_uses=tool_uses,
|
||||
signature_budget=2,
|
||||
)
|
||||
assert calls["n"] == 2
|
||||
blocked, _ = results["c3"]
|
||||
assert blocked.get("error_code") == "tool_loop_guard"
|
||||
|
||||
|
||||
def test_repeated_tool_results_are_compacted_in_history(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "g2.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
|
||||
def _handler(_args):
|
||||
# Emulate large-ish tabular payload that should not be replayed verbatim repeatedly.
|
||||
return {"ok": True, "rows": [{"i": i, "v": f"row-{i}"} for i in range(0, 200)], "rows_returned": 200}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="run_tabular_sql",
|
||||
description="fetch rows",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
|
||||
handler=_handler,
|
||||
read_only=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
tool_uses = [
|
||||
LLMToolCall(id="c1", name="run_tabular_sql", arguments={"x": 1}),
|
||||
LLMToolCall(id="c2", name="run_tabular_sql", arguments={"x": 2}),
|
||||
LLMToolCall(id="c3", name="run_tabular_sql", arguments={"x": 3}),
|
||||
]
|
||||
ToolExecutor().execute_tool_uses(
|
||||
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id, turn_uuid="turn-1"),
|
||||
assistant_msg_id=1,
|
||||
tool_uses=tool_uses,
|
||||
)
|
||||
rows = store.get_messages(session_id=sess.id, limit=50)
|
||||
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
|
||||
assert len(tool_rows) == 3
|
||||
payloads = [json.loads(str(getattr(m, "content", "") or "{}")) for m in tool_rows]
|
||||
assert bool(payloads[0].get("_history_compacted"))
|
||||
assert bool(payloads[1].get("_history_compacted"))
|
||||
assert bool(payloads[2].get("_history_compacted"))
|
||||
assert str(payloads[2].get("_history_compact_reason") or "") == "repeated_tool_calls_in_turn"
|
||||
assert int(payloads[0].get("_tool_observed_rows_cumulative_in_turn") or 0) == 200
|
||||
assert int(payloads[1].get("_tool_observed_rows_cumulative_in_turn") or 0) == 400
|
||||
assert int(payloads[2].get("_tool_observed_rows_this_call") or 0) == 200
|
||||
assert int(payloads[2].get("_tool_observed_rows_cumulative_in_turn") or 0) == 600
|
||||
assert "audit_note" in payloads[2]
|
||||
|
||||
|
||||
def test_repeated_non_sql_tools_are_not_compacted(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "g3.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
|
||||
def _handler(_args):
|
||||
return {"ok": True, "rows": [{"i": i} for i in range(0, 50)], "rows_returned": 50}
|
||||
|
||||
reg = ToolRegistry(
|
||||
[
|
||||
ToolSpec(
|
||||
name="generic_fetch",
|
||||
description="generic",
|
||||
parameters={"type": "object", "properties": {"x": {"type": "integer"}}},
|
||||
handler=_handler,
|
||||
read_only=True,
|
||||
)
|
||||
]
|
||||
)
|
||||
tool_uses = [
|
||||
LLMToolCall(id="c1", name="generic_fetch", arguments={"x": 1}),
|
||||
LLMToolCall(id="c2", name="generic_fetch", arguments={"x": 2}),
|
||||
LLMToolCall(id="c3", name="generic_fetch", arguments={"x": 3}),
|
||||
]
|
||||
ToolExecutor().execute_tool_uses(
|
||||
ctx=ToolExecutionContext(store=store, tools=reg, session_id=sess.id, turn_uuid="turn-2"),
|
||||
assistant_msg_id=1,
|
||||
tool_uses=tool_uses,
|
||||
)
|
||||
rows = store.get_messages(session_id=sess.id, limit=50)
|
||||
tool_rows = [m for m in rows if str(getattr(m, "role", "") or "") == "tool"]
|
||||
payloads = [json.loads(str(getattr(m, "content", "") or "{}")) for m in tool_rows]
|
||||
assert payloads
|
||||
assert not any(bool(p.get("_history_compacted")) for p in payloads)
|
||||
|
||||
167
tests/test_tool_pairing_messages.py
Normal file
167
tests/test_tool_pairing_messages.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from oclaw.chat.agent_messages import build_llm_messages
|
||||
from oclaw.platform.llm.chat_models import RuleBasedChatModel
|
||||
|
||||
|
||||
class _Msg:
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
content: str = "",
|
||||
tool_calls: str | None = None,
|
||||
*,
|
||||
event_type: str | None = None,
|
||||
turn_uuid: str | None = None,
|
||||
):
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls
|
||||
self.attachments = None
|
||||
self.event_type = event_type
|
||||
self.turn_uuid = turn_uuid
|
||||
|
||||
|
||||
def test_build_llm_messages_unpaired_tool_rows_downgrade_to_assistant_text() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg("user", "hi"),
|
||||
_Msg(
|
||||
"assistant",
|
||||
"ok",
|
||||
tool_calls=json.dumps(
|
||||
[
|
||||
{
|
||||
"id": "call_1",
|
||||
"name": "t",
|
||||
"arguments": {},
|
||||
}
|
||||
],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
_Msg(
|
||||
"tool",
|
||||
'{"ok":false,"error":"x"}',
|
||||
tool_calls=json.dumps({"tool_call_id": "missing_call_id", "name": "t"}, ensure_ascii=False),
|
||||
),
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
assert not any(m.get("role") == "tool" for m in msgs), msgs
|
||||
assistant_texts = [str(m.get("content") or "") for m in msgs if m.get("role") == "assistant"]
|
||||
assert any("[tool_use_result" in t for t in assistant_texts)
|
||||
|
||||
|
||||
def test_build_llm_messages_user_relay_pointer_as_text_meta() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
u = _Msg("user", "see shared file")
|
||||
u.attachments = [
|
||||
{
|
||||
"type": "relay_pointer",
|
||||
"pointer_uri": "relay://attachments/scope_1/abcdef123456",
|
||||
"rel_path": "attachments/a.txt",
|
||||
"mime": "text/plain",
|
||||
"bytes": 12,
|
||||
"sha256": "f" * 64,
|
||||
}
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=[u], system_prompt="s", model=model, lang="zh")
|
||||
user_rows = [m for m in msgs if m.get("role") == "user"]
|
||||
assert user_rows
|
||||
c = user_rows[0].get("content")
|
||||
assert isinstance(c, list)
|
||||
joined = "\n".join(str(x.get("text") or "") for x in c if isinstance(x, dict))
|
||||
assert "relay://attachments/scope_1/abcdef123456" in joined
|
||||
|
||||
|
||||
def test_build_llm_messages_skips_reasoning_event_rows() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg("user", "hi", event_type="user_text", turn_uuid="turn-1"),
|
||||
_Msg("assistant", "internal", event_type="reasoning", turn_uuid="turn-1"),
|
||||
_Msg("assistant", "visible answer", event_type="assistant_text", turn_uuid="turn-1"),
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
assistant_texts = [str(m.get("content") or "") for m in msgs if m.get("role") == "assistant"]
|
||||
assert assistant_texts == ["visible answer"]
|
||||
|
||||
|
||||
def test_build_llm_messages_strips_think_blocks_from_legacy_assistant_content() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows = [_Msg("assistant", "<think>\nsecret plan\n</think>\n\nfinal answer")]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
assistant_rows = [m for m in msgs if m.get("role") == "assistant"]
|
||||
assert assistant_rows
|
||||
assert "secret plan" not in str(assistant_rows[0].get("content") or "")
|
||||
assert "final answer" in str(assistant_rows[0].get("content") or "")
|
||||
|
||||
|
||||
def test_build_llm_messages_only_keeps_recent_3_tool_rounds_full() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows: list[_Msg] = []
|
||||
for idx in range(1, 5):
|
||||
tcid = f"call_{idx}"
|
||||
rows.append(
|
||||
_Msg(
|
||||
"assistant",
|
||||
"",
|
||||
tool_calls=json.dumps([{"id": tcid, "name": "tool_x", "arguments": {"n": idx}}], ensure_ascii=False),
|
||||
event_type="tool_call",
|
||||
turn_uuid=f"turn-{idx}",
|
||||
)
|
||||
)
|
||||
rows.append(
|
||||
_Msg(
|
||||
"tool",
|
||||
json.dumps({"ok": True, "blob": "x" * 3000, "idx": idx}, ensure_ascii=False),
|
||||
tool_calls=json.dumps({"tool_call_id": tcid, "name": "tool_x"}, ensure_ascii=False),
|
||||
event_type="tool_result",
|
||||
turn_uuid=f"turn-{idx}",
|
||||
)
|
||||
)
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
tool_by_id = {str(m.get("tool_call_id")): str(m.get("content") or "") for m in msgs if m.get("role") == "tool"}
|
||||
assert tool_by_id["call_1"].find("_history_summarized") >= 0
|
||||
assert tool_by_id["call_4"].find("_history_summarized") < 0
|
||||
|
||||
|
||||
def test_signature_metadata_not_replayed_by_default_for_non_whitelist_model() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg(
|
||||
"assistant",
|
||||
"",
|
||||
tool_calls=json.dumps(
|
||||
[{"id": "call_1", "name": "t", "arguments": {}, "thought_signature": "sig_abc"}],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
event_type="tool_call",
|
||||
)
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
assistant = [m for m in msgs if m.get("role") == "assistant"][0]
|
||||
tc = (assistant.get("tool_calls") or [])[0]
|
||||
assert "extra_content" not in tc
|
||||
|
||||
|
||||
def test_signature_metadata_can_be_forced_on_via_env(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_REPLAY_REASONING_SIGNATURE_POLICY", "on")
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg(
|
||||
"assistant",
|
||||
"",
|
||||
tool_calls=json.dumps(
|
||||
[{"id": "call_1", "name": "t", "arguments": {}, "thought_signature": "sig_abc"}],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
event_type="tool_call",
|
||||
)
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh")
|
||||
assistant = [m for m in msgs if m.get("role") == "assistant"][0]
|
||||
tc = (assistant.get("tool_calls") or [])[0]
|
||||
assert tc.get("extra_content", {}).get("google", {}).get("thought_signature") == "sig_abc"
|
||||
|
||||
213
tests/test_tool_wire_policy.py
Normal file
213
tests/test_tool_wire_policy.py
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from oclaw.platform.llm.tool_wire_policy import (
|
||||
SETTINGS_KEY_PENALTY_STATE,
|
||||
migrate_legacy_penalty_store,
|
||||
prepare_openai_tools_for_llm_api,
|
||||
)
|
||||
|
||||
|
||||
class FakeToolStore:
|
||||
"""Minimal store stub for ``prepare_openai_tools_for_llm_api``."""
|
||||
|
||||
def __init__(self, usage_map: dict, penalty_state: dict | None = None) -> None:
|
||||
self.usage_map = dict(usage_map)
|
||||
self._penalty_json = json.dumps(penalty_state or {})
|
||||
self.last_penalty_saved: str | None = None
|
||||
|
||||
def list_mcp_tool_aggregate_usage(self) -> dict:
|
||||
return dict(self.usage_map)
|
||||
|
||||
def get_setting(self, key: str) -> str | None:
|
||||
if key == SETTINGS_KEY_PENALTY_STATE:
|
||||
return self._penalty_json
|
||||
return None
|
||||
|
||||
def set_setting(self, key: str, val: str) -> None:
|
||||
if key == SETTINGS_KEY_PENALTY_STATE:
|
||||
self._penalty_json = val
|
||||
self.last_penalty_saved = val
|
||||
|
||||
|
||||
def _fn(name: str, *, desc: str = "hello") -> dict:
|
||||
props: dict = {"fld": {"type": "string", "description": "inner"}}
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": desc,
|
||||
"parameters": {"type": "object", "properties": props},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _names(tools: list[dict]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for t in tools:
|
||||
fn = t.get("function")
|
||||
if isinstance(fn, dict) and fn.get("name"):
|
||||
out.append(str(fn["name"]))
|
||||
return out
|
||||
|
||||
|
||||
def _iso_hours_ago(h: float) -> str:
|
||||
n = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
return (n - timedelta(hours=h)).isoformat()
|
||||
|
||||
|
||||
class ToolWirePolicyTests(unittest.TestCase):
|
||||
def test_builtin_tools_always_full(self) -> None:
|
||||
ts = _iso_hours_ago(1.0)
|
||||
usage = {
|
||||
"mcp__rank__1": {"count": 400, "last_ts": ts},
|
||||
"mcp__rank__2": {"count": 300, "last_ts": ts},
|
||||
"mcp__rank__3": {"count": 200, "last_ts": ts},
|
||||
"mcp__srv__x": {"count": 1, "last_ts": ts},
|
||||
}
|
||||
tools = [_fn("read_file"), _fn("mcp__rank__1"), _fn("mcp__rank__2"), _fn("mcp__rank__3"), _fn("mcp__srv__x")]
|
||||
st = FakeToolStore(usage)
|
||||
with patch("oclaw.platform.llm.tool_wire_policy._utc_now", return_value=datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)):
|
||||
with patch.dict("os.environ", {"OPS_MCP_WIRE_TOP_N_FULL": "3"}, clear=False):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
by_name = {str(t["function"]["name"]): t for t in out if t.get("type") == "function"}
|
||||
self.assertIn("inner", json.dumps(by_name["read_file"]))
|
||||
self.assertNotIn("inner", json.dumps(by_name["mcp__srv__x"]))
|
||||
|
||||
def test_graduated_minimal_below_top_n(self) -> None:
|
||||
ts = _iso_hours_ago(0.5)
|
||||
usage = {
|
||||
"mcp__p__a": {"count": 100, "last_ts": ts},
|
||||
"mcp__p__b": {"count": 90, "last_ts": ts},
|
||||
"mcp__p__c": {"count": 80, "last_ts": ts},
|
||||
"mcp__p__d": {"count": 70, "last_ts": ts},
|
||||
}
|
||||
tools = [_fn("mcp__p__a"), _fn("mcp__p__b"), _fn("mcp__p__c"), _fn("mcp__p__d")]
|
||||
st = FakeToolStore(usage)
|
||||
fixed = datetime(2026, 1, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
with patch("oclaw.platform.llm.tool_wire_policy._utc_now", return_value=fixed):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"OPS_MCP_WIRE_TOP_N_FULL": "3",
|
||||
"OPS_MCP_WIRE_MEDIUM_RANK_START": "999",
|
||||
"OPS_MCP_WIRE_MEDIUM_RANK_END": "999",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
by_name = {str(t["function"]["name"]): t for t in out}
|
||||
self.assertIn("properties", by_name["mcp__p__a"]["function"]["parameters"])
|
||||
self.assertIn("properties", by_name["mcp__p__b"]["function"]["parameters"])
|
||||
self.assertIn("properties", by_name["mcp__p__c"]["function"]["parameters"])
|
||||
self.assertEqual(by_name["mcp__p__d"]["function"]["parameters"].get("additionalProperties"), True)
|
||||
|
||||
def test_never_logged_mcp_not_omitted(self) -> None:
|
||||
tools = [_fn("mcp__ghost__z")]
|
||||
st = FakeToolStore({})
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
self.assertEqual(_names(out), ["mcp__ghost__z"])
|
||||
|
||||
def test_stale_penalty_then_release(self) -> None:
|
||||
t0 = datetime(2026, 4, 19, 12, 0, 0, tzinfo=timezone.utc)
|
||||
stale_ts = (t0 - timedelta(hours=4)).isoformat()
|
||||
usage = {
|
||||
"mcp__st__fill1": {"count": 900, "last_ts": stale_ts},
|
||||
"mcp__st__fill2": {"count": 800, "last_ts": stale_ts},
|
||||
"mcp__st__fill3": {"count": 700, "last_ts": stale_ts},
|
||||
"mcp__st__old": {"count": 5, "last_ts": stale_ts},
|
||||
}
|
||||
tools = [_fn("mcp__st__fill1"), _fn("mcp__st__fill2"), _fn("mcp__st__fill3"), _fn("mcp__st__old")]
|
||||
st = FakeToolStore(usage)
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"},
|
||||
clear=False,
|
||||
):
|
||||
with patch("oclaw.platform.llm.tool_wire_policy._utc_now", return_value=t0):
|
||||
out1 = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
self.assertEqual(_names(out1), ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3"])
|
||||
|
||||
pen = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertEqual(pen["mcp__st__old"]["phase"], "active")
|
||||
|
||||
t_late = t0 + timedelta(minutes=31)
|
||||
with patch("oclaw.platform.llm.tool_wire_policy._utc_now", return_value=t_late):
|
||||
out2 = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
want = ["mcp__st__fill1", "mcp__st__fill2", "mcp__st__fill3", "mcp__st__old"]
|
||||
self.assertEqual(_names(out2), want)
|
||||
pen2 = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertEqual(pen2["mcp__st__old"]["phase"], "done")
|
||||
|
||||
def test_wave_ts_change_clears_penalty(self) -> None:
|
||||
t0 = datetime(2026, 4, 19, 15, 0, 0, tzinfo=timezone.utc)
|
||||
old_ts = (t0 - timedelta(hours=5)).isoformat()
|
||||
usage1 = {"mcp__w__x": {"count": 1, "last_ts": old_ts}}
|
||||
penalty1 = {"mcp__w__x": {"phase": "done", "wave_ts": old_ts}}
|
||||
tools = [_fn("mcp__w__x")]
|
||||
st = FakeToolStore(usage1, penalty1)
|
||||
|
||||
new_ts = (t0 - timedelta(minutes=30)).isoformat()
|
||||
usage2 = {"mcp__w__x": {"count": 2, "last_ts": new_ts}}
|
||||
st.usage_map = usage2
|
||||
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{"OPS_MCP_WIRE_STALE_HOURS": "3", "OPS_MCP_WIRE_TOP_N_FULL": "3"},
|
||||
clear=False,
|
||||
):
|
||||
with patch("oclaw.platform.llm.tool_wire_policy._utc_now", return_value=t0):
|
||||
out = prepare_openai_tools_for_llm_api(
|
||||
tools,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
max_json_bytes=None,
|
||||
store=st,
|
||||
)
|
||||
pen = json.loads(st.last_penalty_saved or "{}")
|
||||
self.assertNotIn("mcp__w__x", pen)
|
||||
self.assertEqual(_names(out), ["mcp__w__x"])
|
||||
|
||||
def test_migrate_legacy_penalty_store(self) -> None:
|
||||
future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
||||
past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
raw = {
|
||||
"legacy_active": {"omit_until": future, "wave_ts": "x"},
|
||||
"legacy_done": {"omit_until": past, "wave_ts": "y"},
|
||||
}
|
||||
mig = migrate_legacy_penalty_store(raw)
|
||||
self.assertEqual(mig["legacy_active"]["phase"], "active")
|
||||
self.assertEqual(mig["legacy_done"]["phase"], "done")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
60
tests/test_tool_wire_policy_role_scoped.py
Normal file
60
tests/test_tool_wire_policy_role_scoped.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.platform.llm.tool_wire_policy import prepare_openai_tools_for_llm_api
|
||||
|
||||
|
||||
def test_manager_role_respects_permanent_ban_by_default(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}},
|
||||
{"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}},
|
||||
]
|
||||
out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager")
|
||||
out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist")
|
||||
mgr_names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"}
|
||||
gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in mgr_names
|
||||
assert "mcp__echo__ping" not in gen_names
|
||||
|
||||
|
||||
def test_role_scoped_policies_override_global(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
# Global ban
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
# Role override allows it (policy 0)
|
||||
store.set_setting("mcp_tool_wire_tool_policies_by_role", '{"ops":{"mcp__echo__ping":0}}')
|
||||
tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}]
|
||||
out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops")
|
||||
out_gen = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="generalist")
|
||||
ops_names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"}
|
||||
gen_names = {t["function"]["name"] for t in out_gen if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" in ops_names
|
||||
assert "mcp__echo__ping" not in gen_names
|
||||
|
||||
|
||||
def test_role_mode_unrestricted_disables_penalty_but_keeps_ban(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_tool_policies", '{"mcp__echo__ping": 9999}')
|
||||
store.set_setting("mcp_tool_wire_role_mode_by_role", '{"manager":"unrestricted"}')
|
||||
tools = [{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}}]
|
||||
out_mgr = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="manager")
|
||||
names = {t["function"]["name"] for t in out_mgr if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in names
|
||||
|
||||
|
||||
def test_role_mode_forbidden_disables_all_mcp(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("mcp_tool_wire_role_mode_by_role", '{"ops":"forbidden"}')
|
||||
tools = [
|
||||
{"type": "function", "function": {"name": "mcp__echo__ping", "description": "Ping", "parameters": {"type": "object"}}},
|
||||
{"type": "function", "function": {"name": "system_time", "description": "t", "parameters": {"type": "object"}}},
|
||||
]
|
||||
out_ops = prepare_openai_tools_for_llm_api(tools, base_url="https://example", max_json_bytes=None, store=store, role="ops")
|
||||
names = {t["function"]["name"] for t in out_ops if isinstance(t, dict) and t.get("type") == "function"}
|
||||
assert "mcp__echo__ping" not in names
|
||||
assert "system_time" in names
|
||||
|
||||
88
tests/test_wecom_longconn_outbound_drain.py
Normal file
88
tests/test_wecom_longconn_outbound_drain.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_wecom_longconn_drains_outbound_on_idle_timeout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
# Import module under test.
|
||||
import oclaw.channels.wecom.longconn_runner as m
|
||||
|
||||
# Build a fake websocket that raises timeout once, then exits by raising KeyboardInterrupt.
|
||||
class _TimeoutOnceWs:
|
||||
def __init__(self) -> None:
|
||||
self.sent: list[str] = []
|
||||
self._recv_calls = 0
|
||||
|
||||
def settimeout(self, _t: float) -> None:
|
||||
return None
|
||||
|
||||
def recv(self) -> str:
|
||||
self._recv_calls += 1
|
||||
if self._recv_calls == 1:
|
||||
# Mimic websocket-client exception type used in module.
|
||||
raise WebSocketTimeoutException()
|
||||
raise KeyboardInterrupt()
|
||||
|
||||
def send(self, data: str) -> None:
|
||||
self.sent.append(data)
|
||||
|
||||
def ping(self) -> None:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
return None
|
||||
|
||||
ws = _TimeoutOnceWs()
|
||||
|
||||
class WebSocketTimeoutException(Exception):
|
||||
pass
|
||||
|
||||
fake_ws_mod = SimpleNamespace(
|
||||
WebSocketTimeoutException=WebSocketTimeoutException,
|
||||
create_connection=lambda *_a, **_k: ws,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "websocket", fake_ws_mod)
|
||||
|
||||
# Patch WeComClient to avoid real credentials.
|
||||
class _Sender:
|
||||
def __init__(self) -> None:
|
||||
self.store = SimpleNamespace(
|
||||
get_setting=lambda _k: "",
|
||||
set_setting=lambda _k, _v: None,
|
||||
)
|
||||
|
||||
def get_bot_credentials(self) -> tuple[str, str]:
|
||||
return ("bot", "secret")
|
||||
|
||||
sender = _Sender()
|
||||
|
||||
# Patch worker threads/queues by replacing queue.Queue with one we can prefill for outbound only.
|
||||
# We do this by monkeypatching the queue constructor used in _run_ws_forever.
|
||||
real_queue = queue.Queue
|
||||
ctor_count = {"n": 0}
|
||||
|
||||
def _queue_ctor(*args: Any, **kwargs: Any) -> Any:
|
||||
ctor_count["n"] += 1
|
||||
q = real_queue(*args, **kwargs)
|
||||
# Second queue constructed is outbound_q in _run_ws_forever.
|
||||
if ctor_count["n"] == 2:
|
||||
q.put({"callback_req_id": "r1", "response_url": "", "text": "hello", "raw_rep": {}})
|
||||
return q
|
||||
|
||||
monkeypatch.setattr(m.queue, "Queue", _queue_ctor)
|
||||
|
||||
# Run; it should exit cleanly via KeyboardInterrupt from fake ws.recv.
|
||||
assert m._run_ws_forever(sender=sender, deliver_outbound=True, use_response_url=False) == 0 # type: ignore[arg-type]
|
||||
|
||||
# Assert that a send happened with expected cmd.
|
||||
assert ws.sent, "expected outbound to be sent during idle timeout drain"
|
||||
obj = json.loads(ws.sent[-1])
|
||||
assert obj.get("cmd") == "aibot_respond_msg"
|
||||
assert (obj.get("body") or {}).get("markdown", {}).get("content") == "hello"
|
||||
|
||||
240
tests/test_wiki_auto_inject_hook.py
Normal file
240
tests/test_wiki_auto_inject_hook.py
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _load_handler_module():
|
||||
p = Path("d:/project/chatgpt/oclaw/hooks/bundled/wiki-auto-inject/handler.py").resolve()
|
||||
spec = importlib.util.spec_from_file_location("wiki_auto_inject_handler_test", str(p))
|
||||
assert spec and spec.loader
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod) # type: ignore[assignment]
|
||||
return mod
|
||||
|
||||
|
||||
def test_collect_snippets_top_k_and_relevance(tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "a.md").write_text(
|
||||
"# A\nRouter VLAN troubleshooting guide\nA guide for vlan and trunk setup.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(wiki / "b.md").write_text(
|
||||
"# B\nUnrelated note.\nAnother line.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(wiki / "c.md").write_text(
|
||||
"# C\nVLAN tagging tips for router.\nrouter vlan quick checklist.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
text, meta = mod._collect_snippets(wiki, "router vlan", max_chars=500, top_k=2)
|
||||
lines = [x for x in text.splitlines() if x.strip()]
|
||||
assert len(lines) == 2
|
||||
assert len(meta) == 2
|
||||
assert "a.md" in text or "c.md" in text
|
||||
assert "b.md" not in text
|
||||
|
||||
|
||||
def test_handle_sets_prepend_system_context(monkeypatch, tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "ops.md").write_text("Router vlan baseline\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_load_config",
|
||||
lambda: {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"wiki_root": str(wiki),
|
||||
"auto": {"enabled": True, "inject": {"max_chars": 800, "top_k": 3}},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
event = types.SimpleNamespace(
|
||||
type="llm",
|
||||
action="before_prompt_build",
|
||||
context={"userText": "router vlan", "prepend_system_context": ""},
|
||||
)
|
||||
mod.handle(event)
|
||||
out = str(event.context.get("prepend_system_context") or "")
|
||||
meta = event.context.get("wiki_inject_meta") or {}
|
||||
assert "Wiki Context (Auto Inject)" in out
|
||||
assert "ops.md" in out
|
||||
assert bool(meta.get("enabled")) is True
|
||||
assert isinstance(meta.get("hits"), list)
|
||||
|
||||
|
||||
def test_handle_ultra_saver_skips_short_query(monkeypatch, tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "ops.md").write_text("router vlan baseline\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_load_config",
|
||||
lambda: {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"wiki_root": str(wiki),
|
||||
"auto": {
|
||||
"enabled": True,
|
||||
"inject": {
|
||||
"max_chars": 800,
|
||||
"top_k": 3,
|
||||
"ultra_saver_enabled": True,
|
||||
"min_query_chars": 20,
|
||||
"require_topic_hint": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
event = types.SimpleNamespace(
|
||||
type="llm",
|
||||
action="before_prompt_build",
|
||||
context={"userText": "hi", "prepend_system_context": ""},
|
||||
)
|
||||
mod.handle(event)
|
||||
assert str(event.context.get("prepend_system_context") or "") == ""
|
||||
meta = event.context.get("wiki_inject_meta") or {}
|
||||
assert bool(meta.get("enabled")) is False
|
||||
assert str(meta.get("skip_reason") or "") == "short_query"
|
||||
|
||||
|
||||
def test_handle_ultra_saver_requires_topic_hint(monkeypatch, tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "ops.md").write_text("router vlan baseline\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_load_config",
|
||||
lambda: {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"wiki_root": str(wiki),
|
||||
"auto": {
|
||||
"enabled": True,
|
||||
"inject": {
|
||||
"max_chars": 800,
|
||||
"top_k": 3,
|
||||
"ultra_saver_enabled": True,
|
||||
"min_query_chars": 5,
|
||||
"require_topic_hint": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
event = types.SimpleNamespace(
|
||||
type="llm",
|
||||
action="before_prompt_build",
|
||||
context={"userText": "summarize this message", "prepend_system_context": ""},
|
||||
)
|
||||
mod.handle(event)
|
||||
assert str(event.context.get("prepend_system_context") or "") == ""
|
||||
meta = event.context.get("wiki_inject_meta") or {}
|
||||
assert bool(meta.get("enabled")) is False
|
||||
assert str(meta.get("skip_reason") or "") == "no_topic_hint"
|
||||
|
||||
|
||||
def test_handle_store_only_mode_skips_injection(monkeypatch, tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
wiki.mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "ops.md").write_text("router vlan baseline\n", encoding="utf-8")
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"_load_config",
|
||||
lambda: {
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"memory-wiki": {
|
||||
"wiki_root": str(wiki),
|
||||
"auto": {"enabled": True, "inject": {"max_chars": 800, "top_k": 3}},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
event = types.SimpleNamespace(
|
||||
type="llm",
|
||||
action="before_prompt_build",
|
||||
context={"userText": "router vlan", "memory_mode": "store_only", "prepend_system_context": ""},
|
||||
)
|
||||
mod.handle(event)
|
||||
assert str(event.context.get("prepend_system_context") or "") == ""
|
||||
meta = event.context.get("wiki_inject_meta") or {}
|
||||
assert bool(meta.get("enabled")) is False
|
||||
assert str(meta.get("skip_reason") or "") == "memory_mode_store_only"
|
||||
|
||||
|
||||
def test_collect_snippets_prioritizes_merged_turns(tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
(wiki / "inbox").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / ".oclaw").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "inbox" / "merged-turns.md").write_text("router vlan merged signal\n", encoding="utf-8")
|
||||
(wiki / "normal.md").write_text("router vlan normal note\n", encoding="utf-8")
|
||||
(wiki / ".oclaw" / "index.json").write_text(
|
||||
'{"files":["normal.md","inbox/merged-turns.md"]}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
text, meta = mod._collect_snippets(wiki, "router vlan", max_chars=800, top_k=1)
|
||||
assert "inbox/merged-turns.md" in text
|
||||
assert meta and str(meta[0].get("source") or "").endswith("merged-turns.md")
|
||||
|
||||
|
||||
def test_collect_snippets_prefers_topic_page_before_merged(tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
(wiki / "inbox").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "topics").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / ".oclaw").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "topics" / "auto-network.md").write_text("router vlan topic page\n", encoding="utf-8")
|
||||
(wiki / "inbox" / "merged-turns.md").write_text("router vlan merged page\n", encoding="utf-8")
|
||||
(wiki / ".oclaw" / "topic-index.json").write_text(
|
||||
'{"topics":{"network":{"path":"topics/auto-network.md","merged_count":3}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
text, meta = mod._collect_snippets(wiki, "router vlan", max_chars=900, top_k=1)
|
||||
assert "topics/auto-network.md" in text
|
||||
assert meta and str(meta[0].get("source") or "").endswith("topics/auto-network.md")
|
||||
|
||||
|
||||
def test_collect_snippets_honors_configured_topic_rules(tmp_path: Path) -> None:
|
||||
mod = _load_handler_module()
|
||||
wiki = tmp_path / "wiki"
|
||||
(wiki / "topics").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / ".oclaw").mkdir(parents=True, exist_ok=True)
|
||||
(wiki / "topics" / "auto-iac.md").write_text("terraform module baseline\n", encoding="utf-8")
|
||||
(wiki / ".oclaw" / "topic-index.json").write_text(
|
||||
'{"topics":{"iac":{"path":"topics/auto-iac.md","merged_count":1}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
rules = [{"topic": "iac", "keywords": ["terraform", "ansible"]}]
|
||||
text, meta = mod._collect_snippets(
|
||||
wiki,
|
||||
"terraform drift",
|
||||
max_chars=800,
|
||||
top_k=1,
|
||||
topic_rules=rules,
|
||||
)
|
||||
assert "topics/auto-iac.md" in text
|
||||
assert meta and str(meta[0].get("source") or "").endswith("topics/auto-iac.md")
|
||||
130
tests/test_wiki_worker_pipeline.py
Normal file
130
tests/test_wiki_worker_pipeline.py
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import OpenClawTask
|
||||
from oclaw.wiki_worker.main import _capture_after_turn, _dedup_merge, _process_task
|
||||
|
||||
|
||||
def _task(task_id: str = "t1") -> OpenClawTask:
|
||||
return OpenClawTask(
|
||||
id=task_id,
|
||||
tenant_id="tenant-1",
|
||||
session_id="session-1",
|
||||
task_type="wiki_capture",
|
||||
status="claimed",
|
||||
payload="{}",
|
||||
result="{}",
|
||||
attempt_count=1,
|
||||
claimed_by="worker",
|
||||
lease_expires_at=None,
|
||||
last_error="",
|
||||
created_at="2026-01-01T00:00:00Z",
|
||||
updated_at="2026-01-01T00:00:00Z",
|
||||
finished_at=None,
|
||||
)
|
||||
|
||||
|
||||
def test_capture_and_dedup_merge_are_incremental(tmp_path: Path) -> None:
|
||||
wiki_root = tmp_path / "wiki"
|
||||
task = _task("t-cap")
|
||||
payload = {
|
||||
"session_id": "s-a",
|
||||
"turn_uuid": "turn-1",
|
||||
"user_text": "hello",
|
||||
"assistant_text": "world",
|
||||
}
|
||||
cap = _capture_after_turn(wiki_root=wiki_root, task=task, payload=payload)
|
||||
assert cap["ok"] is True
|
||||
assert str(cap["staged"]).endswith(".md")
|
||||
|
||||
merge1 = _dedup_merge(wiki_root=wiki_root)
|
||||
assert merge1["ok"] is True
|
||||
assert int(merge1["merged_count"]) == 1
|
||||
|
||||
# A second staging file with identical content should be treated as duplicate.
|
||||
payload2 = {
|
||||
"session_id": "s-a",
|
||||
"turn_uuid": "turn-2",
|
||||
"user_text": "hello",
|
||||
"assistant_text": "world",
|
||||
}
|
||||
_capture_after_turn(wiki_root=wiki_root, task=task, payload=payload2)
|
||||
merge2 = _dedup_merge(wiki_root=wiki_root)
|
||||
assert merge2["ok"] is True
|
||||
assert int(merge2["merged_count"]) == 0
|
||||
assert int(merge2["duplicate_count"]) >= 1
|
||||
|
||||
|
||||
def test_process_task_writes_index_and_lint_report(tmp_path: Path) -> None:
|
||||
wiki_root = tmp_path / "wiki"
|
||||
plugin_cfg = {"wiki_root": str(wiki_root)}
|
||||
payload = {
|
||||
"kind": "captureAfterTurn",
|
||||
"session_id": "s-b",
|
||||
"turn_uuid": "turn-3",
|
||||
"user_text": "query",
|
||||
"assistant_text": "answer",
|
||||
}
|
||||
task = _task("t-proc")
|
||||
task = OpenClawTask(**{**task.__dict__, "payload": json.dumps(payload, ensure_ascii=False)})
|
||||
|
||||
def _wiki_apply(args: dict) -> dict:
|
||||
rel = str(args.get("path") or "").replace("\\", "/").strip("/")
|
||||
fp = wiki_root / rel
|
||||
fp.parent.mkdir(parents=True, exist_ok=True)
|
||||
action = str(args.get("action") or "").strip()
|
||||
content = str(args.get("content") or "")
|
||||
if action == "write":
|
||||
fp.write_text(content, encoding="utf-8")
|
||||
elif action == "append":
|
||||
old = fp.read_text(encoding="utf-8") if fp.exists() else ""
|
||||
sep = "" if not old or old.endswith("\n") else "\n"
|
||||
fp.write_text(old + sep + content, encoding="utf-8")
|
||||
return {"ok": True}
|
||||
|
||||
def _wiki_lint(_args: dict) -> dict:
|
||||
return {"ok": True, "issues": []}
|
||||
|
||||
out = _process_task(task=task, handlers={"wiki_apply": _wiki_apply, "wiki_lint": _wiki_lint}, plugin_cfg=plugin_cfg)
|
||||
assert out["ok"] is True
|
||||
assert (wiki_root / ".oclaw" / "index.json").exists()
|
||||
assert (wiki_root / ".oclaw" / "LINT_REPORT.md").exists()
|
||||
|
||||
|
||||
def test_dedup_merge_routes_into_topic_pages(tmp_path: Path) -> None:
|
||||
wiki_root = tmp_path / "wiki"
|
||||
task = _task("t-topic")
|
||||
payload = {
|
||||
"session_id": "s-topic",
|
||||
"turn_uuid": "turn-topic",
|
||||
"user_text": "please check router vlan",
|
||||
"assistant_text": "network fix applied",
|
||||
}
|
||||
_capture_after_turn(wiki_root=wiki_root, task=task, payload=payload)
|
||||
out = _dedup_merge(wiki_root=wiki_root)
|
||||
assert out["ok"] is True
|
||||
assert int(out["merged_count"]) >= 1
|
||||
assert int((out.get("topic_counts") or {}).get("network", 0)) >= 1
|
||||
assert (wiki_root / "topics" / "auto-network.md").exists()
|
||||
assert (wiki_root / ".oclaw" / "topic-index.json").exists()
|
||||
|
||||
|
||||
def test_dedup_merge_honors_configured_topic_rules(tmp_path: Path) -> None:
|
||||
wiki_root = tmp_path / "wiki"
|
||||
task = _task("t-custom-topic")
|
||||
payload = {
|
||||
"session_id": "s-custom",
|
||||
"turn_uuid": "turn-custom",
|
||||
"user_text": "please use terraform module",
|
||||
"assistant_text": "terraform plan applied",
|
||||
}
|
||||
_capture_after_turn(wiki_root=wiki_root, task=task, payload=payload)
|
||||
out = _dedup_merge(
|
||||
wiki_root=wiki_root,
|
||||
topic_rules=[{"topic": "iac", "keywords": ["terraform", "ansible"]}],
|
||||
)
|
||||
assert out["ok"] is True
|
||||
assert int((out.get("topic_counts") or {}).get("iac", 0)) >= 1
|
||||
assert (wiki_root / "topics" / "auto-iac.md").exists()
|
||||
300
tests/test_workspace_path_guard.py
Normal file
300
tests/test_workspace_path_guard.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.platform.config.paths import db_path
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.tools.experts.workspace.fs_tools import list_files_tool
|
||||
from oclaw.tools.experts.workspace.workspace_base import (
|
||||
access_from_env,
|
||||
build_workspace_path_access,
|
||||
clear_workspace_path_access_for_tests,
|
||||
resolve_workspace_path,
|
||||
workspace_path_access_scope,
|
||||
)
|
||||
|
||||
|
||||
class WorkspacePathGuardTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.root = Path(self._tmp.name) / "ws"
|
||||
self.root.mkdir(parents=True)
|
||||
self.extra = Path(self._tmp.name) / "outside"
|
||||
self.extra.mkdir(parents=True)
|
||||
clear_workspace_path_access_for_tests()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
clear_workspace_path_access_for_tests()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_default_only_under_workspace_root(self) -> None:
|
||||
inner = self.root / "a" / "b.txt"
|
||||
inner.parent.mkdir(parents=True)
|
||||
inner.write_text("x", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""},
|
||||
clear=False,
|
||||
):
|
||||
with workspace_path_access_scope(None, None):
|
||||
p = resolve_workspace_path("a/b.txt")
|
||||
self.assertEqual(p, inner.resolve())
|
||||
with self.assertRaises(ValueError):
|
||||
resolve_workspace_path(str(self.extra))
|
||||
|
||||
def test_extra_roots_from_env(self) -> None:
|
||||
f = self.extra / "f.txt"
|
||||
f.write_text("y", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": str(self.extra),
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
with workspace_path_access_scope(None, None):
|
||||
p = resolve_workspace_path(str(f))
|
||||
self.assertEqual(p, f.resolve())
|
||||
|
||||
def test_glob_tool_accepts_root_parameter(self) -> None:
|
||||
sub = self.root / "sub"
|
||||
sub.mkdir(parents=True)
|
||||
(sub / "a.txt").write_text("a", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
spec = list_files_tool()
|
||||
with workspace_path_access_scope(None, None):
|
||||
r = spec.handler({"root": str(sub), "pattern": "**/*", "max_results": 50})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertEqual(str(sub.resolve()), str(r.get("root")))
|
||||
files = r.get("files") or []
|
||||
self.assertTrue(any("a.txt" in f for f in files))
|
||||
|
||||
def test_allow_any_path_from_env(self) -> None:
|
||||
f = self.extra / "z.txt"
|
||||
f.write_text("z", encoding="utf-8")
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
with workspace_path_access_scope(None, None):
|
||||
p = resolve_workspace_path(str(f))
|
||||
self.assertEqual(p, f.resolve())
|
||||
|
||||
def test_per_user_extra_roots_from_db(self) -> None:
|
||||
f = self.extra / "u.txt"
|
||||
f.write_text("u", encoding="utf-8")
|
||||
dbf = Path(self._tmp.name) / "ops.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u1",
|
||||
display_name="U1",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"x").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
sess = store.create_session("s")
|
||||
sid = str(sess.id)
|
||||
store.ensure_ui_session_owner(session_id=sid, tenant_id=tid, user_id=uid)
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(self.extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""},
|
||||
clear=False,
|
||||
):
|
||||
acc = build_workspace_path_access(store, sid)
|
||||
self.assertIn(self.extra.resolve(), [p.resolve() for p in acc.extra_roots])
|
||||
with workspace_path_access_scope(store, sid):
|
||||
p = resolve_workspace_path(str(f))
|
||||
self.assertEqual(p, f.resolve())
|
||||
|
||||
def test_extra_roots_use_ui_owner_fallback_when_tool_session_is_unowned(self) -> None:
|
||||
"""Specialist steps use a temp chat_session without ui_session_owner; allowlist must follow user session."""
|
||||
f = self.extra / "orphan.txt"
|
||||
f.write_text("x", encoding="utf-8")
|
||||
dbf = Path(self._tmp.name) / "ops2.sqlite"
|
||||
store = SqliteStore(str(dbf))
|
||||
t = store.create_tenant("T2")
|
||||
tid = str(t["id"])
|
||||
u = store.create_user_account(
|
||||
tenant_id=tid,
|
||||
username="u2",
|
||||
display_name="U2",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"z").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
uid = str(u["id"])
|
||||
user_sess = store.create_session_for_user(title="ui", tenant_id=tid, user_id=uid)
|
||||
user_sid = str(user_sess.id)
|
||||
temp_sess = store.create_session("specialist:generalist")
|
||||
temp_sid = str(temp_sess.id)
|
||||
store.upsert_user_workspace_path_allowlist(
|
||||
tenant_id=tid,
|
||||
user_id=uid,
|
||||
extra_roots=str(self.extra),
|
||||
allow_any_path=False,
|
||||
)
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"OPS_WORKSPACE_ROOT": str(self.root), "OPS_WORKSPACE_EXTRA_ROOTS": "", "OPS_WORKSPACE_ALLOW_ANY_PATH": ""},
|
||||
clear=False,
|
||||
):
|
||||
acc = build_workspace_path_access(store, temp_sid)
|
||||
roots_no_fb = [p.resolve() for p in acc.extra_roots]
|
||||
self.assertNotIn(self.extra.resolve(), roots_no_fb)
|
||||
acc2 = build_workspace_path_access(store, temp_sid, owner_fallback_session_id=user_sid)
|
||||
self.assertIn(self.extra.resolve(), [p.resolve() for p in acc2.extra_roots])
|
||||
with workspace_path_access_scope(store, temp_sid, owner_fallback_session_id=user_sid):
|
||||
p = resolve_workspace_path(str(f))
|
||||
self.assertEqual(p, f.resolve())
|
||||
|
||||
|
||||
class WorkspacePathPolicyAdminApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True)
|
||||
self.db = Path(self._tmp.name) / "ops.sqlite"
|
||||
os.environ["OPS_ASSISTANT_DB_PATH"] = str(self.db)
|
||||
os.environ["OPS_ASSISTANT_PASSWORD"] = "test-admin-pass"
|
||||
store = SqliteStore(str(self.db))
|
||||
t = store.create_tenant("Team")
|
||||
self.tenant_id = str(t["id"])
|
||||
store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="administrator",
|
||||
display_name="Admin",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-admin-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.target = store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="member1",
|
||||
display_name="M1",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"y").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
self.client = TestClient(create_app())
|
||||
self.client.post("/admin/api/auth/bootstrap", json={})
|
||||
self.token = self._login()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _login(self) -> str:
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "administrator",
|
||||
"password": "test-admin-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
self.assertTrue(data.get("ok"), data)
|
||||
return str(data.get("token") or "")
|
||||
|
||||
def test_get_post_workspace_path_policy(self) -> None:
|
||||
uid = str(self.target.get("id") or "")
|
||||
g = self.client.get(
|
||||
f"/admin/api/users/workspace-path-policy?tenant_id={self.tenant_id}&user_id={uid}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(g.status_code, 200)
|
||||
self.assertTrue(g.json().get("ok"))
|
||||
self.assertFalse(g.json().get("from_db"))
|
||||
|
||||
p = Path(self._tmp.name) / "allowed_side"
|
||||
p.mkdir(parents=True)
|
||||
resp = self.client.post(
|
||||
"/admin/api/users/workspace-path-policy",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"user_id": uid,
|
||||
"extra_roots": str(p),
|
||||
"allow_any_path": False,
|
||||
},
|
||||
headers=self._headers(),
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
self.assertTrue(resp.json().get("ok"), resp.json())
|
||||
g2 = self.client.get(
|
||||
f"/admin/api/users/workspace-path-policy?tenant_id={self.tenant_id}&user_id={uid}",
|
||||
headers=self._headers(),
|
||||
)
|
||||
data = g2.json()
|
||||
self.assertTrue(data.get("from_db"))
|
||||
self.assertIn(str(p.resolve()), str((data.get("policy") or {}).get("extra_roots") or ""))
|
||||
|
||||
def test_member_workspace_path_policy_self_only(self) -> None:
|
||||
store = SqliteStore(str(self.db))
|
||||
mem = store.create_user_account(
|
||||
tenant_id=self.tenant_id,
|
||||
username="pathmember",
|
||||
display_name="Path Member",
|
||||
role="member",
|
||||
password_hash=hashlib.sha256(b"mem-pass").hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
mid = str(mem.get("id") or "")
|
||||
resp = self.client.post(
|
||||
"/admin/api/auth/login",
|
||||
json={
|
||||
"tenant_id": self.tenant_id,
|
||||
"username": "pathmember",
|
||||
"password": "mem-pass",
|
||||
"purpose": "console",
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
tok = str(resp.json().get("token") or "")
|
||||
self.assertTrue(tok)
|
||||
h = {"authorization": f"Bearer {tok}"}
|
||||
g = self.client.get(
|
||||
f"/admin/api/users/workspace-path-policy?tenant_id={self.tenant_id}&user_id={mid}",
|
||||
headers=h,
|
||||
)
|
||||
self.assertEqual(g.status_code, 200)
|
||||
self.assertTrue(g.json().get("ok"))
|
||||
g2 = self.client.get(
|
||||
f"/admin/api/users/workspace-path-policy?tenant_id={self.tenant_id}&user_id=wrong-user-id",
|
||||
headers=h,
|
||||
)
|
||||
self.assertEqual(g2.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
320
tests/test_ws_gateway.py
Normal file
320
tests/test_ws_gateway.py
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from oclaw.interfaces.http.fastapi_app import create_app
|
||||
from oclaw.openclaw_runtime.gateway import OpenClawGatewayResult
|
||||
|
||||
|
||||
def _connect_params() -> dict:
|
||||
return {
|
||||
"minProtocol": 3,
|
||||
"maxProtocol": 3,
|
||||
"client": {"id": "test", "version": "0.0.0", "platform": "pytest", "mode": "operator"},
|
||||
"role": "operator",
|
||||
"scopes": ["operator.read"],
|
||||
"caps": [],
|
||||
"commands": [],
|
||||
"permissions": {},
|
||||
}
|
||||
|
||||
|
||||
class WsGatewayTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.client = TestClient(create_app())
|
||||
|
||||
def test_ws_requires_connect_first(self) -> None:
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
# server sends connect.challenge first
|
||||
evt = ws.receive_json()
|
||||
assert evt["type"] == "event"
|
||||
assert evt["event"] == "connect.challenge"
|
||||
|
||||
ws.send_json({"type": "req", "id": "1", "method": "agent", "params": {"message": "hi", "idempotencyKey": "k"}})
|
||||
res = ws.receive_json()
|
||||
assert res["type"] == "res"
|
||||
assert res["id"] == "1"
|
||||
assert res["ok"] is False
|
||||
assert (res.get("error") or {}).get("code") == "INVALID_REQUEST"
|
||||
|
||||
def test_ws_connect_returns_hello_ok(self) -> None:
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json() # connect.challenge
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
res = ws.receive_json()
|
||||
assert res["type"] == "res"
|
||||
assert res["id"] == "c1"
|
||||
assert res["ok"] is True
|
||||
hello = res["payload"]
|
||||
assert hello["type"] == "hello-ok"
|
||||
assert int(hello["protocol"]) >= 1
|
||||
assert "snapshot" in hello and "policy" in hello
|
||||
assert "sessions.send" in list((hello.get("features") or {}).get("methods") or [])
|
||||
|
||||
def test_ws_rejects_invalid_frame(self) -> None:
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json() # connect.challenge
|
||||
ws.send_text("not-json")
|
||||
res = ws.receive_json()
|
||||
assert res["type"] == "res"
|
||||
assert res["ok"] is False
|
||||
|
||||
def test_ws_agent_run_emits_events_and_response(self) -> None:
|
||||
def _fake_handle_turn(self, **kwargs): # noqa: ANN001
|
||||
on_progress = kwargs.get("on_progress")
|
||||
on_token = kwargs.get("on_token")
|
||||
on_tool_ui = kwargs.get("on_tool_ui")
|
||||
rid = kwargs.get("run_id") or "run_test"
|
||||
if callable(on_progress):
|
||||
on_progress("oclaw: think (1)…")
|
||||
if callable(on_tool_ui):
|
||||
on_tool_ui("skill", {"ok": True})
|
||||
if callable(on_token):
|
||||
on_token("hello")
|
||||
return OpenClawGatewayResult(
|
||||
run_id=str(rid),
|
||||
reply_text="done",
|
||||
trace_id="trace_test",
|
||||
elapsed_ms=5,
|
||||
mode="sync_direct",
|
||||
task_id=None,
|
||||
selected_specialist="generalist",
|
||||
interaction_mode="comprehensive",
|
||||
)
|
||||
|
||||
with mock.patch("oclaw.openclaw_runtime.gateway.OpenClawGateway.handle_turn", new=_fake_handle_turn):
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json() # connect.challenge
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
ws.receive_json()
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "req",
|
||||
"id": "a1",
|
||||
"method": "agent",
|
||||
"params": {"message": "hi", "idempotencyKey": "k", "sessionId": "sess1"},
|
||||
}
|
||||
)
|
||||
# Events may race ahead of the response; drain until we see res(id=a1).
|
||||
res = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "res" and msg.get("id") == "a1":
|
||||
res = msg
|
||||
break
|
||||
assert res is not None
|
||||
assert res["ok"] is True
|
||||
payload = res["payload"]
|
||||
assert payload["runId"]
|
||||
|
||||
# We should see at least one agent.event (token/progress/tool) and the terminal end.
|
||||
seen_agent_event = False
|
||||
seen_end = False
|
||||
for _ in range(6):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "event":
|
||||
continue
|
||||
if msg.get("event") != "agent.event":
|
||||
continue
|
||||
seen_agent_event = True
|
||||
data = (msg.get("payload") or {}).get("data") or {}
|
||||
if data.get("phase") == "end":
|
||||
seen_end = True
|
||||
break
|
||||
assert seen_agent_event is True
|
||||
assert seen_end is True
|
||||
|
||||
def test_ws_sessions_send_routes_to_agent_flow(self) -> None:
|
||||
def _fake_handle_turn(self, **kwargs): # noqa: ANN001
|
||||
rid = kwargs.get("run_id") or "run_test"
|
||||
return OpenClawGatewayResult(
|
||||
run_id=str(rid),
|
||||
reply_text="done",
|
||||
trace_id="trace_sessions_send",
|
||||
elapsed_ms=7,
|
||||
mode="sync_direct",
|
||||
task_id=None,
|
||||
selected_specialist="generalist",
|
||||
interaction_mode="comprehensive",
|
||||
)
|
||||
|
||||
with mock.patch("oclaw.openclaw_runtime.gateway.OpenClawGateway.handle_turn", new=_fake_handle_turn):
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
ws.receive_json()
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "req",
|
||||
"id": "s1",
|
||||
"method": "sessions.send",
|
||||
"params": {"key": "sessX", "message": "hello from sessions", "idempotencyKey": "idem1"},
|
||||
}
|
||||
)
|
||||
res = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "res" and msg.get("id") == "s1":
|
||||
res = msg
|
||||
break
|
||||
assert res is not None
|
||||
assert res["ok"] is True
|
||||
assert str((res.get("payload") or {}).get("runId") or "").strip() != ""
|
||||
|
||||
def test_ws_agent_run_alias_works(self) -> None:
|
||||
def _fake_handle_turn(self, **kwargs): # noqa: ANN001
|
||||
rid = kwargs.get("run_id") or "run_alias"
|
||||
return OpenClawGatewayResult(
|
||||
run_id=str(rid),
|
||||
reply_text="ok",
|
||||
trace_id="trace_alias",
|
||||
elapsed_ms=2,
|
||||
mode="sync_direct",
|
||||
task_id=None,
|
||||
selected_specialist="generalist",
|
||||
interaction_mode="comprehensive",
|
||||
)
|
||||
|
||||
with mock.patch("oclaw.openclaw_runtime.gateway.OpenClawGateway.handle_turn", new=_fake_handle_turn):
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
hello = ws.receive_json()
|
||||
methods = list(((hello.get("payload") or {}).get("features") or {}).get("methods") or [])
|
||||
assert "agent.run" in methods
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "req",
|
||||
"id": "r1",
|
||||
"method": "agent.run",
|
||||
"params": {"message": "alias", "idempotencyKey": "idem_alias", "sessionId": "sessAlias"},
|
||||
}
|
||||
)
|
||||
res = None
|
||||
for _ in range(10):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") == "res" and msg.get("id") == "r1":
|
||||
res = msg
|
||||
break
|
||||
assert res is not None
|
||||
assert res["ok"] is True
|
||||
|
||||
def test_ws_chat_send_ack_then_final_event(self) -> None:
|
||||
def _fake_handle_turn(self, **kwargs): # noqa: ANN001
|
||||
rid = kwargs.get("run_id") or "run_chat_send"
|
||||
on_token = kwargs.get("on_token")
|
||||
if callable(on_token):
|
||||
on_token("he")
|
||||
on_token("llo")
|
||||
return OpenClawGatewayResult(
|
||||
run_id=str(rid),
|
||||
reply_text="hello",
|
||||
trace_id="trace_chat_send",
|
||||
elapsed_ms=3,
|
||||
mode="sync_direct",
|
||||
task_id=None,
|
||||
selected_specialist="generalist",
|
||||
interaction_mode="comprehensive",
|
||||
)
|
||||
|
||||
with mock.patch("oclaw.openclaw_runtime.gateway.OpenClawGateway.handle_turn", new=_fake_handle_turn):
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
ws.receive_json()
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "req",
|
||||
"id": "cs1",
|
||||
"method": "chat.send",
|
||||
"params": {"sessionKey": "sess-chat", "message": "hi", "idempotencyKey": "idem-chat-1"},
|
||||
}
|
||||
)
|
||||
ack = ws.receive_json()
|
||||
assert ack.get("type") == "res"
|
||||
assert ack.get("id") == "cs1"
|
||||
assert ack.get("ok") is True
|
||||
assert str((ack.get("payload") or {}).get("status") or "") == "started"
|
||||
run_id = str((ack.get("payload") or {}).get("runId") or "")
|
||||
assert run_id.strip() != ""
|
||||
|
||||
seen_final = False
|
||||
for _ in range(12):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "event":
|
||||
continue
|
||||
if msg.get("event") != "chat":
|
||||
continue
|
||||
payload = msg.get("payload") or {}
|
||||
if str(payload.get("runId") or "") != run_id:
|
||||
continue
|
||||
if str(payload.get("state") or "") == "final":
|
||||
seen_final = True
|
||||
break
|
||||
assert seen_final is True
|
||||
|
||||
def test_ws_chat_send_emits_delta_and_session_tool(self) -> None:
|
||||
def _fake_handle_turn(self, **kwargs): # noqa: ANN001
|
||||
rid = kwargs.get("run_id") or "run_chat_send_stream"
|
||||
on_token = kwargs.get("on_token")
|
||||
on_tool_ui = kwargs.get("on_tool_ui")
|
||||
if callable(on_tool_ui):
|
||||
on_tool_ui("skill", {"ok": True})
|
||||
if callable(on_token):
|
||||
on_token("Hel")
|
||||
on_token("lo")
|
||||
return OpenClawGatewayResult(
|
||||
run_id=str(rid),
|
||||
reply_text="Hello",
|
||||
trace_id="trace_chat_send_stream",
|
||||
elapsed_ms=3,
|
||||
mode="sync_direct",
|
||||
task_id=None,
|
||||
selected_specialist="generalist",
|
||||
interaction_mode="comprehensive",
|
||||
)
|
||||
|
||||
with mock.patch("oclaw.openclaw_runtime.gateway.OpenClawGateway.handle_turn", new=_fake_handle_turn):
|
||||
with self.client.websocket_connect("/ws") as ws:
|
||||
ws.receive_json()
|
||||
ws.send_json({"type": "req", "id": "c1", "method": "connect", "params": _connect_params()})
|
||||
ws.receive_json()
|
||||
|
||||
ws.send_json(
|
||||
{
|
||||
"type": "req",
|
||||
"id": "cs2",
|
||||
"method": "chat.send",
|
||||
"params": {"sessionKey": "sess-chat2", "message": "hi", "idempotencyKey": "idem-chat-2"},
|
||||
}
|
||||
)
|
||||
ack = ws.receive_json()
|
||||
assert ack.get("type") == "res"
|
||||
assert ack.get("id") == "cs2"
|
||||
assert ack.get("ok") is True
|
||||
|
||||
seen_delta = False
|
||||
seen_tool = False
|
||||
seen_final = False
|
||||
for _ in range(30):
|
||||
msg = ws.receive_json()
|
||||
if msg.get("type") != "event":
|
||||
continue
|
||||
if msg.get("event") == "session.tool":
|
||||
seen_tool = True
|
||||
if msg.get("event") != "chat":
|
||||
continue
|
||||
payload = msg.get("payload") or {}
|
||||
st = str(payload.get("state") or "")
|
||||
if st == "delta":
|
||||
seen_delta = True
|
||||
if st == "final":
|
||||
seen_final = True
|
||||
break
|
||||
assert seen_delta is True
|
||||
assert seen_tool is True
|
||||
assert seen_final is True
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue