feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等) - 更新外部连接器配置结构,包括BASE_URL和认证令牌设置 - 改进connector provider支持更多类型(official, feishu_bot等) - 实现Mistral模型推理模式支持reasoning_effort参数 - 增强外部连接器策略配置和运行时配置管理 - 添加connector bridge事件验证和安全保护机制 - 优化任务路由逻辑,区分simple_chat和new_task场景 - 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
@ -34,6 +34,21 @@ def _connected_connection(tmp_path):
|
||||
return connection
|
||||
|
||||
|
||||
def _connection_with_status(tmp_path, status: str):
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
connection = store.create(
|
||||
kind="feishu",
|
||||
mode="sidecar",
|
||||
display_name="Feishu Main",
|
||||
account_id="feishu:app-1",
|
||||
owner_user_id=None,
|
||||
auth_type="connector_session",
|
||||
)
|
||||
store.update_status(connection.connection_id, status=status, last_error=None)
|
||||
return connection
|
||||
|
||||
|
||||
def _payload(connection, *, event_id: str = "evt-1", delivery_attempt: int = 1) -> dict:
|
||||
return {
|
||||
"eventId": event_id,
|
||||
@ -85,6 +100,77 @@ def test_bridge_endpoint_rejects_invalid_token(tmp_path, monkeypatch) -> None:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_connection_identity_mismatch(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connected_connection(tmp_path)
|
||||
payload = _payload(connection)
|
||||
payload["channelId"] = "forged-channel"
|
||||
payload["kind"] = "feishu"
|
||||
payload["accountId"] = "feishu:attacker"
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json=payload,
|
||||
)
|
||||
assert response.status_code == 403
|
||||
assert "does not match connection" in response.json()["detail"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_inactive_connection(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connection_with_status(tmp_path, "pairing")
|
||||
response = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "not connected" in response.json()["detail"]
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_rejects_empty_or_oversized_content(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
with TestClient(app) as client:
|
||||
connection = _connection_with_status(tmp_path, "connected")
|
||||
blank = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection, event_id="blank"),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
"content": " ",
|
||||
},
|
||||
)
|
||||
too_long = client.post(
|
||||
"/api/channel-connector-bridge/events",
|
||||
headers={"Authorization": "Bearer bridge-token"},
|
||||
json={
|
||||
**_payload(connection, event_id="too-long"),
|
||||
"kind": "feishu",
|
||||
"accountId": "feishu:app-1",
|
||||
"content": "x" * 20001,
|
||||
},
|
||||
)
|
||||
assert blank.status_code == 400
|
||||
assert too_long.status_code == 413
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_bridge_endpoint_dedupes_repeated_event(tmp_path, monkeypatch) -> None:
|
||||
app, service = _app(tmp_path, monkeypatch)
|
||||
try:
|
||||
|
||||
@ -43,6 +43,22 @@ class FakeSidecarClient:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
class ImmediateConnectedSidecarClient(FakeSidecarClient):
|
||||
async def start_session(self, payload: dict) -> dict:
|
||||
self.started.append(payload)
|
||||
session = {
|
||||
"sessionId": "cs_connected",
|
||||
"kind": payload["kind"],
|
||||
"status": "connected",
|
||||
"qrImage": None,
|
||||
"accountId": f"{payload['kind']}:me",
|
||||
"displayName": "Connected Account",
|
||||
"metadata": {"stateRef": "state-1", "appSecret": "secret-1", "tenantAccessToken": "token-1"},
|
||||
}
|
||||
self.sessions["cs_connected"] = session
|
||||
return session
|
||||
|
||||
|
||||
def test_weixin_connector_starts_connector_session(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
@ -67,6 +83,30 @@ def test_weixin_connector_starts_connector_session(tmp_path) -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_start_session_connected_updates_connection(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
view = await connector.start_session(display_name="Feishu Main", owner_user_id=None, options={})
|
||||
connection = connection_store.get(view["connectionId"])
|
||||
|
||||
assert view["status"] == "connected"
|
||||
assert connection.status == "connected"
|
||||
assert connection.account_id == "feishu:me"
|
||||
assert connection.display_name == "Connected Account"
|
||||
assert connection.credentials_ref is not None
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_weixin_connector_poll_connected_materializes_external_runtime(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
@ -124,6 +164,67 @@ def test_feishu_connector_uses_feishu_kind(tmp_path) -> None:
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_persists_policy_options_in_runtime_config(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = FakeSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
await connector.start_session(
|
||||
display_name="Feishu Main",
|
||||
owner_user_id=None,
|
||||
options={
|
||||
"domain": "feishu",
|
||||
"requireMentionInGroups": True,
|
||||
"allowFrom": ["ou_1"],
|
||||
"groupAllowFrom": ["oc_1"],
|
||||
"maxMessageChars": 1234,
|
||||
},
|
||||
)
|
||||
connection = connection_store.list()[0]
|
||||
|
||||
assert client.started[0]["options"]["requireMentionInGroups"] is True
|
||||
assert connection.runtime_config["requireMentionInGroups"] is True
|
||||
assert connection.runtime_config["allowFrom"] == ["ou_1"]
|
||||
assert connection.runtime_config["groupAllowFrom"] == ["oc_1"]
|
||||
assert connection.runtime_config["maxMessageChars"] == 1234
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_feishu_connector_materializes_policy_for_external_runtime(tmp_path) -> None:
|
||||
async def run() -> None:
|
||||
connection_store = ChannelConnectionStore(tmp_path / "connections.json")
|
||||
credential_store = CredentialStore(tmp_path / "credentials.json")
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
connector = FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
|
||||
view = await connector.start_session(
|
||||
display_name="Feishu Main",
|
||||
owner_user_id=None,
|
||||
options={"requireMentionInGroups": True, "allowFrom": ["ou_1"], "groupAllowFrom": ["oc_1"]},
|
||||
)
|
||||
spec = await connector.materialize_runtime(view["connectionId"])
|
||||
|
||||
assert spec.config["platformKind"] == "feishu"
|
||||
assert spec.config["requireMentionInGroups"] is True
|
||||
assert spec.config["allowFrom"] == ["ou_1"]
|
||||
assert spec.config["groupAllowFrom"] == ["oc_1"]
|
||||
|
||||
asyncio.run(run())
|
||||
|
||||
|
||||
def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
@ -174,3 +275,44 @@ def test_connector_session_api_starts_and_polls_connected_session(tmp_path, monk
|
||||
assert polled.json()["connection"]["channel_id"] in app.state.channel_runtime.adapters
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
|
||||
def test_connector_session_api_activates_immediate_connected_session(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("EXTERNAL_CONNECTOR_TOKEN", "connector-token")
|
||||
config_path = tmp_path / "config.json"
|
||||
config_path.write_text(
|
||||
'{"agents": {"defaults": {"workspace": "%s"}}, "providers": {}}' % str(tmp_path),
|
||||
encoding="utf-8",
|
||||
)
|
||||
service = AgentService(config_path=config_path)
|
||||
app = create_app(service=service, manage_service_lifecycle=False)
|
||||
client = ImmediateConnectedSidecarClient()
|
||||
|
||||
try:
|
||||
with TestClient(app) as http:
|
||||
state_dir = tmp_path / "state" / "channel_connections"
|
||||
connection_store = ChannelConnectionStore(state_dir / "connections.json")
|
||||
credential_store = CredentialStore(state_dir / "credentials.json")
|
||||
registry = ChannelConnectorRegistry(connection_store=connection_store, credential_store=credential_store)
|
||||
registry.register(
|
||||
FeishuConnector(
|
||||
connection_store=connection_store,
|
||||
credential_store=credential_store,
|
||||
sidecar_client=client,
|
||||
sidecar_base_url="http://external-connector:8787",
|
||||
)
|
||||
)
|
||||
app.state.channel_connector_registry = registry
|
||||
|
||||
started = http.post(
|
||||
"/api/channel-connector-sessions",
|
||||
json={"kind": "feishu", "displayName": "Feishu Main", "options": {}},
|
||||
)
|
||||
|
||||
assert started.status_code == 200
|
||||
connection = started.json()["connection"]
|
||||
assert connection["status"] == "connected"
|
||||
assert connection["channel_id"] in app.state.channel_runtime.adapters
|
||||
assert started.json()["session"]["metadata"] == {"stateRef": "state-1"}
|
||||
finally:
|
||||
service.close()
|
||||
|
||||
@ -30,10 +30,14 @@ EXPECTED_INITIAL_SKILL_TOOLS = {
|
||||
"mcp_outlook_mcp_calendar_find_meeting_times",
|
||||
"mcp_outlook_mcp_calendar_delta_sync",
|
||||
],
|
||||
"skills-admin": ["skills_list", "skill_manage", "skill_view"],
|
||||
"skills-admin": ["skills_list", "skill_view"],
|
||||
"terminal-operation": ["terminal", "process", "execute_code"],
|
||||
"utility-tools": ["clarify", "delegate", "send_message", "spawn", "todo"],
|
||||
"web-operation": ["web_fetch", "web_search"],
|
||||
"multi-search-engine": ["web_fetch"],
|
||||
}
|
||||
|
||||
EXPECTED_NON_INITIAL_SKILL_TOOLS = {
|
||||
"skills-authoring-admin": ["skill_manage"],
|
||||
}
|
||||
|
||||
|
||||
@ -48,6 +52,23 @@ def test_initial_skill_tool_hints_match_runtime_tool_names() -> None:
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_skill_authoring_admin_is_seeded_but_not_initial() -> None:
|
||||
published = json.loads((REPO_ROOT / "skills" / "_index" / "published.json").read_text(encoding="utf-8"))
|
||||
disabled = json.loads((REPO_ROOT / "skills" / "_index" / "disabled.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert "skills-authoring-admin" not in published["items"]
|
||||
assert "skills-authoring-admin" in disabled["items"]
|
||||
|
||||
for skill_name, expected_tools in EXPECTED_NON_INITIAL_SKILL_TOOLS.items():
|
||||
skill_dir = REPO_ROOT / "skills" / skill_name / "versions" / "v0001"
|
||||
frontmatter, _body = parse_frontmatter((skill_dir / "SKILL.md").read_text(encoding="utf-8"))
|
||||
version = json.loads((skill_dir / "version.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert frontmatter["tools"] == expected_tools
|
||||
assert version["frontmatter"]["tools"] == expected_tools
|
||||
assert version["tool_hints"] == expected_tools
|
||||
|
||||
|
||||
def test_default_runtime_registers_skill_view_tool(tmp_path: Path) -> None:
|
||||
loaded = EngineLoader(workspace=tmp_path).load()
|
||||
try:
|
||||
|
||||
@ -169,6 +169,90 @@ def test_thinking_mode_is_forced_disabled_even_when_requested_enabled(monkeypatc
|
||||
}
|
||||
|
||||
|
||||
def test_mistral_vllm_uses_reasoning_effort_instead_of_qwen_thinking_body(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="EMPTY",
|
||||
api_base="http://localhost:8000/v1",
|
||||
default_model="mistralai/Mistral-Medium-3.5-128B",
|
||||
provider_name="vllm",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="mistralai/Mistral-Medium-3.5-128B",
|
||||
thinking_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert captured["model"] == "hosted_vllm/mistralai/Mistral-Medium-3.5-128B"
|
||||
assert captured["extra_body"] == {"reasoning_effort": "high"}
|
||||
|
||||
|
||||
def test_mistral_vllm_omits_reasoning_body_when_thinking_mode_is_unspecified(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
class Message:
|
||||
content = "ok"
|
||||
reasoning_content = None
|
||||
tool_calls = []
|
||||
|
||||
class Choice:
|
||||
message = Message()
|
||||
finish_reason = "stop"
|
||||
|
||||
class Response:
|
||||
choices = [Choice()]
|
||||
usage = None
|
||||
|
||||
async def fake_acompletion(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return Response()
|
||||
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.acompletion", fake_acompletion)
|
||||
monkeypatch.setattr("beaver.engine.providers.litellm.litellm", SimpleNamespace())
|
||||
|
||||
provider = LiteLLMProvider(
|
||||
api_key="EMPTY",
|
||||
api_base="http://localhost:8000/v1",
|
||||
default_model="mistralai/Mistral-Medium-3.5-128B",
|
||||
provider_name="vllm",
|
||||
)
|
||||
asyncio.run(
|
||||
provider.chat(
|
||||
[{"role": "user", "content": "reply ok"}],
|
||||
model="mistralai/Mistral-Medium-3.5-128B",
|
||||
)
|
||||
)
|
||||
|
||||
assert "extra_body" not in captured
|
||||
|
||||
|
||||
def test_litellm_provider_sanitizes_tool_call_arguments(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
captured: dict = {}
|
||||
|
||||
|
||||
@ -149,6 +149,22 @@ def test_router_injects_intent_skill_guidance() -> None:
|
||||
assert "Weather and current external data" in prompt
|
||||
|
||||
|
||||
def test_router_prompt_treats_unrelated_lightweight_conversation_as_new_topic() -> None:
|
||||
provider = RouterProvider('{"action":"simple_chat","reason":"unrelated lightweight conversation"}')
|
||||
|
||||
asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
"吃饭没",
|
||||
active_task=_task(),
|
||||
provider=provider,
|
||||
)
|
||||
)
|
||||
|
||||
prompt = provider.calls[0]["messages"][1]["content"]
|
||||
assert "unrelated lightweight conversation" in prompt
|
||||
assert "must not be classified as revise_task merely because the active Task is awaiting acceptance" in prompt
|
||||
|
||||
|
||||
def test_router_closes_active_task_from_llm_decision() -> None:
|
||||
decision = asyncio.run(
|
||||
MainAgentRouter().classify(
|
||||
|
||||
@ -99,6 +99,191 @@ def test_task_run_records_evidence_and_waits_for_acceptance(tmp_path: Path) -> N
|
||||
assert "validated" not in event_types
|
||||
|
||||
|
||||
def test_unrelated_simple_chat_auto_accepts_active_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:new-topic-chat",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"have you eaten?",
|
||||
session_id="web:new-topic-chat",
|
||||
provider_bundle=_bundle("I do not eat.", route_action="simple_chat"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
previous = task_service.get_task(first.task_id or "")
|
||||
assert previous is not None
|
||||
assert previous.status == "closed"
|
||||
assert previous.run_ids == [first.run_id]
|
||||
assert previous.feedback[-1]["acceptance_type"] == "accept"
|
||||
assert previous.metadata["final_accepted_run_id"] == first.run_id
|
||||
assert second.task_id is None
|
||||
|
||||
|
||||
def test_unrelated_new_task_auto_accepts_previous_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:new-topic-task",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"check today's weather in Iceland",
|
||||
session_id="web:new-topic-task",
|
||||
provider_bundle=_bundle("Weather result", route_action="new_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
previous = task_service.get_task(first.task_id or "")
|
||||
current = task_service.get_task(second.task_id or "")
|
||||
assert previous is not None
|
||||
assert current is not None
|
||||
assert previous.status == "closed"
|
||||
assert previous.run_ids == [first.run_id]
|
||||
assert previous.feedback[-1]["acceptance_type"] == "accept"
|
||||
assert current.task_id != previous.task_id
|
||||
assert current.status == "awaiting_acceptance"
|
||||
assert current.run_ids == [second.run_id]
|
||||
|
||||
|
||||
def test_related_follow_up_continues_active_task_without_accepting_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:continue-topic",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"include restaurants near the port",
|
||||
session_id="web:continue-topic",
|
||||
provider_bundle=_bundle("More recommendations", route_action="continue_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert task.feedback == []
|
||||
|
||||
|
||||
def test_requested_revision_keeps_active_task_without_accepting_it(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:revise-topic",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"remove expensive restaurants",
|
||||
session_id="web:revise-topic",
|
||||
provider_bundle=_bundle("Revised recommendations", route_action="revise_task"),
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert [item["acceptance_type"] for item in task.feedback] == ["revise"]
|
||||
|
||||
|
||||
def test_router_failure_fallback_does_not_auto_accept_active_task(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
workspace=tmp_path,
|
||||
task_execution_planner=StubTaskExecutionPlanner(),
|
||||
)
|
||||
)
|
||||
first = asyncio.run(
|
||||
service.process_direct(
|
||||
"recommend food in Hengqin",
|
||||
session_id="web:router-fallback",
|
||||
provider_bundle=_bundle("Food recommendations"),
|
||||
)
|
||||
)
|
||||
fallback_bundle = ProviderBundle(
|
||||
main_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
main_provider=StubProvider(
|
||||
[
|
||||
LLMResponse(
|
||||
content="Continued response",
|
||||
finish_reason="stop",
|
||||
provider_name="stub",
|
||||
model="stub-model",
|
||||
)
|
||||
]
|
||||
),
|
||||
auxiliary_runtime=SimpleNamespace(model="stub-model", provider_name="stub"),
|
||||
auxiliary_provider=StubProvider([]),
|
||||
)
|
||||
|
||||
second = asyncio.run(
|
||||
service.process_direct(
|
||||
"continue after router failure",
|
||||
session_id="web:router-fallback",
|
||||
provider_bundle=fallback_bundle,
|
||||
)
|
||||
)
|
||||
|
||||
task_service = service.create_loop().boot().task_service
|
||||
assert task_service is not None
|
||||
task = task_service.get_task(first.task_id or "")
|
||||
assert task is not None
|
||||
assert second.task_id == first.task_id
|
||||
assert task.status == "awaiting_acceptance"
|
||||
assert task.run_ids == [first.run_id, second.run_id]
|
||||
assert task.feedback == []
|
||||
|
||||
|
||||
def test_acceptance_closes_task_and_triggers_learning(tmp_path: Path) -> None:
|
||||
service = AgentService(
|
||||
loader=EngineLoader(
|
||||
|
||||
Reference in New Issue
Block a user