mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-13 03:30:46 +08:00
feat: plan agent v2, global chat mode, and user-mode v2 flag sync
- Add runtime/plan_agent_v2 package and shims; gateway/direct_loop/WS wiring - Admin chat: interaction mode and specialist only in user menu; session API stores memory_mode and execution_mode only - POST /admin/api/chat/user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2 to 1, v1 to 0) - Composer cleanup (hidden mode select, no reasoning toggle in meta bar); tests and _local/system.env.example Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
ae44cbcad5
commit
37522f492a
49 changed files with 4146 additions and 238 deletions
|
|
@ -172,6 +172,46 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
payload = json.loads(str(task.payload or "{}"))
|
||||
self.assertEqual(str(payload.get("selected_specialist") or ""), "generalist")
|
||||
|
||||
def test_user_mode_plan_version_sets_v2_feature_flag_in_store(self) -> None:
|
||||
"""POST /user-mode mirrors plan_agent_version to AIA_EXPERT_PLAN_AGENT_V2_ENABLED (v2→1, v1→0)."""
|
||||
token = self._login()
|
||||
headers = {
|
||||
"authorization": f"Bearer {token}",
|
||||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
r2 = self.client.post(
|
||||
"/admin/api/chat/user-mode",
|
||||
headers=headers,
|
||||
json={
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "generalist",
|
||||
"confirm_strategy": "strict",
|
||||
"plan_agent_version": "v2",
|
||||
},
|
||||
)
|
||||
self.assertEqual(r2.status_code, 200)
|
||||
body2 = r2.json()
|
||||
self.assertTrue(body2.get("ok"), body2)
|
||||
self.assertTrue(body2.get("plan_agent_v2_globally_enabled"), body2)
|
||||
self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "1")
|
||||
|
||||
r1 = self.client.post(
|
||||
"/admin/api/chat/user-mode",
|
||||
headers=headers,
|
||||
json={
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "generalist",
|
||||
"confirm_strategy": "strict",
|
||||
"plan_agent_version": "v1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(r1.status_code, 200)
|
||||
body1 = r1.json()
|
||||
self.assertTrue(body1.get("ok"), body1)
|
||||
self.assertFalse(body1.get("plan_agent_v2_globally_enabled"), body1)
|
||||
self.assertEqual(str(self.store.get_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED") or "").strip(), "0")
|
||||
|
||||
def test_session_mode_setting_roundtrip(self) -> None:
|
||||
token = self._login()
|
||||
headers = {
|
||||
|
|
@ -179,6 +219,16 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
_ = self.client.post(
|
||||
"/admin/api/chat/user-mode",
|
||||
headers=headers,
|
||||
json={
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "generalist",
|
||||
"confirm_strategy": "auto",
|
||||
"plan_agent_version": "v1",
|
||||
},
|
||||
)
|
||||
resp1 = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
headers=headers,
|
||||
|
|
@ -188,8 +238,10 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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")
|
||||
# Session POST ignores interaction/specialist; user menu still has generalist.
|
||||
self.assertEqual(str(body1.get("specialist") or ""), "generalist")
|
||||
self.assertEqual(str(body1.get("memory_mode") or ""), "store_only")
|
||||
self.assertEqual(str(body1.get("confirm_strategy") or ""), "auto")
|
||||
|
||||
resp2 = self.client.get(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
|
|
@ -199,8 +251,9 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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("specialist") or ""), "generalist")
|
||||
self.assertEqual(str(body2.get("memory_mode") or ""), "store_only")
|
||||
self.assertEqual(str(body2.get("confirm_strategy") or ""), "auto")
|
||||
|
||||
def test_messages_use_session_mode_when_payload_omits_mode(self) -> None:
|
||||
token = self._login()
|
||||
|
|
@ -209,10 +262,20 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
_ = self.client.post(
|
||||
"/admin/api/chat/user-mode",
|
||||
headers=headers,
|
||||
json={
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "ops",
|
||||
"confirm_strategy": "strict",
|
||||
"plan_agent_version": "v1",
|
||||
},
|
||||
)
|
||||
_ = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
headers=headers,
|
||||
json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"},
|
||||
json={"memory_mode": "store_only"},
|
||||
)
|
||||
resp = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/messages",
|
||||
|
|
@ -238,12 +301,17 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
"accept": "application/json",
|
||||
"content-type": "application/json",
|
||||
}
|
||||
set_resp = self.client.post(
|
||||
f"/admin/api/chat/sessions/{self.session_id}/mode",
|
||||
pref = self.client.post(
|
||||
"/admin/api/chat/user-mode",
|
||||
headers=headers,
|
||||
json={"interaction_mode": "expert", "specialist": "ops", "memory_mode": "store_only"},
|
||||
json={
|
||||
"interaction_mode": "expert",
|
||||
"specialist": "ops",
|
||||
"confirm_strategy": "strict",
|
||||
"plan_agent_version": "v1",
|
||||
},
|
||||
)
|
||||
self.assertEqual(set_resp.status_code, 200)
|
||||
self.assertEqual(pref.status_code, 200)
|
||||
|
||||
create_resp = self.client.post(
|
||||
"/admin/api/chat/sessions",
|
||||
|
|
@ -265,7 +333,12 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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")
|
||||
self.assertEqual(str(mode_body.get("memory_mode") or ""), "default")
|
||||
self.assertEqual(str(mode_body.get("execution_mode") or ""), "agent")
|
||||
self.assertEqual(str(mode_body.get("confirm_strategy") or ""), "strict")
|
||||
gm = mode_body.get("global_menu") if isinstance(mode_body.get("global_menu"), dict) else {}
|
||||
self.assertEqual(str(gm.get("interaction_mode") or ""), "expert")
|
||||
self.assertEqual(str(gm.get("specialist") or ""), "ops")
|
||||
|
||||
def test_new_session_default_mode_is_expert_generalist(self) -> None:
|
||||
token = self._login()
|
||||
|
|
@ -295,6 +368,7 @@ class AdminChatStreamAsyncTaskTests(unittest.TestCase):
|
|||
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")
|
||||
self.assertEqual(str(mode_body.get("confirm_strategy") or ""), "strict")
|
||||
|
||||
def test_admin_dynamic_expert_stats_endpoint(self) -> None:
|
||||
token = self._login()
|
||||
|
|
|
|||
48
tests/test_bootstrap_env.py
Normal file
48
tests/test_bootstrap_env.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from oclaw.platform.config import bootstrap_env as be
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_bootstrap_flag():
|
||||
be._LOADED = False
|
||||
yield
|
||||
be._LOADED = False
|
||||
|
||||
|
||||
def test_load_system_env_reads_only_local_system_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(be, "_project_root", lambda: tmp_path)
|
||||
loc = tmp_path / "_local"
|
||||
loc.mkdir(parents=True, exist_ok=True)
|
||||
(loc / "system.env").write_text("X=only_file\nY=z\n", encoding="utf-8")
|
||||
monkeypatch.delenv("X", raising=False)
|
||||
monkeypatch.delenv("Y", raising=False)
|
||||
|
||||
loaded = be.load_system_env(force=True)
|
||||
assert len(loaded) == 1
|
||||
assert os.environ["X"] == "only_file"
|
||||
assert os.environ["Y"] == "z"
|
||||
|
||||
|
||||
def test_load_system_env_does_not_override_process_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(be, "_project_root", lambda: tmp_path)
|
||||
loc = tmp_path / "_local"
|
||||
loc.mkdir(parents=True, exist_ok=True)
|
||||
(loc / "system.env").write_text("X=from_file\n", encoding="utf-8")
|
||||
monkeypatch.setenv("X", "from_shell")
|
||||
|
||||
be.load_system_env(force=True)
|
||||
assert os.environ["X"] == "from_shell"
|
||||
|
||||
|
||||
def test_load_system_env_missing_file_noop(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(be, "_project_root", lambda: tmp_path)
|
||||
(tmp_path / "_local").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
loaded = be.load_system_env(force=True)
|
||||
assert loaded == []
|
||||
82
tests/test_chat_mode_execution_persistence.py
Normal file
82
tests/test_chat_mode_execution_persistence.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from oclaw.interfaces.admin.chat_api import (
|
||||
_persist_session_dialog_chat_settings,
|
||||
_persist_user_menu_chat_settings,
|
||||
_resolve_mode_settings,
|
||||
)
|
||||
|
||||
|
||||
class _DummyStore:
|
||||
def __init__(self) -> None:
|
||||
self._settings: dict[str, str] = {}
|
||||
|
||||
def get_setting(self, key: str) -> str:
|
||||
return str(self._settings.get(key) or "")
|
||||
|
||||
def set_setting(self, key: str, value: str) -> None:
|
||||
self._settings[str(key)] = str(value)
|
||||
|
||||
|
||||
def test_mode_session_and_user_prefs_merge_for_gateway() -> None:
|
||||
store = _DummyStore()
|
||||
_persist_session_dialog_chat_settings(
|
||||
store=store,
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
session_id="s1",
|
||||
memory_mode="default",
|
||||
execution_mode="plan",
|
||||
)
|
||||
_persist_user_menu_chat_settings(
|
||||
store=store,
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
interaction_mode="expert",
|
||||
specialist="generalist",
|
||||
confirm_strategy="auto",
|
||||
plan_agent_version="v2",
|
||||
)
|
||||
interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = (
|
||||
_resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1")
|
||||
)
|
||||
assert interaction_mode == "expert"
|
||||
assert specialist == "generalist"
|
||||
assert memory_mode == "default"
|
||||
assert execution_mode == "plan"
|
||||
assert confirm_strategy == "auto"
|
||||
assert plan_agent_version == "v2"
|
||||
|
||||
|
||||
def test_mode_session_defaults_when_session_keys_missing() -> None:
|
||||
store = _DummyStore()
|
||||
store.set_setting("chat.user.mode.t1.u1.confirm_strategy", "invalid")
|
||||
interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = (
|
||||
_resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1")
|
||||
)
|
||||
assert interaction_mode == "expert"
|
||||
assert specialist == "generalist"
|
||||
assert memory_mode == "default"
|
||||
assert execution_mode == "agent"
|
||||
assert confirm_strategy == "strict"
|
||||
assert plan_agent_version == "v1"
|
||||
|
||||
|
||||
def test_invalid_execution_mode_on_session_dialog_falls_back_to_agent() -> None:
|
||||
store = _DummyStore()
|
||||
store.set_setting("chat.session.mode.t1.u1.s1.execution_mode", "invalid")
|
||||
interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = (
|
||||
_resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1")
|
||||
)
|
||||
assert execution_mode == "agent"
|
||||
assert interaction_mode == "expert"
|
||||
|
||||
|
||||
def test_invalid_confirm_strategy_on_user_menu_falls_back_to_strict() -> None:
|
||||
store = _DummyStore()
|
||||
store.set_setting("chat.user.mode.t1.u1.confirm_strategy", "invalid")
|
||||
interaction_mode, specialist, memory_mode, execution_mode, confirm_strategy, plan_agent_version = (
|
||||
_resolve_mode_settings(store=store, tenant_id="t1", user_id="u1", session_id="s1")
|
||||
)
|
||||
assert confirm_strategy == "strict"
|
||||
assert specialist == "generalist"
|
||||
|
|
@ -4,7 +4,7 @@ from types import SimpleNamespace
|
|||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.direct_loop import run_oclaw_direct_loop
|
||||
from oclaw.runtime.tools.base import ToolRegistry
|
||||
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
|
||||
|
||||
|
||||
class _Model:
|
||||
|
|
@ -15,7 +15,7 @@ class _Model:
|
|||
return SimpleNamespace(content="", reasoning_content="", tool_calls=[])
|
||||
|
||||
|
||||
def test_direct_loop_persists_stub_on_empty_assistant(tmp_path) -> None: # noqa: ANN001
|
||||
def test_direct_loop_keeps_empty_assistant_without_stub(tmp_path) -> None: # noqa: ANN001
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
|
|
@ -30,8 +30,191 @@ def test_direct_loop_persists_stub_on_empty_assistant(tmp_path) -> None: # noqa
|
|||
persist_user_message=True,
|
||||
max_tool_rounds=1,
|
||||
)
|
||||
assert out.final_text
|
||||
assert out.final_text == ""
|
||||
rows = store.get_messages(session_id=sess.id, limit=10)
|
||||
assistant = [r for r in rows if getattr(r, "role", "") == "assistant"]
|
||||
assert any("空响应" in str(getattr(r, "content", "") or "") for r in assistant)
|
||||
assert not any("空响应" in str(getattr(r, "content", "") or "") for r in assistant)
|
||||
|
||||
|
||||
class _ModelRetryOnce:
|
||||
base_url = ""
|
||||
thinking_mode_enabled = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return SimpleNamespace(content="", reasoning_content="", tool_calls=[])
|
||||
return SimpleNamespace(content="ok-after-retry", reasoning_content="", tool_calls=[])
|
||||
|
||||
|
||||
def test_direct_loop_retries_once_before_stub(tmp_path, monkeypatch) -> None: # noqa: ANN001
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
model = _ModelRetryOnce()
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1")
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
|
||||
out = run_oclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="x",
|
||||
model=model,
|
||||
tools=ToolRegistry([]),
|
||||
user_text="hi",
|
||||
persist_user_message=True,
|
||||
max_tool_rounds=1,
|
||||
)
|
||||
assert out.final_text == "ok-after-retry"
|
||||
assert model.calls == 2
|
||||
rows = store.get_messages(session_id=sess.id, limit=10)
|
||||
assistant = [r for r in rows if getattr(r, "role", "") == "assistant"]
|
||||
assert not any("空响应" in str(getattr(r, "content", "") or "") for r in assistant)
|
||||
monkeypatch.delenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", raising=False)
|
||||
monkeypatch.delenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", raising=False)
|
||||
|
||||
|
||||
class _ModelDsmlThenText:
|
||||
base_url = ""
|
||||
thinking_mode_enabled = False
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls = 0
|
||||
|
||||
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return SimpleNamespace(
|
||||
content='<||DSML||tool_calls><||DSML||invoke name="run_command"></||DSML||invoke></||DSML||tool_calls>',
|
||||
reasoning_content="",
|
||||
tool_calls=[],
|
||||
)
|
||||
return SimpleNamespace(content="native-tools-recovered", reasoning_content="", tool_calls=[])
|
||||
|
||||
|
||||
def test_direct_loop_retries_when_dsml_text_tool_call(tmp_path, monkeypatch) -> None: # noqa: ANN001
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
model = _ModelDsmlThenText()
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1")
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
|
||||
dummy_tool = ToolSpec(
|
||||
name="run_command",
|
||||
description="dummy",
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=lambda args: {"ok": True, "args": args},
|
||||
read_only=True,
|
||||
)
|
||||
out = run_oclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="x",
|
||||
model=model,
|
||||
tools=ToolRegistry([dummy_tool]),
|
||||
user_text="hi",
|
||||
persist_user_message=True,
|
||||
max_tool_rounds=1,
|
||||
)
|
||||
assert out.final_text == "native-tools-recovered"
|
||||
assert model.calls == 2
|
||||
|
||||
|
||||
class _ModelAlwaysDsml:
|
||||
base_url = ""
|
||||
thinking_mode_enabled = False
|
||||
|
||||
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
|
||||
return SimpleNamespace(
|
||||
content='<||DSML||tool_calls><||DSML||invoke name="read_file"></||DSML||invoke></||DSML||tool_calls>',
|
||||
reasoning_content="",
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
|
||||
def test_direct_loop_dsml_text_persisted_as_failed_tool_pair(tmp_path, monkeypatch) -> None: # noqa: ANN001
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
model = _ModelAlwaysDsml()
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1")
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
|
||||
dummy_tool = ToolSpec(
|
||||
name="read_file",
|
||||
description="dummy",
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=lambda args: {"ok": True, "args": args},
|
||||
read_only=True,
|
||||
)
|
||||
out = run_oclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="x",
|
||||
model=model,
|
||||
tools=ToolRegistry([dummy_tool]),
|
||||
user_text="hi",
|
||||
persist_user_message=True,
|
||||
max_tool_rounds=1,
|
||||
)
|
||||
assert out.final_text == ""
|
||||
rows = store.get_messages(session_id=sess.id, limit=20)
|
||||
tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"]
|
||||
assert tool_rows
|
||||
assert any("model_protocol_mismatch_dsml" in str(getattr(r, "content", "") or "") for r in tool_rows)
|
||||
|
||||
|
||||
class _ModelMixedTextualToolIntent:
|
||||
base_url = ""
|
||||
thinking_mode_enabled = False
|
||||
|
||||
def chat(self, msgs, tools, on_token=None): # noqa: ANN001,ARG002
|
||||
return SimpleNamespace(
|
||||
content=(
|
||||
"输出没抓到。再来:\n\n"
|
||||
"<||DSML||tool_calls>\n"
|
||||
"<||DSML||invoke name=\"run_command\">\n"
|
||||
"<||DSML||parameter name=\"command\" string=\"true\">echo test</||DSML||parameter>\n"
|
||||
"</||DSML||invoke>\n"
|
||||
"</||DSML||tool_calls>"
|
||||
),
|
||||
reasoning_content="",
|
||||
tool_calls=[],
|
||||
)
|
||||
|
||||
|
||||
def test_direct_loop_mixed_text_with_tool_intent_is_blocked(tmp_path, monkeypatch) -> None: # noqa: ANN001
|
||||
db = tmp_path / "ops.sqlite"
|
||||
store = SqliteStore(str(db))
|
||||
sess = store.create_session("t")
|
||||
model = _ModelMixedTextualToolIntent()
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_MAX", "1")
|
||||
monkeypatch.setenv("AIA_EMPTY_ASSISTANT_RETRY_DELAY_MS", "0")
|
||||
dummy_tool = ToolSpec(
|
||||
name="run_command",
|
||||
description="dummy",
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=lambda args: {"ok": True, "args": args},
|
||||
read_only=True,
|
||||
)
|
||||
out = run_oclaw_direct_loop(
|
||||
store=store,
|
||||
session_id=sess.id,
|
||||
lang="zh",
|
||||
system_prompt="x",
|
||||
model=model,
|
||||
tools=ToolRegistry([dummy_tool]),
|
||||
user_text="hi",
|
||||
persist_user_message=True,
|
||||
max_tool_rounds=1,
|
||||
)
|
||||
assert out.final_text == ""
|
||||
rows = store.get_messages(session_id=sess.id, limit=20)
|
||||
tool_rows = [r for r in rows if getattr(r, "role", "") == "tool"]
|
||||
assert tool_rows
|
||||
assert any("run_command" in str(getattr(r, "tool_calls", "") or "") for r in tool_rows)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import pytest
|
|||
|
||||
from oclaw.platform.llm.chat_models import LLMResponse
|
||||
from oclaw.runtime.gateway import OclawGateway
|
||||
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
|
||||
|
|
@ -102,6 +103,9 @@ def test_gateway_async_task_payload_preserves_relay_envelope(monkeypatch: pytest
|
|||
def get_setting(self, _k: str) -> str:
|
||||
return ""
|
||||
|
||||
def set_setting(self, _k: str, _v: str) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
|
|
@ -214,6 +218,179 @@ def test_gateway_expert_mode_uses_requested_specialist() -> None:
|
|||
assert chosen.get("sid") == "ops"
|
||||
|
||||
|
||||
def test_gateway_expert_plan_execution_mode_runs_v2_with_plan_prompt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, k: str) -> str:
|
||||
if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED":
|
||||
return "1"
|
||||
return ""
|
||||
|
||||
def set_setting(self, _k: str, _v: str) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
|
||||
return None
|
||||
|
||||
def set_setting(self, _k: str, _v: str) -> None:
|
||||
return None
|
||||
|
||||
class _Exec:
|
||||
model = object()
|
||||
tools = object()
|
||||
system_prompt = "base-system"
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _run_agent_core_ok(**kwargs: object) -> object:
|
||||
data = kwargs.get("data")
|
||||
captured["system_prompt"] = str(getattr(data, "system_prompt", "") or "")
|
||||
return SimpleNamespace(outcome=SimpleNamespace(final_text="plan_llm_reply", turn_uuid="turn-1"))
|
||||
|
||||
monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok)
|
||||
|
||||
gw = OclawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-plan-1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="先给我一个执行计划",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "plan"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
|
||||
assert out.interaction_mode == "expert"
|
||||
assert out.dispatch_reason == "expert_direct"
|
||||
assert str(out.reply_text or "") == "plan_llm_reply"
|
||||
prompt_text = str(captured.get("system_prompt") or "")
|
||||
assert ("plan 模式" in prompt_text) or ("Plan mode is active" in prompt_text)
|
||||
assert ("计划工作流" in prompt_text) or ("Plan Workflow" in prompt_text)
|
||||
|
||||
|
||||
def test_gateway_expert_plan_mode_filters_non_readonly_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, k: str) -> str:
|
||||
if str(k or "") == "AIA_EXPERT_PLAN_AGENT_V2_ENABLED":
|
||||
return "1"
|
||||
return ""
|
||||
|
||||
def set_setting(self, _k: str, _v: str) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
|
||||
return None
|
||||
|
||||
def _mk_tool(name: str, read_only: bool) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=name,
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=lambda args: {"ok": True, "args": args},
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
class _Exec:
|
||||
model = object()
|
||||
system_prompt = "base-system"
|
||||
tools = ToolRegistry([_mk_tool("read_file", True), _mk_tool("edit_file", False)])
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _run_agent_core_ok(**kwargs: object) -> object:
|
||||
data = kwargs.get("data")
|
||||
tools = getattr(data, "tools", None)
|
||||
captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else []
|
||||
return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1"))
|
||||
|
||||
monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok)
|
||||
|
||||
gw = OclawGateway(store=Store())
|
||||
msg = StandardMessage(
|
||||
session_id="sid-plan-tools",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="先给我一个执行计划",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "plan"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
|
||||
assert str(out.reply_text or "") == "ok"
|
||||
names = list(captured.get("tool_names") or [])
|
||||
assert "read_file" in names
|
||||
assert "edit_file" not in names
|
||||
|
||||
|
||||
def test_gateway_expert_agent_mode_injects_plan_control_tools(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def __init__(self) -> None:
|
||||
self.kv: dict[str, str] = {}
|
||||
|
||||
def get_setting(self, k: str) -> str:
|
||||
return str(self.kv.get(k) or "")
|
||||
|
||||
def set_setting(self, k: str, v: str) -> None:
|
||||
self.kv[k] = str(v or "")
|
||||
|
||||
def add_trace_event(self, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
def add_trace_events_batch(self, _rows: list[dict[str, object]]) -> None:
|
||||
return None
|
||||
|
||||
def _mk_tool(name: str, read_only: bool) -> ToolSpec:
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=name,
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=lambda args: {"ok": True, "args": args},
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
class _Exec:
|
||||
model = object()
|
||||
system_prompt = "base-system"
|
||||
tools = ToolRegistry([_mk_tool("read_file", True)])
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _run_agent_core_ok(**kwargs: object) -> object:
|
||||
data = kwargs.get("data")
|
||||
tools = getattr(data, "tools", None)
|
||||
captured["tool_names"] = [t.name for t in tools.list()] if hasattr(tools, "list") else []
|
||||
return SimpleNamespace(outcome=SimpleNamespace(final_text="ok", turn_uuid="turn-1"))
|
||||
|
||||
monkeypatch.setattr("oclaw.runtime.gateway.run_agent_core", _run_agent_core_ok)
|
||||
|
||||
store = Store()
|
||||
store.kv["AIA_EXPERT_PLAN_AGENT_V2_ENABLED"] = "1"
|
||||
gw = OclawGateway(store=store)
|
||||
msg = StandardMessage(
|
||||
session_id="sid-agent-tools",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="admin_chat",
|
||||
text="直接执行",
|
||||
attachments=[],
|
||||
metadata={"interaction_mode": "expert", "selected_specialist": "generalist", "execution_mode": "agent"},
|
||||
)
|
||||
out = gw.handle_turn(msg=msg, lang="zh", executor=_Exec())
|
||||
assert str(out.reply_text or "") == "ok"
|
||||
names = list(captured.get("tool_names") or [])
|
||||
assert "enter_plan_mode_v2" in names
|
||||
assert "exit_plan_mode_v2" in names
|
||||
assert store.get_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID") == "sid-agent-tools"
|
||||
|
||||
|
||||
def test_gateway_comprehensive_mode_manager_first_selects_specialist(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
class Store:
|
||||
def get_setting(self, _k: str) -> str:
|
||||
|
|
|
|||
80
tests/test_plan_agent_v2_gateway_cutover_draft.py
Normal file
80
tests/test_plan_agent_v2_gateway_cutover_draft.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.plan_agent_v2_gateway_cutover import maybe_handle_expert_turn_v2_draft
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
|
||||
def _msg(text: str) -> StandardMessage:
|
||||
return StandardMessage(
|
||||
session_id="cutover-s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="chat",
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_cutover_draft_off_by_default(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
out = maybe_handle_expert_turn_v2_draft(
|
||||
store=store,
|
||||
msg=_msg("hello"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base",
|
||||
force_flag=False,
|
||||
)
|
||||
assert out.handled is False
|
||||
assert out.result is None
|
||||
|
||||
|
||||
def test_cutover_draft_plan_reply_when_forced(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
out = maybe_handle_expert_turn_v2_draft(
|
||||
store=store,
|
||||
msg=_msg("我要做改造"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base",
|
||||
force_flag=True,
|
||||
)
|
||||
assert out.handled is False
|
||||
assert out.result is None
|
||||
assert out.decision_action == "run_agent"
|
||||
assert "base" in str(out.system_prompt_override or "")
|
||||
|
||||
|
||||
def test_cutover_draft_run_agent_returns_prompt_override(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
_ = maybe_handle_expert_turn_v2_draft(
|
||||
store=store,
|
||||
msg=_msg("先给个计划"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base",
|
||||
force_flag=True,
|
||||
)
|
||||
out = maybe_handle_expert_turn_v2_draft(
|
||||
store=store,
|
||||
msg=_msg("确认"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base",
|
||||
force_flag=True,
|
||||
)
|
||||
assert out.decision_action == "stay_plan"
|
||||
assert out.handled is True
|
||||
assert out.result is not None
|
||||
|
||||
142
tests/test_plan_agent_v2_gateway_dryrun.py
Normal file
142
tests/test_plan_agent_v2_gateway_dryrun.py
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.gateway import OclawGatewayResult
|
||||
from oclaw.runtime.plan_agent_v2 import (
|
||||
build_shadow_gateway_result,
|
||||
evaluate_gateway_expert_turn_shadow,
|
||||
legacy_gateway_result_keys,
|
||||
)
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
|
||||
def _msg(text: str) -> StandardMessage:
|
||||
return StandardMessage(
|
||||
session_id="sess-dryrun",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
role="user",
|
||||
channel="chat",
|
||||
text=text,
|
||||
attachments=[],
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_shadow_stays_off_without_force_or_flag(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
out = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=_msg("实现一个功能"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base-system",
|
||||
force_flag=False,
|
||||
)
|
||||
assert out.used_v2 is False
|
||||
assert out.decision is None
|
||||
|
||||
|
||||
def test_gateway_shadow_force_path_matches_legacy_shape(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
out = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=_msg("请先给计划"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base-system",
|
||||
force_flag=True,
|
||||
)
|
||||
assert out.used_v2 is True
|
||||
assert out.decision is not None
|
||||
|
||||
shadow_row = build_shadow_gateway_result(
|
||||
decision=out.decision,
|
||||
run_id="run-1",
|
||||
trace_id="trace-1",
|
||||
elapsed_ms=9,
|
||||
requested_specialist="generalist",
|
||||
)
|
||||
assert set(shadow_row.keys()) == legacy_gateway_result_keys()
|
||||
|
||||
baseline = OclawGatewayResult(run_id="run-1", reply_text="", trace_id="trace-1", elapsed_ms=9)
|
||||
assert shadow_row["mode"] == baseline.mode
|
||||
assert shadow_row["task_id"] == baseline.task_id
|
||||
assert shadow_row["dynamic_agent_used"] == baseline.dynamic_agent_used
|
||||
assert shadow_row["relay_pointer_count"] == baseline.relay_pointer_count
|
||||
|
||||
|
||||
def test_gateway_shadow_confirm_path_builds_compatible_result(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
|
||||
# Enter plan
|
||||
first = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=_msg("我要改造一下"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base-system",
|
||||
force_flag=True,
|
||||
)
|
||||
assert first.used_v2 is True
|
||||
assert first.decision is not None
|
||||
assert first.decision.action == "run_agent"
|
||||
assert "base-system" in str(first.decision.system_prompt_override or "")
|
||||
|
||||
# Confirm plan
|
||||
second = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=_msg("确认"),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base-system",
|
||||
force_flag=True,
|
||||
)
|
||||
assert second.used_v2 is True
|
||||
assert second.decision is not None
|
||||
assert second.decision.action == "stay_plan"
|
||||
assert "切换到 agent 模式" in str(second.decision.reply_text or "")
|
||||
|
||||
row = build_shadow_gateway_result(
|
||||
decision=second.decision,
|
||||
run_id="run-2",
|
||||
trace_id="trace-2",
|
||||
elapsed_ms=12,
|
||||
requested_specialist="generalist",
|
||||
)
|
||||
assert row["interaction_mode"] == "expert"
|
||||
assert str(row["dispatch_reason"]).startswith("plan_agent_v2:")
|
||||
|
||||
|
||||
def test_gateway_shadow_skips_v2_when_metadata_plan_agent_version_v1(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
out = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=StandardMessage(
|
||||
session_id="sess-dryrun",
|
||||
tenant_id="tenant-1",
|
||||
user_id="user-1",
|
||||
role="user",
|
||||
channel="chat",
|
||||
text="请先给计划",
|
||||
attachments=[],
|
||||
metadata={"plan_agent_version": "v1"},
|
||||
),
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base-system",
|
||||
force_flag=True,
|
||||
)
|
||||
assert out.used_v2 is False
|
||||
assert out.decision is None
|
||||
|
||||
450
tests/test_plan_agent_v2_shadow.py
Normal file
450
tests/test_plan_agent_v2_shadow.py
Normal file
|
|
@ -0,0 +1,450 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from oclaw.platform.persistence.sqlite_store import SqliteStore
|
||||
from oclaw.runtime.plan_agent_v2_adapter import evaluate_for_expert_mode
|
||||
from oclaw.runtime.plan_agent_v2_compat import build_shadow_gateway_result, legacy_gateway_result_keys
|
||||
from oclaw.runtime.plan_agent_v2_gateway_adapter import evaluate_gateway_expert_turn_shadow
|
||||
from oclaw.runtime.plan_agent_v2_manager import PlanModeManagerV2
|
||||
from oclaw.runtime.plan_agent_v2_models import PLAN_MODE_PLAN, PlanAgentStateV2
|
||||
from oclaw.runtime.plan_agent_v2_prompt_injector import build_plan_mode_prefix
|
||||
from oclaw.runtime.plan_agent_v2_state_store import PlanAgentStateStoreV2
|
||||
from oclaw.runtime.plan_agent_v2_switch import should_route_to_v2, v2_feature_enabled
|
||||
from oclaw.runtime.plan_agent_v2_tool_specs import materialize_plan_mode_v2_tools
|
||||
from oclaw.runtime.plan_agent_v2_tool_policy import filter_tools_for_mode
|
||||
from oclaw.runtime.plan_agent_v2_trace import emit_plan_agent_v2_trace
|
||||
from oclaw.runtime.plan_agent_v2 import should_route_to_v2 as should_route_to_v2_pkg
|
||||
from oclaw.runtime.gateway import OclawGatewayResult
|
||||
from oclaw.runtime.tools.base import ToolRegistry, ToolSpec
|
||||
from oclaw.runtime.types import StandardMessage
|
||||
|
||||
|
||||
def _dummy_tool(name: str, read_only: bool) -> ToolSpec:
|
||||
def _handler(args):
|
||||
return {"ok": True, "echo": args}
|
||||
|
||||
return ToolSpec(
|
||||
name=name,
|
||||
description=name,
|
||||
parameters={"type": "object", "properties": {}, "additionalProperties": True},
|
||||
handler=_handler,
|
||||
read_only=read_only,
|
||||
)
|
||||
|
||||
|
||||
def test_state_store_roundtrip(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
ss = PlanAgentStateStoreV2(store)
|
||||
st = ss.load(session_id="s1")
|
||||
assert st.mode == "normal"
|
||||
saved = ss.save(session_id="s1", state=st)
|
||||
loaded = ss.load(session_id="s1")
|
||||
assert loaded.mode == saved.mode
|
||||
|
||||
|
||||
def test_manager_enter_and_confirm(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
plan_root = tmp_path / "plans"
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(plan_root))
|
||||
mgr = PlanModeManagerV2(store=store)
|
||||
st1 = mgr.enter(session_id="sess-1", owner_specialist="generalist")
|
||||
assert st1.mode == PLAN_MODE_PLAN
|
||||
assert st1.plan_path
|
||||
assert Path(st1.plan_path).exists()
|
||||
st2 = mgr.confirm(session_id="sess-1")
|
||||
assert st2.mode == "normal"
|
||||
assert st2.plan_confirmed is True
|
||||
assert "## Goal" in str(st2.plan_content or "")
|
||||
|
||||
|
||||
def test_tool_policy_filters_non_readonly_in_plan_mode() -> None:
|
||||
reg = ToolRegistry([_dummy_tool("read_a", True), _dummy_tool("write_a", False)])
|
||||
out = filter_tools_for_mode(registry=reg, mode="plan")
|
||||
names = {t.name for t in out}
|
||||
assert "read_a" in names
|
||||
assert "write_a" not in names
|
||||
|
||||
|
||||
def test_tool_policy_keeps_plan_mode_control_tools() -> None:
|
||||
reg = ToolRegistry([_dummy_tool("exit_plan_mode_v2", False), _dummy_tool("write_a", False)])
|
||||
out = filter_tools_for_mode(registry=reg, mode="plan")
|
||||
names = {t.name for t in out}
|
||||
assert "exit_plan_mode_v2" in names
|
||||
assert "write_a" not in names
|
||||
|
||||
|
||||
def test_adapter_agent_mode_repeated_user_injects_stall_guard(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
sid = store.create_session("stall-test").id
|
||||
duplicate_line = "please handle this request"
|
||||
store.add_message(session_id=sid, role="user", content=duplicate_line, event_type="user_text")
|
||||
store.add_message(session_id=sid, role="assistant", content="I will analyze first…", event_type="assistant_text")
|
||||
dec = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id=sid,
|
||||
lang="en",
|
||||
requested_specialist="generalist",
|
||||
user_text=duplicate_line,
|
||||
execution_mode="agent",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert dec.action == "run_agent"
|
||||
assert "Conversation stall guard" in str(dec.system_prompt_override or "")
|
||||
assert "base" in str(dec.system_prompt_override or "")
|
||||
|
||||
|
||||
def test_adapter_plan_flow(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
|
||||
d1 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s1",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="帮我做一个功能",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d1.action == "run_agent"
|
||||
assert isinstance(d1.plan_state, dict)
|
||||
assert str(d1.plan_state.get("mode") or "") == "plan"
|
||||
assert "base" in str(d1.system_prompt_override or "")
|
||||
|
||||
d2 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s1",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="确认",
|
||||
execution_mode="agent",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d2.action == "run_agent"
|
||||
assert "base" in str(d2.system_prompt_override or "")
|
||||
|
||||
|
||||
def test_adapter_confirm_blocked_until_agent_mode(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
|
||||
_ = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s2",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="先给计划",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
d2 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s2",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="确认",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d2.action == "stay_plan"
|
||||
assert "切换到 agent 模式" in str(d2.reply_text or "")
|
||||
|
||||
|
||||
def test_adapter_confirm_strategy_auto_allows_confirm_in_plan_mode(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "auto")
|
||||
_ = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-auto",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="先给计划",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
d2 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-auto",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="确认",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d2.action == "run_agent"
|
||||
assert "已确认计划" in str(d2.reply_text or "")
|
||||
|
||||
|
||||
def test_adapter_confirm_strategy_off_allows_confirm_in_plan_mode(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_CONFIRM_STRATEGY", "off")
|
||||
_ = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-off",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="先给计划",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
d2 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-off",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="确认",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d2.action == "run_agent"
|
||||
assert "已确认计划" in str(d2.reply_text or "")
|
||||
|
||||
|
||||
def test_adapter_plan_loop_guard_blocks_repeated_input(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
common = dict(
|
||||
store=store,
|
||||
session_id="s-loop",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
_ = evaluate_for_expert_mode(user_text="继续", **common)
|
||||
d2 = evaluate_for_expert_mode(user_text="继续", **common)
|
||||
assert d2.action == "stay_plan"
|
||||
assert "低信息续写" in str(d2.reply_text or "")
|
||||
|
||||
|
||||
def test_adapter_plan_low_signal_continue_short_circuit(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
_ = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-low",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="先给我一版计划",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
d2 = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s-low",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="继续",
|
||||
execution_mode="plan",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
assert d2.action == "stay_plan"
|
||||
assert "低信息续写" in str(d2.reply_text or "")
|
||||
|
||||
|
||||
def test_prompt_prefix_uses_ccmini_like_phases(tmp_path: Path) -> None:
|
||||
plan_file = tmp_path / "plan.md"
|
||||
plan_file.write_text("# Plan\n", encoding="utf-8")
|
||||
st = PlanAgentStateV2(mode="plan", plan_path=str(plan_file))
|
||||
zh = build_plan_mode_prefix(state=st, lang="zh")
|
||||
en = build_plan_mode_prefix(state=st, lang="en")
|
||||
assert "阶段1:理解问题" in zh
|
||||
assert "计划工作流" in zh
|
||||
assert "计划模式工具" in zh
|
||||
assert "执行纪律" in zh
|
||||
assert "enter_plan_mode_v2" in zh
|
||||
assert "Phase 1: Initial Understanding" in en
|
||||
assert "Plan Workflow" in en
|
||||
assert "Plan mode tools" in en
|
||||
assert "Execution discipline" in en
|
||||
assert "enter_plan_mode_v2" in en
|
||||
|
||||
|
||||
def test_shadow_tool_specs_work(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
tools = materialize_plan_mode_v2_tools(store=store)
|
||||
assert len(tools) == 2
|
||||
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
|
||||
exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2")
|
||||
out1 = enter.handler({"session_id": "s-1", "owner_specialist": "generalist"})
|
||||
assert out1.get("ok") is True
|
||||
out2 = exit_tool.handler({"session_id": "s-1", "confirm": True})
|
||||
assert out2.get("ok") is True
|
||||
assert bool((out2.get("state") or {}).get("plan_confirmed")) is True
|
||||
|
||||
|
||||
def test_shadow_plan_tools_emit_trace_when_trace_id(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
tools = materialize_plan_mode_v2_tools(store=store)
|
||||
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
|
||||
exit_tool = next(t for t in tools if t.name == "exit_plan_mode_v2")
|
||||
enter.handler(
|
||||
{
|
||||
"session_id": "s-tr",
|
||||
"owner_specialist": "generalist",
|
||||
"trace_id": "tid-1",
|
||||
"parent_span_id": "ps-9",
|
||||
}
|
||||
)
|
||||
exit_tool.handler({"session_id": "s-tr", "confirm": False, "trace_id": "tid-1"})
|
||||
rows = store.list_trace_events_for_trace(session_id="s-tr", trace_id="tid-1")
|
||||
types = [r.get("event_type") for r in rows]
|
||||
assert "plan_mode_tool_enter" in types
|
||||
assert "plan_mode_tool_exit" in types
|
||||
enter_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_enter")
|
||||
exit_ev = next(r for r in rows if r.get("event_type") == "plan_mode_tool_exit")
|
||||
assert (enter_ev.get("payload") or {}).get("tool") == "enter_plan_mode_v2"
|
||||
assert (exit_ev.get("payload") or {}).get("tool") == "exit_plan_mode_v2"
|
||||
assert (exit_ev.get("payload") or {}).get("confirmed") is False
|
||||
|
||||
|
||||
def test_shadow_tool_specs_can_use_default_session_key(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
store.set_setting("AIA_PLAN_AGENT_V2_DEFAULT_SESSION_ID", "s-default")
|
||||
tools = materialize_plan_mode_v2_tools(store=store)
|
||||
enter = next(t for t in tools if t.name == "enter_plan_mode_v2")
|
||||
out = enter.handler({})
|
||||
assert out.get("ok") is True
|
||||
st = out.get("state") or {}
|
||||
assert str(st.get("mode") or "") == "plan"
|
||||
|
||||
|
||||
def test_switch_default_off_and_opt_in(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
assert v2_feature_enabled(store=store) is False
|
||||
assert should_route_to_v2(store=store, interaction_mode="expert") is False
|
||||
assert should_route_to_v2(store=store, interaction_mode="expert", force_flag=True) is True
|
||||
store.set_setting("AIA_EXPERT_PLAN_AGENT_V2_ENABLED", "1")
|
||||
assert v2_feature_enabled(store=store) is True
|
||||
assert should_route_to_v2(store=store, interaction_mode="expert") is True
|
||||
assert should_route_to_v2(store=store, interaction_mode="comprehensive") is False
|
||||
|
||||
|
||||
def test_gateway_adapter_shadow_force_flag(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
msg = StandardMessage(
|
||||
session_id="s1",
|
||||
tenant_id="t1",
|
||||
user_id="u1",
|
||||
role="user",
|
||||
channel="chat",
|
||||
text="帮我实现一个功能",
|
||||
attachments=[],
|
||||
metadata={},
|
||||
)
|
||||
out = evaluate_gateway_expert_turn_shadow(
|
||||
store=store,
|
||||
msg=msg,
|
||||
lang="zh",
|
||||
interaction_mode="expert",
|
||||
requested_specialist="generalist",
|
||||
base_system_prompt="base",
|
||||
force_flag=True,
|
||||
)
|
||||
assert out.used_v2 is True
|
||||
assert out.decision is not None
|
||||
assert out.decision.action == "run_agent"
|
||||
|
||||
|
||||
def test_shadow_compat_result_shape(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
dec = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s1",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="我要改造",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
row = build_shadow_gateway_result(
|
||||
decision=dec,
|
||||
run_id="r1",
|
||||
trace_id="t1",
|
||||
elapsed_ms=12,
|
||||
requested_specialist="generalist",
|
||||
)
|
||||
assert set(row.keys()) == legacy_gateway_result_keys()
|
||||
|
||||
|
||||
def test_trace_helper_no_crash() -> None:
|
||||
events = []
|
||||
|
||||
class _S:
|
||||
def add_trace_event(self, **kwargs):
|
||||
events.append(kwargs)
|
||||
|
||||
emit_plan_agent_v2_trace(
|
||||
store=_S(),
|
||||
session_id="s1",
|
||||
trace_id="t1",
|
||||
parent_span_id=None,
|
||||
event_type="plan_mode_entered",
|
||||
payload={"x": 1},
|
||||
)
|
||||
assert len(events) == 1
|
||||
assert events[0].get("event_type") == "plan_mode_entered"
|
||||
|
||||
|
||||
def test_shadow_gateway_result_defaults_align_legacy_baseline(tmp_path: Path) -> None:
|
||||
store = SqliteStore(str(tmp_path / "ops.sqlite"))
|
||||
store.set_setting("AIA_EXPERT_PLAN_FILE_DIR", str(tmp_path / "plans"))
|
||||
dec = evaluate_for_expert_mode(
|
||||
store=store,
|
||||
session_id="s1",
|
||||
lang="zh",
|
||||
requested_specialist="generalist",
|
||||
user_text="继续",
|
||||
base_system_prompt="base",
|
||||
)
|
||||
row = build_shadow_gateway_result(
|
||||
decision=dec,
|
||||
run_id="r1",
|
||||
trace_id="t1",
|
||||
elapsed_ms=1,
|
||||
requested_specialist="generalist",
|
||||
)
|
||||
# Baseline invariant fields expected by legacy result dataclass.
|
||||
baseline = OclawGatewayResult(run_id="r1", reply_text="", trace_id="t1", elapsed_ms=1)
|
||||
assert row["mode"] == baseline.mode
|
||||
assert row["task_id"] == baseline.task_id
|
||||
assert row["dynamic_agent_used"] == baseline.dynamic_agent_used
|
||||
assert row["dynamic_agent_name"] == baseline.dynamic_agent_name
|
||||
assert row["relay_pointer_count"] == baseline.relay_pointer_count
|
||||
assert row["relay_envelope_present"] == baseline.relay_envelope_present
|
||||
assert row["relay_envelope_pointer_count"] == baseline.relay_envelope_pointer_count
|
||||
assert row["relay_ttl_turn_count"] == baseline.relay_ttl_turn_count
|
||||
assert row["relay_ttl_session_count"] == baseline.relay_ttl_session_count
|
||||
assert row["relay_ttl_keep_count"] == baseline.relay_ttl_keep_count
|
||||
# Package export should be wired and callable.
|
||||
assert callable(should_route_to_v2_pkg)
|
||||
|
||||
|
||||
def test_package_exports_stable_symbols() -> None:
|
||||
import oclaw.runtime.plan_agent_v2 as p
|
||||
|
||||
required = [
|
||||
"PlanAgentStateV2",
|
||||
"PlanAgentStateStoreV2",
|
||||
"PlanModeManagerV2",
|
||||
"PlanAgentV2Decision",
|
||||
"GatewayPlanV2AdapterOutput",
|
||||
"evaluate_for_expert_mode",
|
||||
"evaluate_gateway_expert_turn_shadow",
|
||||
"should_route_to_v2",
|
||||
"v2_feature_enabled",
|
||||
"emit_plan_agent_v2_trace",
|
||||
"build_shadow_gateway_result",
|
||||
"legacy_gateway_result_keys",
|
||||
]
|
||||
for name in required:
|
||||
assert hasattr(p, name), name
|
||||
|
||||
|
|
@ -295,7 +295,12 @@ class WsGatewayTests(unittest.TestCase):
|
|||
"type": "req",
|
||||
"id": "cs1",
|
||||
"method": "chat.send",
|
||||
"params": {"sessionKey": "sess-chat", "message": "hi", "idempotencyKey": "idem-chat-1"},
|
||||
"params": {
|
||||
"sessionKey": "sess-chat",
|
||||
"message": "hi",
|
||||
"idempotencyKey": "idem-chat-1",
|
||||
"execution_mode": "plan",
|
||||
},
|
||||
}
|
||||
)
|
||||
ack = None
|
||||
|
|
@ -309,6 +314,7 @@ class WsGatewayTests(unittest.TestCase):
|
|||
assert ack.get("id") == "cs1"
|
||||
assert ack.get("ok") is True
|
||||
assert str((ack.get("payload") or {}).get("status") or "") == "started"
|
||||
assert str((ack.get("payload") or {}).get("executionMode") or "") == "plan"
|
||||
run_id = str((ack.get("payload") or {}).get("runId") or "")
|
||||
assert run_id.strip() != ""
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue