mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-16 19:00:47 +08:00
完善 agent/workspace 运行链路与工具结果治理测试覆盖,确保路由、标题生成与上下文截断行为稳定可回归。
同时将 data/wiki 用户数据纳入忽略规则,避免运行态数据被误纳入版本管理。 Made-with: Cursor
This commit is contained in:
parent
45e412b4ab
commit
20362527e2
17 changed files with 974 additions and 41 deletions
|
|
@ -127,6 +127,28 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
self.assertEqual(str(body.get("mode") or ""), "async_task")
|
||||
self.assertTrue(str(body.get("task_id") or "").strip())
|
||||
|
||||
def test_async_task_first_turn_renames_new_chat_title(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
created = self.client.post("/admin/api/chat/sessions", headers=headers, json={}).json()
|
||||
sid = str(((created.get("session") or {}).get("id")) or "")
|
||||
self.assertTrue(sid)
|
||||
send = self.client.post(
|
||||
f"/admin/api/chat/sessions/{sid}/messages",
|
||||
headers=headers,
|
||||
json={"text": "请总结并发送到项目群"},
|
||||
)
|
||||
self.assertEqual(send.status_code, 200)
|
||||
self.assertTrue(send.json().get("ok"), send.json())
|
||||
sess = self.store.get_session(sid)
|
||||
self.assertIsNotNone(sess)
|
||||
title = str(getattr(sess, "title", "") or "")
|
||||
self.assertNotIn(title, {"新会话", "New Chat"})
|
||||
|
||||
def test_async_task_payload_contains_selected_specialist(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
|
|
@ -209,6 +231,71 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
self.assertEqual(str(payload.get("requested_specialist") or ""), "ops")
|
||||
self.assertEqual(str(payload.get("memory_mode") or ""), "store_only")
|
||||
|
||||
def test_new_session_inherits_user_mode_preference(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
set_resp = 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(set_resp.status_code, 200)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/admin/api/chat/sessions",
|
||||
headers=headers,
|
||||
json={"title": "new one"},
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, 200)
|
||||
created = create_resp.json()
|
||||
self.assertTrue(created.get("ok"), created)
|
||||
new_session_id = str(((created.get("session") or {}).get("id")) or "")
|
||||
self.assertTrue(new_session_id)
|
||||
|
||||
mode_resp = self.client.get(
|
||||
f"/admin/api/chat/sessions/{new_session_id}/mode",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(mode_resp.status_code, 200)
|
||||
mode_body = mode_resp.json()
|
||||
self.assertTrue(mode_body.get("ok"), mode_body)
|
||||
self.assertEqual(str(mode_body.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(mode_body.get("specialist") or ""), "ops")
|
||||
self.assertEqual(str(mode_body.get("memory_mode") or ""), "store_only")
|
||||
|
||||
def test_new_session_default_mode_is_expert_generalist(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
create_resp = self.client.post(
|
||||
"/admin/api/chat/sessions",
|
||||
headers=headers,
|
||||
json={"title": "default mode chat"},
|
||||
)
|
||||
self.assertEqual(create_resp.status_code, 200)
|
||||
created = create_resp.json()
|
||||
self.assertTrue(created.get("ok"), created)
|
||||
new_session_id = str(((created.get("session") or {}).get("id")) or "")
|
||||
self.assertTrue(new_session_id)
|
||||
|
||||
mode_resp = self.client.get(
|
||||
f"/admin/api/chat/sessions/{new_session_id}/mode",
|
||||
headers={"authorization": f"Bearer {token}", "accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(mode_resp.status_code, 200)
|
||||
mode_body = mode_resp.json()
|
||||
self.assertTrue(mode_body.get("ok"), mode_body)
|
||||
self.assertEqual(str(mode_body.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(mode_body.get("specialist") or ""), "generalist")
|
||||
self.assertEqual(str(mode_body.get("memory_mode") or ""), "default")
|
||||
|
||||
def test_admin_dynamic_expert_stats_endpoint(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
|
|
@ -322,6 +409,13 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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)
|
||||
# newly added replay caps should exist with sane defaults
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("image_result_replay_cap_chars") or 0), 600)
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("video_result_replay_cap_chars") or 0), 600)
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("video_transcript_chunk_size") or 0), 1)
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("video_transcript_chunk_overlap") or 0), 0)
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("archive_max_depth") or 0), 1)
|
||||
self.assertGreaterEqual(int((get_body.get("limits") or {}).get("archive_max_file_count") or 0), 1)
|
||||
|
||||
set_resp = self.client.post(
|
||||
"/admin/api/chat/settings/attachment-limits",
|
||||
|
|
@ -334,6 +428,12 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
"max_excel_sheets": 8,
|
||||
"large_table_preview_rows": 33,
|
||||
"sql_timeout_ms": 1234,
|
||||
"video_transcript_chunk_size": 1700,
|
||||
"video_transcript_chunk_overlap": 240,
|
||||
"archive_max_depth": 3,
|
||||
"archive_max_file_count": 555,
|
||||
"archive_max_entry_bytes": 123456,
|
||||
"archive_max_total_uncompressed_bytes": 654321,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -347,6 +447,12 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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)
|
||||
self.assertEqual(int(limits.get("video_transcript_chunk_size") or 0), 1700)
|
||||
self.assertEqual(int(limits.get("video_transcript_chunk_overlap") or 0), 240)
|
||||
self.assertEqual(int(limits.get("archive_max_depth") or 0), 3)
|
||||
self.assertEqual(int(limits.get("archive_max_file_count") or 0), 555)
|
||||
self.assertEqual(int(limits.get("archive_max_entry_bytes") or 0), 123456)
|
||||
self.assertEqual(int(limits.get("archive_max_total_uncompressed_bytes") or 0), 654321)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
33
tests/test_agent_core_attempt_memory_gate.py
Normal file
33
tests/test_agent_core_attempt_memory_gate.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.runtime.agent_core_attempt import _should_after_turn_memory
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
|
||||
def _msg(interaction_mode: str, extra: dict | None = None) -> StandardMessage:
|
||||
md = {"interaction_mode": interaction_mode}
|
||||
if isinstance(extra, dict):
|
||||
md.update(extra)
|
||||
return StandardMessage(
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="member",
|
||||
channel="admin_chat",
|
||||
text="hello",
|
||||
attachments=[],
|
||||
metadata=md,
|
||||
)
|
||||
|
||||
|
||||
def test_after_turn_memory_disabled_for_comprehensive_mode() -> None:
|
||||
assert _should_after_turn_memory(_msg("comprehensive")) is False
|
||||
|
||||
|
||||
def test_after_turn_memory_disabled_for_expert_mode_by_default() -> None:
|
||||
assert _should_after_turn_memory(_msg("expert")) is False
|
||||
|
||||
|
||||
def test_after_turn_memory_cannot_be_forced_by_metadata() -> None:
|
||||
assert _should_after_turn_memory(_msg("expert", {"enable_after_turn_memory": True})) is False
|
||||
|
||||
|
|
@ -275,6 +275,65 @@ def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch
|
|||
assert chosen.get("sid") == "image"
|
||||
assert captured.get("exec_text") == "Please edit the image background."
|
||||
|
||||
|
||||
def test_gateway_comprehensive_mode_writes_task_assignment_reasoning(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
written: list[dict] = []
|
||||
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def add_message(self, **kwargs: object) -> None:
|
||||
written.append(dict(kwargs))
|
||||
|
||||
class _ManagerModel:
|
||||
def chat(self, _messages, _tools, *, on_token=None):
|
||||
return LLMResponse(
|
||||
content='{"route":{"specialist":"ops","reason":"ops task"},"dispatch":{"instruction_text":"请检查并修复网关启动失败。"}}',
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
class _Exec:
|
||||
def __init__(self, model=None):
|
||||
self.model = model
|
||||
self.tools = object()
|
||||
self.system_prompt = ""
|
||||
|
||||
monkeypatch.setattr(
|
||||
"oclaw.runtime.gateway.get_manager_prompt_prebuild",
|
||||
lambda **kwargs: {
|
||||
"manager_context": "manager",
|
||||
"allowed_fixed": ("generalist", "ops", "image", "memory"),
|
||||
"allowed_fixed_quoted": '"generalist", "ops", "image", "memory"',
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"oclaw.runtime.gateway.run_agent_core",
|
||||
lambda **kwargs: SimpleNamespace(outcome=SimpleNamespace(final_text="specialist_answer")),
|
||||
)
|
||||
|
||||
gw = OclawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-assign-1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="网关起不来,帮我修复",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "comprehensive"},
|
||||
)
|
||||
_ = gw.handle_turn(msg=msg, lang="zh", executor=_Exec(model=_ManagerModel()))
|
||||
reasoning_rows = [x for x in written if str(x.get("event_type") or "") == "reasoning"]
|
||||
assert reasoning_rows, "expected task-assignment reasoning row"
|
||||
content = str(reasoning_rows[-1].get("content") or "")
|
||||
assert "任务分配" in content
|
||||
assert "specialist=ops" in content
|
||||
assert "请检查并修复网关启动失败" in content
|
||||
|
||||
def test_gateway_comprehensive_mode_has_manager_final_pass(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
|
|
|
|||
|
|
@ -41,3 +41,40 @@ def test_oclaw_tool_result_context_guard_truncates_large_tool_message(tmp_path:
|
|||
assert len(guarded) <= _OCLAW_TOOL_RESULT_HARD_CAP_CHARS + 2000
|
||||
assert "_tool_result_guarded" in guarded
|
||||
|
||||
|
||||
def test_oclaw_tool_result_context_guard_skips_active_turn_tool_messages(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
sess = store.create_session("t")
|
||||
turn_uuid = "turn-active-1"
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": "c1", "name": "echo", "arguments": {"x": 1}}],
|
||||
turn_uuid=turn_uuid,
|
||||
)
|
||||
huge = "X" * (_OCLAW_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},
|
||||
turn_uuid=turn_uuid,
|
||||
)
|
||||
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,
|
||||
active_turn_uuid=turn_uuid,
|
||||
)
|
||||
tool_msgs = [m for m in msgs if m.get("role") == "tool"]
|
||||
assert tool_msgs, msgs
|
||||
raw = str(tool_msgs[-1].get("content") or "")
|
||||
assert "_tool_result_guarded" not in raw
|
||||
|
||||
|
|
|
|||
59
tests/test_oclaw_worker_title.py
Normal file
59
tests/test_oclaw_worker_title.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
from oclaw.runtime.worker import _maybe_generate_title_on_third_round
|
||||
|
||||
|
||||
class _DummyResp:
|
||||
def __init__(self, content: str) -> None:
|
||||
self.content = content
|
||||
|
||||
|
||||
class _DummyModel:
|
||||
def chat(self, _messages, _tools, on_token=None): # noqa: ANN001
|
||||
return _DummyResp("第三轮标题")
|
||||
|
||||
|
||||
def test_worker_third_round_title_generation_updates_stage3(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "worker-title.sqlite"))
|
||||
tenant = store.create_tenant("Team")
|
||||
user = store.create_user_account(
|
||||
tenant_id=str(tenant["id"]),
|
||||
username="tester",
|
||||
display_name="Tester",
|
||||
role="owner",
|
||||
password_hash=hashlib.sha256("test-pass".encode("utf-8")).hexdigest(),
|
||||
is_active=True,
|
||||
)
|
||||
session = store.create_session_for_user(
|
||||
title="第一轮标题",
|
||||
tenant_id=str(tenant["id"]),
|
||||
user_id=str(user["id"]),
|
||||
)
|
||||
sid = str(session.id)
|
||||
store.set_setting(f"AIA_SESSION_AUTO_TITLE_STAGE:{sid}", "1")
|
||||
store.add_message(session_id=sid, role="user", content="第一轮问题")
|
||||
store.add_message(session_id=sid, role="assistant", content="第一轮回答")
|
||||
store.add_message(session_id=sid, role="user", content="第二轮问题")
|
||||
store.add_message(session_id=sid, role="assistant", content="第二轮回答")
|
||||
|
||||
msg = StandardMessage(
|
||||
session_id=sid,
|
||||
tenant_id=str(tenant["id"]),
|
||||
user_id=str(user["id"]),
|
||||
role="member",
|
||||
channel="admin_chat",
|
||||
text="第三轮问题",
|
||||
attachments=[],
|
||||
metadata={"lang": "zh"},
|
||||
)
|
||||
_maybe_generate_title_on_third_round(store=store, msg=msg, model=_DummyModel())
|
||||
|
||||
renamed = store.get_session(sid)
|
||||
assert renamed is not None
|
||||
assert str(getattr(renamed, "title", "") or "") == "第三轮标题"
|
||||
assert str(store.get_setting(f"AIA_SESSION_AUTO_TITLE_STAGE:{sid}") or "") == "3"
|
||||
|
|
@ -3,7 +3,12 @@ from __future__ import annotations
|
|||
import json
|
||||
import unittest
|
||||
|
||||
from oclaw.runtime.chat.tool_runtime import truncate_tool_result_for_llm_messages, tool_llm_message_max_chars
|
||||
from oclaw.runtime.chat.tool_runtime import (
|
||||
compact_turn_tool_messages_for_storage,
|
||||
tool_llm_message_max_chars,
|
||||
truncate_tool_result_for_llm_messages,
|
||||
)
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
|
||||
|
||||
class ToolLlmTruncationTests(unittest.TestCase):
|
||||
|
|
@ -28,6 +33,46 @@ class ToolLlmTruncationTests(unittest.TestCase):
|
|||
with mock.patch.dict(os.environ, {"AIA_TOOL_LLM_MESSAGE_MAX_CHARS": "9000"}, clear=False):
|
||||
self.assertEqual(tool_llm_message_max_chars(), 9000)
|
||||
|
||||
def test_compact_turn_tool_messages_for_storage(self) -> None:
|
||||
import tempfile
|
||||
import uuid
|
||||
|
||||
db = f"{tempfile.gettempdir()}/oclaw-test-{uuid.uuid4().hex}.sqlite"
|
||||
store = SqliteStore(db)
|
||||
sess = store.create_session("t")
|
||||
turn_uuid = "turn-1"
|
||||
store.add_message(
|
||||
session_id=sess.id,
|
||||
role="assistant",
|
||||
content="",
|
||||
tool_calls=[{"id": "c1", "name": "echo", "arguments": {"x": 1}}],
|
||||
turn_uuid=turn_uuid,
|
||||
)
|
||||
huge = "x" * 200_000
|
||||
row = store.add_message(
|
||||
session_id=sess.id,
|
||||
role="tool",
|
||||
content=json.dumps({"ok": True, "blob": huge}, ensure_ascii=False),
|
||||
tool_calls={"tool_call_id": "c1", "name": "echo", "assistant_message_id": 1},
|
||||
turn_uuid=turn_uuid,
|
||||
)
|
||||
before = store.get_messages(session_id=sess.id, limit=20)
|
||||
before_tool = [m for m in before if m.id == row.id][0]
|
||||
self.assertNotIn("_truncated_for_llm", str(before_tool.content or ""))
|
||||
from unittest import mock
|
||||
|
||||
with mock.patch.dict("os.environ", {"AIA_TOOL_LLM_MESSAGE_MAX_CHARS": "8000"}, clear=False):
|
||||
stats = compact_turn_tool_messages_for_storage(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
turn_uuid=turn_uuid,
|
||||
)
|
||||
self.assertGreaterEqual(int(stats.get("scanned") or 0), 1)
|
||||
self.assertGreaterEqual(int(stats.get("updated") or 0), 1)
|
||||
after = store.get_messages(session_id=sess.id, limit=20)
|
||||
after_tool = [m for m in after if m.id == row.id][0]
|
||||
self.assertIn("_truncated_for_llm", str(after_tool.content or ""))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -165,3 +165,52 @@ def test_signature_metadata_can_be_forced_on_via_env(monkeypatch) -> None:
|
|||
tc = (assistant.get("tool_calls") or [])[0]
|
||||
assert tc.get("extra_content", {}).get("google", {}).get("thought_signature") == "sig_abc"
|
||||
|
||||
|
||||
def test_current_turn_tool_content_not_clipped_with_details_phrase() -> None:
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg(
|
||||
"assistant",
|
||||
"",
|
||||
tool_calls=json.dumps([{"id": "call_now", "name": "t", "arguments": {}}], ensure_ascii=False),
|
||||
event_type="tool_call",
|
||||
turn_uuid="turn-now",
|
||||
),
|
||||
_Msg(
|
||||
"tool",
|
||||
json.dumps({"ok": True, "blob": "x" * 1200}, ensure_ascii=False),
|
||||
tool_calls=json.dumps({"tool_call_id": "call_now", "name": "t"}, ensure_ascii=False),
|
||||
event_type="tool_result",
|
||||
turn_uuid="turn-now",
|
||||
),
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh", tool_context_truncate_enabled=True)
|
||||
tool_rows = [m for m in msgs if m.get("role") == "tool"]
|
||||
assert tool_rows
|
||||
assert "详情请重新阅读" not in str(tool_rows[-1].get("content") or "")
|
||||
|
||||
|
||||
def test_historical_tool_content_can_include_details_phrase_when_clamped(monkeypatch) -> None:
|
||||
monkeypatch.setenv("AIA_REPLAY_TOOL_FULL_ROUNDS", "0")
|
||||
model = RuleBasedChatModel()
|
||||
rows = [
|
||||
_Msg(
|
||||
"assistant",
|
||||
"",
|
||||
tool_calls=json.dumps([{"id": "call_hist", "name": "t", "arguments": {}}], ensure_ascii=False),
|
||||
event_type="tool_call",
|
||||
turn_uuid="turn-hist",
|
||||
),
|
||||
_Msg(
|
||||
"tool",
|
||||
json.dumps({"ok": True, "blob": "x" * 1600}, ensure_ascii=False),
|
||||
tool_calls=json.dumps({"tool_call_id": "call_hist", "name": "t"}, ensure_ascii=False),
|
||||
event_type="tool_result",
|
||||
turn_uuid="turn-hist",
|
||||
),
|
||||
]
|
||||
msgs = build_llm_messages(store_messages=rows, system_prompt="s", model=model, lang="zh", tool_context_truncate_enabled=True)
|
||||
tool_rows = [m for m in msgs if m.get("role") == "tool"]
|
||||
assert tool_rows
|
||||
assert "详情请重新阅读" in str(tool_rows[-1].get("content") or "")
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,15 @@ 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.runtime.tools.experts.workspace.fs_tools import list_files_tool, write_file_tool
|
||||
from oclaw.runtime.tools.experts.workspace.shell_tools import run_command_tool
|
||||
from oclaw.runtime.tools.experts.workspace.workspace_base import (
|
||||
access_from_env,
|
||||
build_workspace_path_access,
|
||||
clear_workspace_path_access_for_tests,
|
||||
current_workspace_write_namespace,
|
||||
resolve_workspace_path,
|
||||
workspace_path_access_scope,
|
||||
workspace_write_namespace_scope,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -111,10 +114,223 @@ class WorkspacePathGuardTests(unittest.TestCase):
|
|||
with workspace_path_access_scope(None, None):
|
||||
r = spec.handler({"path": "generated.py", "content": "print('ok')\n", "mode": "overwrite"})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
expected = (self.root / "data" / self.root.name / "generated.py").resolve()
|
||||
expected = (self.root / "data" / "workspace" / "generated.py").resolve()
|
||||
self.assertEqual(str(expected), str(r.get("path")))
|
||||
self.assertTrue(expected.exists())
|
||||
|
||||
def test_write_file_relative_path_uses_workspace_namespace_scope(self) -> None:
|
||||
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 = write_file_tool()
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
self.assertEqual(current_workspace_write_namespace(), "ops")
|
||||
r = spec.handler({"path": "generated.py", "content": "print('ok')\n", "mode": "overwrite"})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
expected = (self.root / "data" / "workspace" / "generated.py").resolve()
|
||||
self.assertEqual(str(expected), str(r.get("path")))
|
||||
self.assertTrue(expected.exists())
|
||||
|
||||
def test_write_file_absolute_path_is_forced_into_workspace_sandbox(self) -> None:
|
||||
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 = write_file_tool()
|
||||
abs_target = str((self.root / "count_items.py").resolve())
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"path": abs_target, "content": "print('ok')\n", "mode": "overwrite"})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
expected = (self.root / "data" / "workspace" / "count_items.py").resolve()
|
||||
self.assertEqual(str(expected), str(r.get("path")))
|
||||
self.assertTrue(expected.exists())
|
||||
|
||||
def test_run_command_default_cwd_uses_workspace_namespace_sandbox(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
spec = run_command_tool()
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"command": "python -c \"print('ok')\""})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
expected_cwd = (self.root / "data" / "workspace").resolve()
|
||||
self.assertEqual(str(expected_cwd), str(r.get("cwd")))
|
||||
|
||||
def test_run_command_strips_leading_cd_chain_in_default_sandbox(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
spec = run_command_tool()
|
||||
cmd = f'cd /d "{self.root}" && python -c "print(123)"'
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"command": cmd})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("normalized_cd_removed")))
|
||||
expected_cwd = (self.root / "data" / "workspace").resolve()
|
||||
self.assertEqual(str(expected_cwd), str(r.get("cwd")))
|
||||
self.assertIn("123", str(r.get("output") or ""))
|
||||
|
||||
def test_run_command_strips_windows_drive_prefix_cd_chain(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
ws_root = self.root / "data" / "workspace"
|
||||
ws_root.mkdir(parents=True, exist_ok=True)
|
||||
(ws_root / "count_directory.py").write_text("print('drive-cd-ok')\n", encoding="utf-8")
|
||||
spec = run_command_tool()
|
||||
cmd = f'D: && cd /d "{self.root}" && python count_directory.py'
|
||||
with workspace_path_access_scope(None, None):
|
||||
r = spec.handler({"command": cmd})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("normalized_cd_removed")), r)
|
||||
self.assertTrue(bool(r.get("script_path_rewritten")), r)
|
||||
self.assertIn("drive-cd-ok", str(r.get("output") or ""))
|
||||
|
||||
def test_run_command_output_flags_distinguish_empty_from_truncation(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
spec = run_command_tool()
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"command": 'python -c "pass"'})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("output_empty")))
|
||||
self.assertFalse(bool(r.get("output_truncated")))
|
||||
self.assertTrue(bool(r.get("output_not_truncated")))
|
||||
self.assertEqual(str(r.get("error_code") or ""), "")
|
||||
|
||||
def test_run_command_nonzero_exit_marks_failure_not_truncation(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
spec = run_command_tool()
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"command": 'python -c "import sys; sys.exit(3)"'})
|
||||
self.assertFalse(bool(r.get("ok")))
|
||||
self.assertEqual(int(r.get("exit_code") or 0), 3)
|
||||
self.assertEqual(str(r.get("error_code") or ""), "command_exit_nonzero")
|
||||
self.assertFalse(bool(r.get("output_truncated")))
|
||||
|
||||
def test_run_command_rewrites_workspace_absolute_script_path_to_sandbox(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
# Prepare script inside sandbox, but command will reference repo-root absolute path.
|
||||
ws_script = self.root / "data" / "workspace" / "count_files.py"
|
||||
ws_script.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_script.write_text("print('sandbox-ok')\n", encoding="utf-8")
|
||||
spec = run_command_tool()
|
||||
absolute_repo_script = str((self.root / "count_files.py").resolve())
|
||||
with workspace_path_access_scope(None, None), workspace_write_namespace_scope("ops"):
|
||||
r = spec.handler({"command": f'python "{absolute_repo_script}"'})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("command_rewritten")), r)
|
||||
self.assertIn("sandbox-ok", str(r.get("output") or ""))
|
||||
|
||||
def test_run_command_explicit_repo_root_cwd_is_redirected_to_sandbox(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
ws_script = self.root / "data" / "workspace" / "count_files.py"
|
||||
ws_script.parent.mkdir(parents=True, exist_ok=True)
|
||||
ws_script.write_text("print('redirect-ok')\n", encoding="utf-8")
|
||||
spec = run_command_tool()
|
||||
abs_repo_script = str((self.root / "count_files.py").resolve())
|
||||
with workspace_path_access_scope(None, None):
|
||||
r = spec.handler(
|
||||
{
|
||||
"command": f'python "{abs_repo_script}"',
|
||||
"cwd": str(self.root),
|
||||
}
|
||||
)
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("cwd_redirected_to_sandbox")), r)
|
||||
self.assertTrue(bool(r.get("command_rewritten")), r)
|
||||
self.assertIn("redirect-ok", str(r.get("output") or ""))
|
||||
|
||||
def test_run_command_rewrites_relative_python_script_to_sandbox_root(self) -> None:
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"OPS_WORKSPACE_ROOT": str(self.root),
|
||||
"OPS_WORKSPACE_EXTRA_ROOTS": "",
|
||||
"OPS_WORKSPACE_ALLOW_ANY_PATH": "",
|
||||
"AIA_ENABLE_RUN_COMMAND": "1",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
clear_workspace_path_access_for_tests()
|
||||
ws_root = self.root / "data" / "workspace"
|
||||
ws_root.mkdir(parents=True, exist_ok=True)
|
||||
(ws_root / "count_directory.py").write_text("print('found-in-sandbox-root')\n", encoding="utf-8")
|
||||
spec = run_command_tool()
|
||||
with workspace_path_access_scope(None, None):
|
||||
r = spec.handler({"command": "python count_directory.py"})
|
||||
self.assertTrue(r.get("ok"), r)
|
||||
self.assertTrue(bool(r.get("script_path_rewritten")), r)
|
||||
self.assertIn("found-in-sandbox-root", str(r.get("output") or ""))
|
||||
|
||||
def test_per_user_extra_roots_from_db(self) -> None:
|
||||
f = self.extra / "u.txt"
|
||||
f.write_text("u", encoding="utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue