初始化:独立 oclaw 仓库首提交

- 在 oclaw/ 下重新初始化 Git 仓库
- 补齐子仓库 .gitignore,避免提交本地运行态数据(_local、node_modules、logs 等)
- 提交当前工程代码与配置

Made-with: Cursor
This commit is contained in:
oliver 2026-04-24 22:31:22 +08:00
commit ba3836f00f
579 changed files with 83112 additions and 0 deletions

36
tests/conftest.py Normal file
View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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()

View 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"

View 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"]

View 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)

View 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

View 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()

View 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()

View 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()

View 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()

View 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 "")

View 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

View 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

View 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")

View 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"

View 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

View 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

View 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}

View 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
View 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
View 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()

View 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()

View 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()

View 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
View 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()

View 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
View 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()

View 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
View 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()

View 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()

View 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()

View 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

View 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)

View 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"

View 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()

View 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 == {}

View 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"

View 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

View 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 == ("会话",)

View 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"]

View 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

View 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")

View 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

View 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"

View 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
)

View 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"

View 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

View 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"

View 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"

View 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

View 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"

View 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")

View 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

View 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

View 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

View 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
View 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

View 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"

View 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"

View 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

View 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

View 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

View 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"]

View 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"

View 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()

View 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

View 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"

View 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"]

View 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()

View 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()

View 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

View 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]

View 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()

View 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)

View 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"

View 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()

View 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

View 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"

View 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")

View 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()

View 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
View 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