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:
oliver 2026-05-02 18:41:45 +08:00
parent ae44cbcad5
commit 37522f492a
49 changed files with 4146 additions and 238 deletions

View file

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

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

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

View file

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

View file

@ -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:

View 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

View 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

View 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

View file

@ -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() != ""