PMFlow/backend/tests/test_pmflow_ai_agent_runtim...

3855 lines
170 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from __future__ import annotations
import asyncio
import inspect
from pathlib import Path
from types import SimpleNamespace
import pytest
from app.api import space_api
from app.api.rag_ask_contract import sanitize_customer_rag_payload
from app.schemas.pmflow_ai_agent_runtime import (
PMFlowAIAgentActionCandidateLedgerEntry,
PMFlowAIAgentAnswerIntentPlan,
PMFlowAIAgentBoundary,
PMFlowAIAgentClaim,
PMFlowAIAgentCriticResult,
PMFlowAIAgentEvidenceNormalization,
PMFlowAIAgentFactLedgerEntry,
PMFlowAIAgentGapLedgerEntry,
PMFlowAIAgentGoalFrame,
PMFlowAIAgentObservation,
PMFlowAIAgentStepProposal,
PMFlowAIAgentTerminalAnswerState,
PMFlowAIAgentWorkspace,
)
from app.schemas.pmflow_ai_operating_runtime import (
PMFlowAIAgentWorkProduct,
PMFlowAIAutonomyDecision,
PMFlowAIEvidenceLedgerEntry,
PMFlowAIOperatingMission,
PMFlowAIOperatingPlan,
PMFlowAIProjectWorldFacet,
PMFlowAIProjectWorldStatePacket,
)
from app.schemas.rag import RAGAskRequest
from app.services.pmflow_agent_runtime import (
PMFlowAgentEnvironmentService,
PMFlowAgentRuntimeService,
PMFlowAgentSpecialistRuntimeService,
)
from app.services.pmflow_agent_runtime.service import _prepared_actions_from_agent_payload
from app.services.pmflow_ai_agent_runtime import PMFlowAIAgentAnswerRuntime, PMFlowAIAgentRunService
from app.services.pmflow_ai_agent_runtime.agent_answer_runtime import (
build_agent_terminal_answer_state,
critique_agent_customer_answer,
render_agent_terminal_state_fallback,
)
from app.services.pmflow_ai_agent_runtime.agent_run_service import _recovery_proposal_from_critic
from app.services.pmflow_ai_agent_runtime.agent_evidence_normalizer import (
PMFlowAIAgentEvidenceNormalizer,
apply_evidence_normalization,
fallback_normalize_observation,
)
from app.services.pmflow_ai_agent_runtime.agent_goal_frame_runtime import (
_ensure_compound_phrase_interpretation_branches,
_goal_frame_safe_conversation_context,
)
from app.services.pmflow_ai_agent_runtime.agent_state_critic import PMFlowAIAgentStateCritic, _enforce_critic_contract
from app.services.pmflow_ai_agent_runtime.agent_step_planner import PMFlowAIAgentStepPlanner
from app.services.pmflow_ai_agent_runtime.agent_workspace import (
agent_workspace_from_context_capsules,
upsert_agent_workspace_capsule,
workspace_from_conversation_context,
)
from app.services.pmflow_ai_agent_runtime.agent_tool_registry import (
AGENT_TOOL_CONTRACTS,
PMFlowAIAgentToolRegistry,
_AgentRuntimeExternalPlanner,
)
from app.services.pmflow_ai_operating_runtime import PMFlowAIOperatingRunService
from app.services.pmflow_ai_operating_runtime.agent_registry import PMFlowAIOperatingAgentRegistry
from app.services.pmflow_ai_operating_runtime.run_service import (
_claim_texts,
_compose_research_operating_answer,
_external_source_rows,
)
from app.services.prompt_runtime_service import PromptRuntimeRenderResult
@pytest.mark.asyncio
async def test_agent_runtime_step1_builds_run_workspace_and_process_packet() -> None:
events = []
async def sink(event: dict) -> None:
events.append(event)
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_FakePlanner("inspect_space_collections"),
tool_registry=_FakeToolRegistry(),
critic=_FakeCritic(ready=True),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
started_at=None,
event_sink=sink,
)
assert payload["processing_stage"] == "agent_runtime"
assert payload["answer_mode"] == "agent_runtime"
assert payload["agent_run_packet"]["packet_version"] == "pmflow_ai_agent_run.v1"
assert payload["agent_workspace"]["workspace_version"] == "pmflow_ai_agent_workspace.v1"
assert payload["agent_workspace"]["objective_ledger"]
assert payload["agent_workspace"]["fact_ledger"]
assert payload["agent_workspace"]["goal_frame"]["packet_version"] == "pmflow_ai_agent_goal_frame.v1"
assert payload["agent_workspace"]["claim_ledger"]
assert payload["agent_workspace"]["evidence_normalizations"]
assert payload["agent_workspace"]["evidence_normalizations"][0]["packet_version"] == "pmflow_ai_agent_evidence_normalization.v1"
assert payload["agent_workspace"]["attempt_ledger"]
assert payload["agent_workspace"]["action_ledger"]
assert payload["agent_workspace"]["deliverable_state"]["status"]
assert payload["deliverable_packet"]["packet_version"] == "pmflow_ai_agent_deliverable_packet.v1"
assert payload["deliverable_packet"]["deliverable_kind"] == "status_brief"
assert payload["agent_workspace"]["deliverable_state"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
assert payload["answer_canvas"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
assert all(isinstance(item, dict) and item.get("title") for item in payload["answer_canvas"]["next_actions"])
assert payload["customer_evidence_packet"]["answer_type"] == payload["deliverable_packet"]["deliverable_kind"]
assert payload["answer_process_packet"]["route_display"] == "agent_runtime"
assert [step["key"] for step in payload["answer_process_packet"]["steps"]] == [
"agent_understand_goal",
"agent_workspace_snapshot",
"agent_step_planner",
"agent_tool_inspect_space_collections",
"agent_evidence_normalizer",
"agent_state_critic",
"agent_answer_runtime",
]
assert payload["answer_process_packet"]["steps"][-1]["status"] == "completed"
assert "我是 PMFlow AI" not in payload["answer"]
assert "项目操作副驾" not in payload["answer"]
assert "说明能力边界" not in payload["answer"]
assert [event["type"] for event in events].count("step.started") >= 5
assert [event["type"] for event in events].count("step.completed") >= 7
assert events[0]["step"]["key"] == "agent_understand_goal"
assert any(event["step"]["key"] == "agent_tool_inspect_space_collections" for event in events if event["type"] == "step.completed")
assert not any("prompt" in str(event).lower() or "trace" in str(event).lower() for event in events)
def test_space_api_default_entry_uses_agent_runtime_not_legacy_turn_runtime() -> None:
source = inspect.getsource(space_api._run_pmflow_agent_turn_payload)
assert "PMFlowAgentRuntimeService" in source
assert "run_space_turn" in source
assert "PMFlowAIOperatingRunService" not in source
assert "PMFlowAIAgentRunService" not in source
assert "PMFlowAITurnRuntimeService" not in source
def test_agent_runtime_tool_environment_has_no_scenario_specific_opportunity_tool() -> None:
assert "run_opportunity_discovery_agents" not in AGENT_TOOL_CONTRACTS
assert "discover_project_opportunities" not in str(AGENT_TOOL_CONTRACTS)
assert "run_market_opportunity_review" not in str(AGENT_TOOL_CONTRACTS)
assert "inspect_pending_confirmations" not in AGENT_TOOL_CONTRACTS
assert "inspect_pending_confirmations" not in str(AGENT_TOOL_CONTRACTS)
assert "inspect_attention_queue" in AGENT_TOOL_CONTRACTS
assert "inspect_project_execution_package" in AGENT_TOOL_CONTRACTS
def test_pmflow_ai_thread_message_projection_hides_raw_run_internals() -> None:
payload = {
"answer": "这是用户应该看到的回答。",
"agent_os_run_id": "run_demo",
"answer_process_packet": {
"steps": [
{
"key": "workspace_binding",
"label": "绑定工作区",
"status": "completed",
"display_summary": "已读取当前空间。",
"duration_ms": 18,
},
{
"key": "final_answer",
"label": "step_plan_abc123",
"status": "passed",
"display_summary": "target_ref final_answer passed",
},
]
},
"agent_os_artifacts": [
{"artifact_id": "art_1", "kind": "final_answer", "title": "PMFlow AI 回答", "summary": "这是用户应该看到的回答。"},
{"artifact_id": "art_2", "kind": "research_note", "title": "推进建议", "summary": "先收口最高优先级动作。"},
],
"agent_os_verifications": [{"verification_id": "verification_1", "target_ref": "step_plan_abc123", "result": "passed"}],
}
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
assert message_payload["work_log"][0]["label"] == "绑定工作区"
assert message_payload["work_log"][0]["status_label"] == "已完成"
assert message_payload["work_log"][1]["label"] == "推进这轮问题的第 2 步"
assert message_payload["work_log"][1]["summary"] == "把可确认的信息、边界和下一步整理成用户能直接看的回复。"
assert message_payload["artifacts"] == [
{"id": "art_2", "title": "推进建议", "summary": "先收口最高优先级动作。", "status_label": "已完成"}
]
assert "verifications" not in message_payload
assert "step_plan_abc123" not in str(message_payload)
assert "passed" not in str(message_payload)
def test_pmflow_ai_thread_message_projection_returns_visible_and_total_work_log_counts() -> None:
payload = {
"answer": "已经整理完成。",
"answer_process_packet": {
"steps": [
{
"key": f"step_{index}",
"label": f"读取材料 {index}",
"status": "completed",
"display_summary": f"已完成第 {index} 步。",
}
for index in range(1, 20)
]
},
}
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
assert len(message_payload["work_log"]) == 12
assert len(message_payload["work_log_full"]) == 19
assert message_payload["work_log_meta"]["visible_step_count"] == 12
assert message_payload["work_log_meta"]["total_step_count"] == 19
assert message_payload["work_log_meta"]["truncated"] is True
def test_pmflow_ai_message_view_backfills_full_work_log_from_run_steps() -> None:
row = SimpleNamespace(
public_message_id="msg_1",
role="assistant",
content="已经完成本轮处理。",
run_public_id="run_1",
qa_record_id=None,
sent_at=None,
message_payload={
"work_log": [
{
"id": f"stored_{index}",
"label": f"已存步骤 {index}",
"summary": "历史投影里只有前 12 条。",
"status": "done",
}
for index in range(1, 13)
],
},
)
run_steps = [
{
"id": f"step_{index}",
"label": f"真实运行步骤 {index}",
"summary": f"已执行第 {index} 步。",
"status": "completed",
}
for index in range(1, 20)
]
view = space_api._pmflow_ai_message_view(row, run_steps=run_steps)
assert view["work_log_meta"]["visible_step_count"] == 12
assert view["work_log_meta"]["total_step_count"] == 19
assert view["work_log_meta"]["truncated"] is True
assert len(view["work_log"]) == 12
assert len(view["work_log_full"]) == 19
def test_pmflow_ai_thread_message_projection_hides_deliverable_artifacts_and_unsafe_actions() -> None:
payload = {
"answer": "你当前最该处理的是确认变更 2,进入变更审查页后确认或退回。",
"answer_canvas": {
"direct_answer": "你当前最该处理的是确认变更 2,进入变更审查页后确认或退回。",
"action_candidates": [
{
"title": "确认变更 2",
"summary": "打开变更审查页处理这条待确认项。",
"href": "/space/change-review?projectId=141",
}
],
},
"agent_os_artifacts": [
{
"artifact_id": "artifact_bad",
"kind": "agent_deliverable",
"title": "明确儿童项目下一步",
"summary": "GoalFrame 明确限定 project:141 和 approval:21。",
},
{
"artifact_id": "artifact_report",
"kind": "customer_report",
"title": "审查说明",
"summary": "当前最高优先级动作需要你本人确认。",
},
],
"agent_os_prepared_action_packets": [
{
"action_id": "action_good",
"source": "deliverable_action_candidate",
"title": "确认变更 2",
"summary": "打开变更审查页处理这条待确认项。",
"href": "/space/change-review?projectId=141",
},
{
"action_id": "action_no_href",
"source": "deliverable_action_candidate",
"title": "修改后再审 3",
"summary": "没有可跳转目标的动作不应展示成按钮。",
},
{
"action_id": "action_internal",
"source": "deliverable_action_candidate",
"title": "确认变更 2;对象:重析;依据:GoalFrame top_next_actions[0]",
"href": "/space/change-review?projectId=141",
},
],
}
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
assert message_payload["artifacts"] == [
{"id": "artifact_report", "title": "审查说明", "summary": "当前最高优先级动作需要你本人确认。", "status_label": "已完成"}
]
assert message_payload["actions"] == [
{
"id": "action_good",
"label": "确认变更 2",
"summary": "打开变更审查页处理这条待确认项。",
"href": "/space/change-review?projectId=141",
}
]
assert "GoalFrame" not in str(message_payload)
assert "approval:21" not in str(message_payload)
def test_pmflow_ai_thread_message_projection_preserves_operating_answer_and_confirmation_action_type() -> None:
payload = {
"answer": "这轮先继续补公开研究来源,确认前不会写项目记忆或发起执行。",
"reply_text": "这轮先继续补公开研究来源,确认前不会写项目记忆或发起执行。",
"processing_stage": "multi_agent_operating_runtime",
"answer_canvas": {
"direct_answer": "这轮结果里混入了内部运行信息,我先不展示那段内容。",
"action_candidates": [],
},
"agent_os_confirmation_requests": [
{
"confirmation_id": "creq_test",
"title": "确认准备动作:继续补研究",
"summary": "确认后只继续做只读研究补证。",
"action_label": "继续补研究",
"action_type": "continue_research",
"can_execute_after_confirmation": True,
"action_payload": {"research_recovery": {"queries": ["2026 market report"]}},
}
],
}
message_payload = space_api._pmflow_ai_assistant_message_payload(payload)
assert message_payload["answer_canvas"]["direct_answer"] == payload["answer"]
assert message_payload["answer"] == payload["answer"]
assert message_payload["processing_stage"] == "multi_agent_operating_runtime"
assert message_payload["confirmations"][0]["action_type"] == "continue_research"
assert message_payload["confirmations"][0]["can_execute"] is True
def test_agent_runtime_message_projection_uses_single_customer_truth_for_blocked_research() -> None:
raw_answer = (
"这轮还不能负责任地下市场结论。真实进展是:已整理 7 个候选来源、0 条可用判断;"
"主要缺口:搜索结果只作为候选来源,未读正文前不能形成 ResearchNote claim;"
"claim_ledger 仍为空,gap_ledger 中仍存在 blocking gaps。"
)
payload = {
"answer": raw_answer,
"reply_text": raw_answer,
"processing_stage": "agent_runtime",
"answer_canvas": {
"direct_answer": "这轮结果里混入了内部运行信息,我先不展示那段内容。",
"action_candidates": [],
},
"deliverable_packet": {
"deliverable_kind": "blocked_state",
"status": "blocked",
"answer_text": raw_answer,
"summary": raw_answer,
},
"customer_evidence_packet": {
"answer_type": "blocked_state",
"fallback_summary": raw_answer,
},
}
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
message_payload = space_api._pmflow_ai_assistant_message_payload(sanitized)
assert sanitized["answer"]
assert message_payload["answer"] == sanitized["answer"]
assert message_payload["reply_text"] == sanitized["answer"]
assert message_payload["answer_canvas"]["direct_answer"] == sanitized["answer"]
assert "混入了内部运行信息" not in message_payload["answer_canvas"]["direct_answer"]
assert "ResearchNote claim" not in str(message_payload)
assert "claim_ledger" not in str(message_payload)
assert "gap_ledger" not in str(message_payload)
def test_research_answer_projection_blocks_when_research_note_claims_are_not_gated() -> None:
answer = _compose_research_operating_answer(
mission=PMFlowAIOperatingMission(original_user_input="最近AI会议市场怎么样"),
project_world_state=PMFlowAIProjectWorldStatePacket(
primary_project_ref="space:external_research",
status="partial",
remaining_gaps=["external_public_sources_need_agent_reading"],
),
research_product=PMFlowAIAgentWorkProduct(
product_type="ResearchNote",
structured_output={
"evidence_state": {"source_count": 5, "usable_claim_count": 2, "ready_for_opportunity_brief": True},
"market_claims": [
"Forbes Councils Membership Learn more about our services and contact us.",
"公开来源候选目前只能作为候选线索,尚未抽取能直接支撑业务判断的具体事实。",
],
"research_gaps": ["external_public_sources_need_agent_reading"],
},
),
operating_plan=PMFlowAIOperatingPlan(highest_leverage_move="继续补三类研究来源"),
autonomy_gate=PMFlowAIAutonomyDecision(
confirmation_requests=[
{
"action_type": "continue_research",
"action_label": "继续补研究",
}
]
),
operating_cycle=SimpleNamespace(consumed_learning_ledger=SimpleNamespace(learning_items=[{"summary": "上一轮来源不可用"}])),
)
assert answer.startswith("这轮还不能负责任地下市场结论。")
assert "初步市场判断" not in answer
def test_research_answer_projection_uses_gated_partial_claims_before_opportunity_threshold() -> None:
answer = _compose_research_operating_answer(
mission=PMFlowAIOperatingMission(original_user_input="最近智能会议市场怎么样"),
project_world_state=PMFlowAIProjectWorldStatePacket(
primary_project_ref="space:external_research",
status="partial",
remaining_gaps=["还缺少第二个可核验公开来源"],
),
research_product=PMFlowAIAgentWorkProduct(
product_type="ResearchNote",
structured_output={
"evidence_state": {"source_count": 1, "usable_claim_count": 1, "ready_for_opportunity_brief": False},
"market_claims": [
"2025年智能会议系统市场规模达到186亿元,具备AI能力的产品占比超过45%。",
],
"research_gaps": ["还缺少第二个可核验公开来源"],
},
),
operating_plan=PMFlowAIOperatingPlan(highest_leverage_move="继续补研究来源"),
autonomy_gate=PMFlowAIAutonomyDecision(confirmation_requests=[]),
operating_cycle=SimpleNamespace(consumed_learning_ledger=SimpleNamespace(learning_items=[])),
)
assert answer.startswith("这轮能先给一个有边界的初判:")
assert "186亿元" in answer
assert "还不能升级成机会简报或执行动作" in answer
assert "Forbes Councils" not in answer
assert "公开来源候选" not in answer
assert "external_public_sources_need_agent_reading" not in answer
assert "Project Memory" not in answer
assert "handoff" not in answer
assert space_api._customer_safe_display_text(answer, fallback="fallback", limit=4000) == answer
def test_agent_payload_prepared_actions_only_use_complete_action_candidates() -> None:
payload = {
"answer_canvas": {
"action_candidates": [
{
"title": "确认变更 2",
"summary": "打开变更审查页处理这条待确认项。",
"href": "/space/change-review?projectId=141",
"target_ref": "approval:21",
},
{
"title": "确认变更 2;对象:重析;依据:GoalFrame top_next_actions[0]",
"href": "/space/change-review?projectId=141",
},
{
"title": "修改后再审 3",
"summary": "缺少 href 的候选动作不能投影成前端按钮。",
"target_ref": "approval:20",
},
],
"next_actions": [
{
"title": "GoalFrame 明确限定仅绑定 project:141 和 approval:21,其他待办项虽存在但不满足目标约束条件。",
}
],
}
}
actions = _prepared_actions_from_agent_payload(payload)
assert actions == [
{
"action_id": "agent_action_1",
"title": "确认变更 2",
"summary": "打开变更审查页处理这条待确认项。",
"href": "/space/change-review?projectId=141",
"target_ref": "approval:21",
"status": "suggested",
"confirmation_required": False,
"source": "deliverable_action_candidate",
}
]
def test_agent_answer_quality_rejects_internal_goalframe_and_repairs_from_clean_actions() -> None:
terminal_state = PMFlowAIAgentTerminalAnswerState(
user_goal="儿童那个项目下一步我要做什么呢",
terminal_status="answerable",
claims=[
PMFlowAIAgentClaim(
claim_id="claim_internal",
claim_type="project_next_action",
claim_text="GoalFrame 明确限定仅绑定 project:141 和 approval:21,必须满足 top_next_actions[0]。",
allowed_to_state=True,
)
],
action_candidates=[
PMFlowAIAgentActionCandidateLedgerEntry(
title="确认变更 2",
target_title="童析—学龄前儿童行为特质AI评估助手",
reason="这是当前工作台里优先级最高的待确认项。",
href="/space/change-review?projectId=141",
requires_confirmation=True,
)
],
answer_intent_plan=PMFlowAIAgentAnswerIntentPlan(lead_claim_id="claim_internal"),
)
bad_answer = "GoalFrame 明确限定 project:141 和 approval:21,且不满足 top_next_actions[0] 唯一性约束。"
assert "internal_runtime_terms" in critique_agent_customer_answer(terminal_state=terminal_state, answer=bad_answer)
repaired = render_agent_terminal_state_fallback(terminal_state)
assert "确认变更 2" in repaired
assert "童析" in repaired
assert "GoalFrame" not in repaired
assert "top_next_actions" not in repaired
assert "approval:21" not in repaired
assert "project:141" not in repaired
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_step1_persists_run_and_keeps_customer_answer() -> None:
db = _FakeDB()
payload = await PMFlowAgentRuntimeService(
db,
tenant_id=12,
delivery_runtime=_FakeDeliveryRuntime(),
).run_space_turn(
request=RAGAskRequest(question="你是谁"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["processing_stage"] == "pmflow_agent_runtime"
assert payload["answer_mode"] == "pmflow_agent_runtime"
assert payload["pmflow_agent_run"]["packet_version"] == "pmflow_agent_run.v1"
assert payload["pmflow_agent_run"]["run_id"] == payload["agent_os_run_id"]
assert payload["agent_os_environment"]["packet_version"] == "pmflow_agent_environment.v1"
assert payload["agent_os_steps"][0]["kind"] == "agent_answer_runtime"
assert not any(step.get("tool_name") == "read_capability_contract" for step in payload["agent_os_steps"])
assert not any(step.get("tool_name") == "read_space_state" for step in payload["agent_os_steps"])
assert payload["agent_os_steps"]
assert payload["agent_os_observations"]
assert not any(observation.get("source_kind") == "inspect_project_operating_state" for observation in payload["agent_os_observations"])
assert payload["agent_os_artifacts"]
assert payload["agent_os_verifications"]
assert payload["answer"] == "项目当前需要先收口核心验证,再准备可确认的下一步。"
assert "project_object_required_for_world_state" not in payload["answer"]
assert any(item.__class__.__name__ == "PMFlowAgentRun" for item in db.items)
assert any(item.__class__.__name__ == "PMFlowAgentStep" for item in db.items)
assert any(item.__class__.__name__ == "PMFlowAgentObservation" for item in db.items)
assert any(item.__class__.__name__ == "PMFlowAgentArtifact" for item in db.items)
assert any(item.__class__.__name__ == "PMFlowAgentVerification" for item in db.items)
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
assert sanitized["processing_stage"] == "pmflow_agent_runtime"
assert sanitized["pmflow_agent_run"]["packet_version"] == "pmflow_agent_run.v1"
assert sanitized["agent_os_environment"]["packet_version"] == "pmflow_agent_environment.v1"
assert "answer_runtime_packet" not in sanitized
assert "agent_workspace" not in sanitized
@pytest.mark.asyncio
async def test_pmflow_agent_environment_step2_reads_project_world_state_only_with_structured_anchor() -> None:
visible_only_registry = _RecordingAgentOSToolRegistry()
visible_only = await PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=visible_only_registry,
).observe_initial(
request=RAGAskRequest(question="现在有哪些项目"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert [item["tool_name"] for item in visible_only_registry.calls] == ["inspect_space_collections"]
assert not any(step.tool_name == "inspect_project_operating_state" for step in visible_only.steps)
assert any(gap.get("type") == "object_anchor_gap" for observation in visible_only.observations for gap in observation.gaps)
anchored_registry = _RecordingAgentOSToolRegistry()
anchored = await PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=anchored_registry,
).observe_initial(
request=RAGAskRequest(question="这个项目怎么推进", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert [item["tool_name"] for item in anchored_registry.calls] == [
"inspect_space_collections",
"inspect_project_operating_state",
]
assert anchored_registry.calls[-1]["tool_input"] == {"project_ref": "project:141"}
assert any(step.tool_name == "inspect_project_operating_state" for step in anchored.steps)
assert any(
fact.get("type") == "project_world_state"
for observation in anchored.observations
for fact in observation.facts
)
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_step3_uses_dynamic_agent_runtime_without_specialist_override() -> None:
db = _FakeDB()
payload = await PMFlowAgentRuntimeService(
db,
tenant_id=12,
delivery_runtime=_FakeDeliveryRuntime(),
environment_service=PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=_RecordingAgentOSToolRegistry(),
),
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
db=object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
),
).run_space_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["processing_stage"] == "pmflow_agent_runtime"
assert payload["policy_version"] == "pmflow-agent-os-v2"
assert payload["agent_os_specialists"] == {}
assert payload["agent_os_specialist_assignments"] == []
assert payload["agent_os_work_products"] == []
assert payload["pmflow_agent_run"]["work_products"] == payload["agent_os_work_products"]
assert payload["agent_os_operating_cycle"] == {}
assert payload["agent_os_learning_ledger"] == {}
assert payload["agent_os_next_cycle_triggers"] == []
assert any(item["kind"] == "agent_deliverable" for item in payload["agent_os_artifacts"])
assert any(item["target_ref"] == "final_answer" for item in payload["agent_os_verifications"])
assert payload["answer"] == "项目当前需要先收口核心验证,再准备可确认的下一步。"
assert "project_object_required_for_world_state" not in payload["answer"]
assert [step["key"] for step in payload["answer_process_packet"]["steps"][:1]] == ["agent_answer_runtime"]
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
assert sanitized["agent_os_specialists"] == {}
assert sanitized["agent_os_autonomy_gate"] == {}
assert "answer_runtime_packet" not in sanitized
assert "agent_workspace" not in sanitized
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_step4_prepares_actions_but_blocks_writes_and_handoffs() -> None:
payload = await PMFlowAgentRuntimeService(
_FakeDB(),
tenant_id=12,
delivery_runtime=_FakeDeliveryRuntime(),
environment_service=PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=_RecordingAgentOSToolRegistry(),
),
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
db=object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
),
).run_space_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["agent_os_prepared_action_packets"]
assert payload["agent_os_confirmation_requests"] == []
assert payload["agent_os_execution_preparations"] == []
assert payload["prepared_action_packets"] == payload["agent_os_prepared_action_packets"]
assert all(item["confirmation_required"] is False for item in payload["agent_os_prepared_action_packets"])
assert all(item["status"] == "suggested" for item in payload["agent_os_prepared_action_packets"])
assert payload["agent_os_autonomy_gate"] == {}
assert "项目当前需要先收口核心验证" in payload["answer"]
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_step5_consumes_updates_and_persists_learning_ledger() -> None:
previous_ledger = {
"packet_version": "pmflow_ai_learning_ledger.v1",
"ledger_id": "lgr_previous",
"scope_ref": "project:141",
"learning_items": [
{
"learning_key": "single_priority_order",
"summary": "上一轮确认:用户希望只看一个优先级顺序。",
"outcome_type": "user_preference",
"outcome_status": "observed",
}
],
"decision_outcomes": [],
"experiment_results": [],
"execution_outcomes": [],
"user_preferences": [],
"consumed_memory_refs": [],
}
payload = await PMFlowAgentRuntimeService(
_FakeDB(),
tenant_id=12,
delivery_runtime=_FakeDeliveryRuntime(),
environment_service=PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=_RecordingAgentOSToolRegistry(),
),
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
db=object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
),
).run_space_turn(
request=RAGAskRequest(question="继续推进这个项目", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={
"context_capsules": [
{
"capsule_type": "operating_learning_ledger",
"scope_ref": "project:141",
"learning_ledger": previous_ledger,
}
],
"operating_outcomes": [
{
"outcome_type": "recommendation_decision",
"outcome_status": "accepted",
"source_ref": "creq_previous",
"target_ref": "project:141",
"summary": "用户接受了上一轮 MVP 收口建议。",
"learning_signals": {"decision_state": "accepted"},
}
],
},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["policy_version"] == "pmflow-agent-os-v2"
assert payload["agent_os_consumed_learning_ledger"] == {}
assert payload["agent_os_learning_ledger"] == {}
assert payload["learning_ledger"] == {}
assert payload["agent_os_next_cycle_triggers"] == []
assert any(step["key"] == "agent_answer_runtime" for step in payload["answer_process_packet"]["steps"])
assert any(item.__class__.__name__ == "PMFlowAgentMemory" for item in payload["pmflow_agent_run"].get("memories", [])) is False
assert "项目当前需要先收口核心验证" in payload["answer"]
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
assert sanitized["agent_os_operating_cycle"] == {}
assert sanitized["agent_os_learning_ledger"] == {}
@pytest.mark.asyncio
async def test_pmflow_agent_specialist_runtime_step3_does_not_bind_visible_project_list() -> None:
result = await PMFlowAgentSpecialistRuntimeService(
db=object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
).run(
request=RAGAskRequest(question="你是谁"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert result.skipped_reason == "本轮没有强项目锚点,未运行项目专业 agent。"
assert result.assignments == []
assert result.work_products == []
assert result.answer is None
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_step3_keeps_direct_dialogue_answer_even_with_project_anchor() -> None:
payload = await PMFlowAgentRuntimeService(
_FakeDB(),
tenant_id=12,
delivery_runtime=_FakeDirectDeliveryRuntime(),
environment_service=PMFlowAgentEnvironmentService(
db=object(),
tenant_id=12,
tool_registry=_RecordingAgentOSToolRegistry(),
),
specialist_runtime=PMFlowAgentSpecialistRuntimeService(
db=object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
),
).run_space_turn(
request=RAGAskRequest(question="你是谁", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["answer"].startswith("我是 PMFlow AI")
assert "可以帮你查看空间、项目和下一步" in payload["answer"]
assert payload["agent_os_work_products"] == []
assert "最应该先推进" not in payload["answer"]
@pytest.mark.asyncio
async def test_operating_runtime_step1_wraps_answer_agent_with_mission_and_work_products() -> None:
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
delivery_runtime=_FakeDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["processing_stage"] == "multi_agent_operating_runtime"
assert payload["answer_mode"] == "operating_runtime"
assert payload["operating_mission"]["packet_version"] == "pmflow_ai_operating_mission.v1"
assert payload["operating_mission"]["project_scope"]["object_refs"] == ["project:141"]
assert payload["project_world_state_packet"]["packet_version"] == "pmflow_ai_project_world_state.v1"
assert payload["project_world_state_packet"]["status"] == "ready"
assert payload["project_world_state_packet"]["coverage"]["covered_facets"]
assert payload["evidence_ledger"]
assert payload["operating_run_packet"]["packet_version"] == "pmflow_ai_operating_run.v1"
assert payload["operating_run_packet"]["project_world_state"]["packet_version"] == "pmflow_ai_project_world_state.v1"
assert payload["operating_run_packet"]["agent_council_review"]["packet_version"] == "pmflow_ai_agent_council_review.v1"
assert payload["operating_run_packet"]["status"] == "waiting_for_confirmation"
assert len(payload["agent_assignments"]) >= 6
assert {item["agent_id"] for item in payload["agent_assignments"]} >= {
"product_strategy_agent",
"mvp_scope_agent",
"pm_breakdown_agent",
"tech_architect_agent",
"qa_risk_agent",
"learning_agent",
}
assert all("project_world_state_packet" in item["input_refs"] for item in payload["agent_assignments"])
assert all("learning_ledger" in item["input_refs"] for item in payload["agent_assignments"])
assert payload["agent_work_products"]
assert all(item["claims"] for item in payload["agent_work_products"])
assert {item["product_type"] for item in payload["agent_work_products"]} >= {
"OpportunityBrief",
"MVPOption",
"TaskDraft",
"TechnicalArchitectureAssessment",
"QAPlan",
"LearningUpdate",
}
assert all(item["structured_output"] for item in payload["agent_work_products"])
assert all(item["verification"]["role_contract"] == item["product_type"] for item in payload["agent_work_products"])
assert any("evd_test" in item["evidence_refs"] for item in payload["agent_work_products"])
assert payload["product_object_projections"]
assert payload["product_object_projections"][0]["packet_version"] == "pmflow_ai_product_object_projection.v1"
assert payload["product_object_projections"] == payload["command_payload"]["product_object_projections"]
assert payload["answer"] != "项目当前需要先收口核心验证,再准备可确认的下一步。"
assert "最高杠杆" in payload["answer"] or "最该推进" in payload["answer"]
assert payload["command_payload"]["packet_version"] == "pmflow_ai_operating_command_payload.v1"
assert payload["command_payload"]["project_world_state"]["status"] == "ready"
assert payload["command_payload"]["evidence_ledger"]
assert payload["agent_council_review"]["priority_order"]
assert payload["command_payload"]["priority_order"] == payload["agent_council_review"]["priority_order"]
assert payload["operating_plan"]["priority_order"] == payload["agent_council_review"]["priority_order"]
assert payload["operating_plan"]["conflict_ledger"] == payload["conflict_ledger"]
assert payload["command_payload"]["prepared_actions"]
assert payload["autonomy_gate"]["packet_version"] == "pmflow_ai_autonomy_gate.v1"
assert payload["autonomy_gate"]["status"] == "prepared_waiting_confirmation"
assert payload["prepared_action_packets"] == payload["command_payload"]["prepared_actions"]
assert payload["confirmation_requests"] == payload["command_payload"]["confirmation_requests"]
assert payload["execution_preparations"] == payload["command_payload"]["execution_preparations"]
assert payload["operating_cycle"]["packet_version"] == "pmflow_ai_operating_cycle_state.v1"
assert payload["operating_cycle"]["trigger"]["trigger_type"] == "user_turn"
assert {item["trigger_type"] for item in payload["operating_cycle"]["supported_triggers"]} >= {
"user_turn",
"signal",
"project_creation",
"task_update",
"execution_result",
"daily_review",
"weekly_strategy_review",
}
assert payload["learning_ledger"]["packet_version"] == "pmflow_ai_learning_ledger.v1"
assert payload["learning_updates"] == payload["command_payload"]["learning_updates"]
assert payload["next_cycle_triggers"] == payload["command_payload"]["next_cycle"]["triggers"]
assert any(item.get("source_agent") in {"mvp_scope_agent", "pm_breakdown_agent", "product_strategy_agent"} for item in payload["prepared_action_packets"])
assert payload["answer_process_packet"]["route_display"] == "multi_agent_operating_runtime"
assert [step["key"] for step in payload["answer_process_packet"]["steps"][:8]] == [
"operating_mission",
"specialist_agent_assignment",
"project_world_state_read",
"specialist_work_products",
"agent_council_review",
"operating_plan_compile",
"autonomy_gate_prepared_actions",
"learning_ledger_update",
]
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
assert sanitized["processing_stage"] == "multi_agent_operating_runtime"
assert sanitized["project_world_state_packet"]["packet_version"] == "pmflow_ai_project_world_state.v1"
assert sanitized["agent_council_review"]["packet_version"] == "pmflow_ai_agent_council_review.v1"
assert sanitized["autonomy_gate"]["packet_version"] == "pmflow_ai_autonomy_gate.v1"
assert sanitized["conflict_ledger"]
assert sanitized["prepared_action_packets"]
assert sanitized["confirmation_requests"]
assert sanitized["execution_preparations"]
assert sanitized["product_object_projections"]
assert sanitized["operating_cycle"]["packet_version"] == "pmflow_ai_operating_cycle_state.v1"
assert sanitized["learning_ledger"]["packet_version"] == "pmflow_ai_learning_ledger.v1"
assert sanitized["outcome_observations"]
assert sanitized["learning_updates"]
assert sanitized["next_cycle_triggers"]
assert sanitized["evidence_ledger"]
assert sanitized["command_payload"]["packet_version"] == "pmflow_ai_operating_command_payload.v1"
assert "agent_workspace" not in sanitized
assert "answer_runtime_packet" not in sanitized
@pytest.mark.asyncio
async def test_operating_runtime_accepts_stream_event_sink_and_delegates_it() -> None:
delivery_runtime = _RecordingEventSinkDeliveryRuntime()
events: list[dict] = []
async def sink(event: dict) -> None:
events.append(event)
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
delivery_runtime=delivery_runtime,
).run_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
event_sink=sink,
)
assert payload["processing_stage"] == "multi_agent_operating_runtime"
assert delivery_runtime.saw_event_sink is True
assert events == [{"type": "step.completed", "step": {"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}}]
@pytest.mark.asyncio
async def test_operating_runtime_open_research_projects_partial_evidence_to_research_recovery() -> None:
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeOpenResearchProjectWorldStateService(),
delivery_runtime=_FakeOpenResearchDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="最近AI会议市场怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": []}},
)
world_state = payload["project_world_state_packet"]
research_facet = next(item for item in world_state["facets"] if item["facet_key"] == "external_research_evidence")
projection = payload["product_object_projections"][0]
prepared = payload["prepared_action_packets"]
confirmations = payload["confirmation_requests"]
assert world_state["project_refs"] == []
assert research_facet["status"] == "partial"
assert projection["object_type"] == "ResearchNote"
assert projection["status"] == "draft"
assert any(item["action_family"] == "research_recovery" for item in prepared)
assert any(item["source_action_type"] == "continue_research" for item in prepared)
assert any(item["action_type"] == "continue_research" and item["action_label"] == "继续补研究" for item in confirmations)
assert all(item["can_execute_after_confirmation"] is True for item in confirmations if item["action_type"] == "continue_research")
recovery_payload = next(item for item in prepared if item["action_family"] == "research_recovery")["payload"]["research_recovery"]
assert len(recovery_payload["source_plan"]) == 3
assert recovery_payload["queries"]
assert "OpportunityBrief" not in {item["object_type"] for item in payload["product_object_projections"]}
@pytest.mark.asyncio
async def test_operating_runtime_promotes_nested_agent_run_observations_before_specialists() -> None:
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeOpenResearchProjectWorldStateService(),
delivery_runtime=_FakeNestedResearchDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="最近AI会议市场怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": []}},
)
world_state = payload["project_world_state_packet"]
research_facet = next(item for item in world_state["facets"] if item["facet_key"] == "external_research_evidence")
facts = {item["fact_key"]: item["value"] for item in research_facet["facts"]}
confirmations = payload["confirmation_requests"]
assert len(facts["source_candidates"]) == 2
assert len(facts["usable_claims"]) == 1
assert {item["agent_id"] for item in payload["agent_work_products"]} == {
"idea_incubation_agent",
"market_research_agent",
"competitor_agent",
"product_strategy_agent",
"growth_agent",
"monetization_agent",
"learning_agent",
}
assert {item["action_family"] for item in payload["prepared_action_packets"]} == {"research_recovery"}
assert all(item["action_type"] == "continue_research" for item in confirmations)
assert all(item["can_execute_after_confirmation"] is True for item in confirmations)
assert payload["answer_canvas"]["direct_answer"] == payload["answer"]
blocked = {item["agent_id"]: item for item in payload["agent_work_products"] if item["status"] == "blocked"}
assert {"competitor_agent", "growth_agent", "monetization_agent"}.issubset(blocked)
def test_operating_registry_keeps_requested_specialists_and_leaves_dependency_gate_to_roles() -> None:
mission = PMFlowAIOperatingMission(
stage="research",
original_user_input="最近AI会议市场怎么样",
required_agents=["growth_agent", "monetization_agent", "market_research_agent"],
)
assignments = PMFlowAIOperatingAgentRegistry().assignments_for_mission(mission)
assert [item.agent_id for item in assignments] == ["growth_agent", "monetization_agent", "market_research_agent"]
@pytest.mark.asyncio
async def test_operating_runtime_step6_consumes_and_updates_learning_ledger() -> None:
previous_ledger = {
"packet_version": "pmflow_ai_learning_ledger.v1",
"ledger_id": "lgr_previous",
"scope_ref": "project:141",
"learning_items": [
{
"learning_key": "single_priority_order",
"summary": "上一轮确认:用户希望只看一个优先级顺序。",
"outcome_type": "user_preference",
"outcome_status": "observed",
}
],
"decision_outcomes": [],
"experiment_results": [],
"execution_outcomes": [],
"user_preferences": [],
"consumed_memory_refs": [],
}
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
delivery_runtime=_FakeDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="继续推进这个项目", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={
"context_capsules": [
{
"capsule_type": "operating_learning_ledger",
"scope_ref": "project:141",
"learning_ledger": previous_ledger,
}
],
"operating_outcomes": [
{
"outcome_type": "recommendation_decision",
"outcome_status": "accepted",
"source_ref": "creq_previous",
"target_ref": "project:141",
"summary": "用户接受了上一轮 MVP 收口建议。",
"learning_signals": {"decision_state": "accepted"},
}
],
},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
assert payload["consumed_learning_ledger"]["ledger_id"] == "lgr_previous"
assert payload["consumed_learning_ledger"]["learning_items"][0]["learning_key"] == "single_priority_order"
assert payload["learning_ledger"]["ledger_id"] == "lgr_previous"
assert any(item["outcome_status"] == "accepted" for item in payload["learning_ledger"]["decision_outcomes"])
assert any(item["trigger_type"] == "execution_result" for item in payload["next_cycle_triggers"])
assert "读取了上一轮" in payload["answer"]
capsules = space_api._upsert_operating_learning_ledger_capsule(capsules=[], learning_ledger=payload["learning_ledger"])
assert capsules[0]["capsule_type"] == "operating_learning_ledger"
assert capsules[0]["learning_ledger"]["ledger_id"] == "lgr_previous"
@pytest.mark.asyncio
async def test_operating_runtime_step5_prepares_actions_but_blocks_writes_and_handoffs() -> None:
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(),
delivery_runtime=_FakeDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
families = {item["action_family"] for item in payload["prepared_action_packets"]}
required_before = {
required
for request in payload["confirmation_requests"]
for required in request.get("required_before", [])
}
prohibited = set(payload["autonomy_gate"]["prohibited_side_effects"])
assert {"project_memory_candidate", "product_object_projection"} <= families
assert payload["execution_preparations"]
assert payload["execution_preparations"][0]["packet_version"] == "pmflow_ai_execution_preparation.v1"
assert payload["execution_preparations"][0]["readiness"] == "prepared_waiting_confirmation"
assert payload["execution_preparations"][0]["handoff_package"]["packet_version"] == "execution_bridge_handoff_preparation.v1"
assert payload["execution_preparations"][0]["handoff_package"]["result_writeback_contract"]["protocol_version"] == "execution_result_protocol_v1"
assert all(item["confirmation_required"] is True for item in payload["prepared_action_packets"])
assert all(item["status"] == "prepared_waiting_confirmation" for item in payload["prepared_action_packets"])
assert all(request["status"] == "waiting_for_user_confirmation" for request in payload["confirmation_requests"])
assert "write_project_memory" in prohibited
assert "execute_handoff" in prohibited
assert "write_project_memory" in required_before
assert "execute_handoff" in required_before
executable_requests = [request for request in payload["confirmation_requests"] if request.get("can_execute_after_confirmation")]
assert executable_requests
assert {request.get("action_type") for request in executable_requests} >= {"save_candidate", "start_execution_handoff"}
assert any(
request.get("action_payload", {}).get("payload", {}).get("handoff_package", {}).get("result_writeback_contract", {}).get("protocol_version")
== "execution_result_protocol_v1"
for request in executable_requests
)
assert "确认前只展示候选,不会写入项目资料、改动任务或交给执行环境" in payload["answer"]
def test_space_confirmation_continue_research_builds_server_side_rerun_prompt() -> None:
prompt = space_api._research_recovery_prompt_from_confirmation(
confirmation={
"title": "确认准备动作:准备 ResearchNote",
"action_payload": {
"payload": {
"research_recovery": {
"research_question": "最近 AI 会议市场怎么样",
"queries": ["AI conference market 2026 sponsorship", "AI event platform market"],
"research_gaps": ["缺少参会增长", "缺少商业化证据"],
}
}
},
},
request=space_api.PMFlowAIConfirmationExecutionRequest(),
)
assert "只读研究补证" in prompt
assert "重新经过 specialist/council" in prompt
assert "AI conference market 2026 sponsorship" in prompt
assert "不要写 Project Memory" in prompt
def test_space_confirmation_execution_handoff_parses_formal_task_contract() -> None:
result = space_api._execution_handoff_request_from_confirmation(
action_payload={
"target_ref": "project:141",
"payload": {
"handoff_package": {
"task_candidates": [{"target_ref": "task:9001", "title": "实现执行桥回写"}],
"repo_binding_id": "8",
"runtime_target": "codex",
}
},
},
supplement_text="先跑最小测试。",
)
assert result["project_id"] == 141
assert result["task_id"] == 9001
assert result["repo_binding_id"] == "8"
assert result["runtime_target"] == "codex"
assert result["note"] == "先跑最小测试。"
def test_space_confirmation_execution_handoff_blocks_without_formal_task() -> None:
result = space_api._execution_handoff_request_from_confirmation(
action_payload={"target_ref": "project:141", "payload": {"handoff_package": {"task_candidates": []}}},
supplement_text="",
)
assert result["blocked"] is True
assert "正式任务" in result["missing"]
assert "不能创建执行交接" in result["customer_message"]
@pytest.mark.asyncio
async def test_operating_runtime_step4_council_records_risk_veto_and_single_priority() -> None:
payload = await PMFlowAIOperatingRunService(
object(),
tenant_id=12,
project_world_state_service=_FakeProjectWorldStateService(
blocked_actions=[
{
"display_label": "先处理当前任务阻塞",
"reason": "项目世界状态显示该动作仍受阻,需要先解除阻塞。",
}
]
),
delivery_runtime=_FakeDeliveryRuntime(),
).run_turn(
request=RAGAskRequest(question="这个项目怎么推进好呢", project_id=141),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
conflict_types = {item["conflict_type"] for item in payload["conflict_ledger"]}
blocked_titles = {item["title"] for item in payload["operating_plan"]["blocked_actions"]}
priority_titles = [item["title"] for item in payload["operating_plan"]["priority_order"]]
assert payload["agent_council_review"]["status"] == "blocked"
assert "risk_veto_conflict" in conflict_types
assert "先处理当前任务阻塞" in blocked_titles
assert "先处理当前任务阻塞" not in priority_titles
assert priority_titles == [item["title"] for item in payload["command_payload"]["priority_order"]]
assert len(priority_titles) == len(set(priority_titles))
def test_space_api_frame_type_ignores_legacy_turn_state() -> None:
frame_type = space_api._space_frame_type_from_agent_payload(
{
"pmflow_ai_turn_state": {"answer_state": {"answer_shape": "legacy_status_answer"}},
"terminal_answer_state": {"terminal_status": "answerable"},
"agent_run_packet": {"status": "answered"},
}
)
assert frame_type == "answerable"
@pytest.mark.asyncio
async def test_agent_runtime_step2_lets_planner_choose_external_search_without_identity_template() -> None:
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_FakePlanner("external_search"),
tool_registry=_FakeToolRegistry(),
critic=_FakeCritic(ready=True),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
started_at=None,
)
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
assert "agent_step_planner" in step_keys
assert "agent_tool_external_search" in step_keys
assert "agent_tool_inspect_source_detail" in step_keys
assert "agent_state_critic" in step_keys
assert payload["agent_workspace"]["observations"][-1]["observation_type"] == "external_source_document"
assert payload["sources"] == [{"label": "公开来源", "url": "https://example.com/source"}]
assert "我是 PMFlow AI" not in payload["answer"]
assert "身份" not in payload["answer"]
assert "能力边界" not in payload["answer"]
assert payload["terminal_answer_state"]["packet_version"] == "pmflow_ai_agent_terminal_answer_state.v1"
assert payload["answer_runtime_packet"]["packet_version"] == "pmflow_ai_agent_answer_runtime_packet.v1"
assert payload["deliverable_packet"]["source_observation_ids"]
assert payload["deliverable_packet"]["sections"]
assert "判断依据:" not in payload["answer"]
assert "Claim Ledger" not in payload["answer"]
sanitized = sanitize_customer_rag_payload(payload, apply_presentation_contract=False)
assert "answer_runtime_packet" not in sanitized
assert "agent_workspace" not in sanitized
assert "pmflow_ai_turn_state" not in sanitized
assert "pmflow_ai_route_plan" not in sanitized
assert sanitized["deliverable_packet"]["packet_version"] == "pmflow_ai_agent_deliverable_packet.v1"
assert sanitized["terminal_answer_state"]["packet_version"] == "pmflow_ai_agent_terminal_answer_state.v1"
def test_agent_runtime_step2_keeps_legacy_goal_assessor_out_of_new_service() -> None:
from app.services.pmflow_ai_agent_runtime import agent_run_service
source = inspect.getsource(agent_run_service)
assert "PROMPT_PMFLOW_AI_TURN_GOAL_STATE_ASSESSOR" not in source
assert "PMFlowAITurnRuntimeService" not in source
assert "looks_like_" not in source
assert "operation_kind" not in source
assert "external_search" in AGENT_TOOL_CONTRACTS
def test_pmflow_ai_runtime_prompt_renderers_do_not_use_code_fallback() -> None:
service_roots = [
Path("backend/app/services/pmflow_ai_agent_runtime"),
Path("backend/app/services/pmflow_ai_operating_runtime"),
]
source = "\n".join(
path.read_text(encoding="utf-8")
for root in service_roots
for path in root.glob("*.py")
)
assert "code_fallback" not in source
assert "fallback_template=" not in source
def test_agent_tool_environment_exposes_typed_contracts_not_name_whitelist() -> None:
contract = AGENT_TOOL_CONTRACTS["inspect_project_operating_state"]
assert contract.read_or_write == "read"
assert contract.risk_level == "low"
assert "inspect_project_status" in contract.aliases
assert contract.input_schema
assert contract.output_schema
assert contract.permission_boundary
assert contract.success_signal
assert contract.failure_signal
assert contract.side_effects == []
@pytest.mark.asyncio
async def test_agent_runtime_v2_replans_when_critic_requests_more_evidence() -> None:
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["inspect_space_collections", "external_search"]),
tool_registry=_FakeToolRegistry(),
critic=_SequenceCritic(
[
PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
state_delta={"remaining_gap": "needs_external_source"},
reason="还需要继续读取外部证据。",
),
PMFlowAIAgentCriticResult(
ready_to_answer=True,
should_continue=False,
state_delta={"evidence_ready": True},
reason="证据已足够。",
),
]
),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="帮我分析下用AI来协助采购监督的系统怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
started_at=None,
)
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
assert "agent_step_planner_2" in step_keys
assert "agent_tool_external_search_2" in step_keys
assert "agent_tool_inspect_source_detail_2" in step_keys
assert "agent_state_critic_2" in step_keys
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
assert payload["agent_run_packet"]["iterations"] == 2
assert payload["agent_run_packet"]["tool_call_count"] == 3
assert [event["event_type"] for event in payload["agent_run_packet"]["event_log"]].count("plan") == 2
assert len(payload["agent_workspace"]["action_ledger"]) >= 2
assert any(item.get("tool_name") == "external_search" for item in payload["agent_workspace"]["attempt_ledger"])
assert payload["agent_workspace"]["deliverable_state"]["stop_reason"] == "ready_to_answer"
@pytest.mark.asyncio
async def test_agent_runtime_recovers_from_failed_source_detail_by_reading_untried_ranked_sources() -> None:
tool_registry = _RecoveryExternalToolRegistry()
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["external_search"]),
tool_registry=tool_registry,
critic=_SequenceCritic(
[
PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=False,
retry_would_change_state=False,
state_delta={"source_quality_blocked_claims": True},
reason="第一个来源不可读,旧链路会直接停住。",
),
PMFlowAIAgentCriticResult(
ready_to_answer=True,
should_continue=False,
retry_would_change_state=False,
state_delta={"recovered_with_second_source": True},
reason="补读未尝试来源后可以回答。",
),
]
),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="最近AI会议市场怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": []}},
started_at=None,
)
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
assert "agent_recovery_planner_2" in step_keys
assert "agent_tool_inspect_source_detail_2" in step_keys
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
assert payload["agent_run_packet"]["tool_call_count"] == 3
failed_first_url = tool_registry.inspect_calls[0][0]
assert failed_first_url not in tool_registry.inspect_calls[1]
assert "https://example.com/ai-conference-attendance-2026" in tool_registry.inspect_calls[1]
@pytest.mark.asyncio
async def test_agent_runtime_recovers_when_only_source_candidate_claims_exist() -> None:
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["external_search"]),
tool_registry=_CandidateClaimRecoveryToolRegistry(),
evidence_normalizer=_CandidateOnlyNormalizer(),
critic=_SequenceCritic(
[
PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
retry_would_change_state=True,
state_delta={"remaining_gaps": ["needs_conference_specific_sources"]},
reason="当前只有候选来源,没有可陈述业务事实,继续检索会改变状态。",
),
PMFlowAIAgentCriticResult(
ready_to_answer=True,
should_continue=False,
state_delta={"recovered": True},
reason="第二轮已补充观察。",
),
]
),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="最近AI会议市场怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": []}},
started_at=None,
)
step_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
assert "agent_recovery_planner_2" in step_keys
assert "agent_tool_external_search_2" in step_keys
assert payload["agent_run_packet"]["stop_reason"] == "ready_to_answer"
assert payload["agent_run_packet"]["iterations"] == 2
@pytest.mark.asyncio
async def test_pmflow_agent_runtime_uses_external_search_and_ledger_for_open_market_goal() -> None:
dynamic_runtime = PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["external_search"]),
tool_registry=_FakeToolRegistry(),
critic=_SequenceCritic(
[
PMFlowAIAgentCriticResult(
ready_to_answer=True,
should_continue=False,
state_delta={"external_claim_ready": True},
reason="公开材料已进入 claim ledger。",
),
]
),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
)
payload = await PMFlowAgentRuntimeService(
_FakeDB(),
tenant_id=12,
delivery_runtime=dynamic_runtime,
).run_space_turn(
request=RAGAskRequest(question="帮我看看市场上有没有什么新的好项目"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
)
process_keys = [step["key"] for step in payload["answer_process_packet"]["steps"]]
assert "agent_tool_external_search" in process_keys
assert "agent_evidence_normalizer" in process_keys
assert any(step.get("tool_name") == "external_search" for step in payload["agent_os_steps"])
assert any(observation.get("source_kind") == "external_search" for observation in payload["agent_os_observations"])
assert not any(step.get("tool_name") == "run_opportunity_discovery_agents" for step in payload["agent_os_steps"])
assert "我是 PMFlow AI" not in payload["answer"]
assert payload["customer_evidence_packet"]["answer_type"]
assert payload["agent_os_verifications"][-1]["target_ref"] == "final_answer"
@pytest.mark.asyncio
async def test_agent_runtime_blocks_raw_external_source_noise_from_customer_answer() -> None:
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["external_search", "inspect_source_detail"]),
tool_registry=_NoisyExternalToolRegistry(),
critic=PMFlowAIAgentStateCritic(db=object(), tenant_id=12, generator=object()),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="最近AI漫剧行业怎么样"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": []}},
started_at=None,
)
answer = payload["answer"]
assert payload["deliverable_packet"]["status"] == "blocked"
assert payload["agent_workspace"]["claim_ledger"] == []
assert payload["agent_workspace"]["evidence_normalizations"][0]["source_quality"] == "noisy"
assert "news@iimedia.cn" not in answer
assert "020-8922-4700" not in answer
assert "&emsp;" not in answer
assert "联系我们 订阅邮箱" not in answer
assert "网页噪音" in answer or "证据" in answer
assert all("news@iimedia.cn" not in str(item) for item in payload["deliverable_packet"]["sections"])
def test_agent_answer_runtime_never_promotes_fact_ledger_to_customer_claims() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="检查外部来源")
workspace.fact_ledger = [
PMFlowAIAgentFactLedgerEntry(
fact_type="external_evidence",
fact_text="原始网页:news@example.com 020-1234-5678 &emsp; 联系我们 订阅邮箱",
source_tool="external_search",
)
]
terminal_state = build_agent_terminal_answer_state(
question="检查外部来源",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
observation=None,
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=False,
retry_would_change_state=False,
missing_state=["缺少可入账 claim"],
reason="claim ledger 为空。",
),
)
answer = render_agent_terminal_state_fallback(terminal_state)
assert terminal_state.claims == []
assert "news@example.com" not in answer
assert "020-1234-5678" not in answer
assert "&emsp;" not in answer
assert "这轮还没有形成可以可靠展示的结论" in answer
def test_non_usable_normalization_cannot_enter_claim_ledger() -> None:
observation = PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
facts=[
{
"type": "external_evidence",
"title": "公开页面",
"summary": "联系我们 020-1234-5678 news@example.com &emsp; 订阅邮箱",
"url": "https://example.com/noisy",
}
],
sources=[{"label": "公开页面", "url": "https://example.com/noisy"}],
confidence="high",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="检查外部来源")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert workspace.claim_ledger == []
assert workspace.boundary_ledger
def test_source_candidate_normalization_does_not_enter_claim_ledger() -> None:
observation = PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
facts=[
{
"type": "external_evidence",
"title": "AI Meeting Assistants 2026",
"summary": "This page may contain related market material.",
"url": "https://example.com/ai-meeting-assistants",
}
],
sources=[{"label": "AI Meeting Assistants 2026", "url": "https://example.com/ai-meeting-assistants"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert workspace.claim_ledger == []
assert workspace.boundary_ledger
assert any("候选来源" in boundary.customer_safe_reason for boundary in workspace.boundary_ledger)
def test_external_search_candidate_summary_never_enters_claim_ledger_before_reading_source() -> None:
observation = PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
facts=[
{
"type": "external_evidence",
"title": "Industry report candidate",
"summary": "A 2026 public report says adoption increased by 42% and sponsors expanded budgets.",
"url": "https://example.com/report-candidate",
}
],
sources=[{"label": "Industry report candidate", "url": "https://example.com/report-candidate"}],
confidence="high",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="最近行业市场怎么样")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert workspace.claim_ledger == []
assert normalization.source_normalizations[0].rejected_reason == "search_candidate_requires_source_reading"
def test_external_source_document_can_use_event_market_signals_after_source_reading() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "RAISE Summit 2026",
"url": "https://example.com/raise-summit",
"text": (
"European AI conferences are shifting toward applied enterprise AI, buyer networking, "
"and sponsor-backed communities. Upcoming events like RAISE Summit 2026 report over "
"9,000 attendees and dedicated enterprise AI tracks."
),
}
],
sources=[{"label": "RAISE Summit 2026", "url": "https://example.com/raise-summit"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(
current_goal="最近AI会议市场怎么样",
goal_frame=PMFlowAIAgentGoalFrame(
user_goal="判断AI会议活动市场的近期态势",
desired_output="给出有边界的市场研究判断",
evidence_requirements=["AI conference attendance", "conference market activity"],
interpretation_branches=[
{
"branch_id": "b1",
"meaning": "AI会议和活动市场",
"user_language_prior": "high",
"anchor_terms": ["AI conference", "conference", "event", "attendance", "attendees", "summit"],
},
{
"branch_id": "b2",
"meaning": "AI技术软件市场",
"user_language_prior": "medium",
"anchor_terms": ["software", "model", "agentic AI"],
},
],
),
)
normalization = fallback_normalize_observation(
question="最近AI会议市场怎么样",
workspace=workspace,
observation=observation,
)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "usable"
assert workspace.claim_ledger
assert any("9,000 attendees" in claim.claim_text for claim in workspace.claim_ledger)
assert all("###" not in claim.claim_text for claim in workspace.claim_ledger)
def test_external_source_page_shell_does_not_enter_claim_ledger() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "Forbes Councils Article",
"url": "https://example.com/forbes-councils",
"text": (
"Forbes Councils Paid Program Subscribe Sign In Newsletter Share This Article "
"Contact Us Privacy Terms Copyright &#x27;AI events&#x27; Read More Premium "
"About Us Site Map Follow Us Advertisement"
),
}
],
sources=[{"label": "Forbes Councils Article", "url": "https://example.com/forbes-councils"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="做一轮公开市场研究")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert workspace.claim_ledger == []
assert normalization.source_normalizations[0].rejected_reason == "raw_source_noise"
def test_operating_research_evidence_ignores_raw_and_generic_claim_texts() -> None:
workspace = {
"claim_ledger": [
{
"claim_type": "external_source_evidence",
"claim_text": "公开来源《Example》提供了与本轮目标相关的材料:Subscribe Sign In Newsletter Privacy Terms Advertisement",
},
{
"claim_type": "external_source_evidence",
"claim_text": "A 2026 industry survey reported 42% revenue growth among vendors, and enterprise buyers increased budgets for specialized solutions.",
},
{
"claim_type": "external_source_evidence",
"claim_text": "3万人 | Example | ### 关键增长驱动因素 - 预计2026年形成$330-480亿美元市场 --- ## 区域市场格局",
},
]
}
deliverable = {
"sections": [
{
"section_kind": "claims",
"items": [
{"summary": "候选来源需要结合其它来源交叉验证。"},
{"summary": "The report shows demand increased in 2026 as budgets shifted toward practical adoption programs."},
],
}
]
}
claims = _claim_texts(workspace, deliverable)
assert len(claims) == 2
assert all("Subscribe" not in item for item in claims)
assert all("候选来源" not in item for item in claims)
assert all("###" not in item for item in claims)
def test_external_source_document_fallback_extracts_goal_relevant_claims_without_llm() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "AI Conference Outlook 2026",
"url": "https://example.com/ai-conference-outlook",
"text": (
"AI Conference Outlook 2026 reports that enterprise buyers are increasing budgets for specialized "
"AI conferences and event programs. The same report says organizers are packaging sponsorship, "
"training and community formats around practical AI adoption, with revenue growth expected in 2026."
),
}
],
sources=[{"label": "AI Conference Outlook 2026", "url": "https://example.com/ai-conference-outlook"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "usable"
assert normalization.usable_claims
assert workspace.claim_ledger
assert workspace.claim_ledger[0].claim_kind == "substantive_fact"
assert workspace.claim_ledger[0].claim_type == "external_source_evidence"
assert "提供了与本轮目标相关的材料" not in workspace.claim_ledger[0].claim_text
def test_source_relevance_gate_blocks_raw_ai_market_page_as_research_claim() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="获取近期人工智能相关会议市场的整体态势",
desired_output="简要综述 AI 会议市场现状、活动活跃度及主要趋势。",
interpretation_branches=[
{
"branch_id": "b1",
"meaning": "行业活动生态:关注AI领域会议、峰会、论坛等活动的近期动态",
"semantic_decomposition": "AI领域 + 会议活动 + 市场活跃度 + 状态评估",
"user_language_prior": "high",
"confidence": "high",
"anchor_terms": ["AI", "会议", "市场"],
}
],
evidence_requirements=[
"近期主要AI会议活动及其关注热点",
"AI会议活动的整体趋势描述",
"公开媒体或行业分析对会议活跃度的评价",
],
)
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "中国CIO峰会",
"url": "https://example.com/cio-summit",
"text": (
"中国CIO峰会 上海站 | 2026年10月23日 深圳站:5月15日 立即注册 "
"当前,中国人工智能市场发展迅猛,IDC数据显示,2024年中国生成式AI占AI市场投资总规模的18.2%。"
"随着生成式AI技术发展,2029年生成式AI投资占比将达到41.1%,投资规模超450亿美元。"
"企业员工可借助生成式AI生成结构化业务洞察,开发人员能够进行代码重构。"
),
}
],
confidence="medium",
relevance_to_goal="high",
)
normalization = fallback_normalize_observation(
question="最近AI会议市场怎么样",
workspace=workspace,
observation=observation,
)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality != "usable"
assert workspace.claim_ledger == []
def test_research_note_threshold_blocks_single_dimension_external_claims() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="获取近期人工智能相关会议市场的整体态势",
desired_output="简要综述 AI 会议市场现状、活动活跃度及主要趋势。",
evidence_requirements=[
"近期主要AI会议活动及其关注热点",
"AI会议活动的整体趋势描述",
"公开媒体或行业分析对会议活跃度的评价",
],
interpretation_branches=[
{
"branch_id": "b1",
"meaning": "行业活动生态:关注AI领域会议、峰会、论坛等活动的近期动态",
"user_language_prior": "high",
"confidence": "high",
}
],
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="external_source_evidence",
claim_text="2026年某公开来源显示多个AI主题会议正在聚焦智能体和产业落地热点。",
support_refs=["https://source-a.example/report"],
interpretation_branch_id="b1",
relevance_to_user_goal="partial",
claim_kind="substantive_fact",
confidence="medium",
),
PMFlowAIAgentClaim(
claim_type="external_source_evidence",
claim_text="另一公开来源显示AI主题会议议程正在聚焦智能体和产业落地热点。",
support_refs=["https://source-b.example/report"],
interpretation_branch_id="b1",
relevance_to_user_goal="partial",
claim_kind="substantive_fact",
confidence="medium",
),
]
result = _enforce_critic_contract(
PMFlowAIAgentCriticResult(
ready_to_answer=True,
should_continue=False,
retry_would_change_state=False,
recover_or_continue="compose_answer",
next_step_hint="compose_answer",
),
workspace=workspace,
observation=PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
confidence="medium",
relevance_to_goal="high",
),
)
assert result.ready_to_answer is False
assert result.should_continue is True
assert any("研究记录" in item for item in result.missing_state)
def test_external_source_document_extracts_clean_fact_from_noisy_page_shell() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "Enterprise Collaboration Summit",
"url": "https://example.com/summit",
"text": (
"Subscribe Sign In Newsletter Share Privacy Terms Advertisement Contact Us "
"Enterprise Collaboration Summit 2026 reports 1,200 enterprise attendees and says sponsors "
"increased budgets by 38% as workflow automation moved into mainstream conference programs. "
"Read More Premium Site Map Copyright"
),
}
],
sources=[{"label": "Enterprise Collaboration Summit", "url": "https://example.com/summit"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(
current_goal="最近企业协作峰会市场怎么样",
goal_frame=PMFlowAIAgentGoalFrame(
user_goal="判断企业协作峰会市场最近怎么样",
interpretation_branches=[
{
"branch_id": "b1",
"meaning": "企业协作峰会和活动市场趋势",
"user_language_prior": "high",
"anchor_terms": ["enterprise collaboration summit", "conference", "sponsor"],
},
{
"branch_id": "b2",
"meaning": "企业协作软件市场趋势",
"user_language_prior": "medium",
"anchor_terms": ["collaboration software"],
},
],
),
)
normalization = fallback_normalize_observation(
question="最近企业协作峰会市场怎么样",
workspace=workspace,
observation=observation,
)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "usable"
assert workspace.claim_ledger
claim = workspace.claim_ledger[0]
assert claim.interpretation_branch_id == "b1"
assert "1,200 enterprise attendees" in claim.claim_text
assert "Subscribe Sign In Newsletter" not in claim.claim_text
assert "Contact Us" not in claim.claim_text
def test_operating_source_rows_use_clean_gated_fragments_instead_of_page_shell() -> None:
observations = [
{
"source_kind": "inspect_source_detail",
"observation_type": "external_source_document",
"facts": [
{
"type": "external_source_text",
"title": "Enterprise Collaboration Summit",
"url": "https://example.com/summit",
"text": (
"Subscribe Sign In Newsletter Privacy Terms Advertisement "
"The 2026 enterprise collaboration summit drew 1,200 attendees and reported 38% sponsor budget growth."
),
}
],
}
]
rows = _external_source_rows(observations)
assert len(rows) == 1
assert "1,200 attendees" in rows[0]["summary"]
assert "Subscribe Sign In" not in rows[0]["summary"]
@pytest.mark.asyncio
async def test_agent_evidence_normalizer_timeboxes_slow_llm_and_falls_back(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER_TIMEOUT_SECONDS", "0.1")
class SlowGenerator:
async def generate(self, *_args, **_kwargs):
await asyncio.sleep(1)
return "{}"
observation = PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
facts=[
{
"type": "external_evidence",
"title": "AI Meeting Assistants 2026",
"summary": "This page may contain related market material.",
"url": "https://example.com/ai-meeting-assistants",
}
],
sources=[{"label": "AI Meeting Assistants 2026", "url": "https://example.com/ai-meeting-assistants"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
normalization = await PMFlowAIAgentEvidenceNormalizer(
db=SimpleNamespace(execute=lambda *_args, **_kwargs: None),
tenant_id=12,
generator=SlowGenerator(),
).normalize(question="最近AI会议行业怎么样", workspace=workspace, observation=observation)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert normalization.boundaries
@pytest.mark.asyncio
async def test_agent_evidence_normalizer_uses_deterministic_extraction_when_llm_only_returns_gaps(
monkeypatch: pytest.MonkeyPatch,
) -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "智能协作会议系统市场观察",
"url": "https://example.com/collaboration-meeting-market",
"text": (
"2025年智能会议系统市场规模达到186亿元,具备AI能力的产品占比超过45%。"
"报告预计2027年AI会议系统市占率将突破75%,会议场景正在从单点纪要转向协作工作流。"
),
}
],
sources=[{"label": "智能协作会议系统市场观察", "url": "https://example.com/collaboration-meeting-market"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="最近智能会议市场怎么样")
monkeypatch.setattr(PMFlowAIAgentEvidenceNormalizer, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_EVIDENCE_NORMALIZER"))
generator = _SequenceGenerator(
[
"""
{
"source_quality": "noisy",
"gaps": ["模型没有抽出可用事实"]
}
"""
]
)
normalization = await PMFlowAIAgentEvidenceNormalizer(
db=SimpleNamespace(execute=lambda *_args, **_kwargs: None),
tenant_id=12,
generator=generator,
).normalize(question="最近智能会议市场怎么样", workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "usable"
assert workspace.claim_ledger
assert "186亿元" in workspace.claim_ledger[0].claim_text
assert "模型没有抽出可用事实" in normalization.gaps
def test_attention_queue_normalization_keeps_action_candidates() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_attention_queue",
observation_type="attention_queue",
summary="已读取 1 条客户安全注意力队列候选。",
facts=[
{
"type": "action_candidate",
"title": "审查任务 194 的 PR 结果",
"summary": "任务 194 当前 PR 已提交,需要确认验收状态。",
"target_ref": "project:26",
"target_title": "儿童项目",
"href": "/execution-bridge?projectId=26&taskId=194",
"reason": "执行状态 pr_open,下一步是人工审查。",
"completion_criteria": "PR 审查完成,验收状态更新。",
"safety": "prepare_only",
"requires_confirmation": True,
},
{
"type": "object_affordance",
"object_ref": "project:26",
"title": "儿童项目",
"action_label": "审查任务 194 的 PR 结果",
"href": "/execution-bridge?projectId=26&taskId=194",
},
],
sources=[{"label": "PMFlow 注意力队列"}],
confidence="high",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="儿童那个项目下一步我要做什么呢")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
apply_evidence_normalization(workspace, normalization=normalization)
assert normalization.source_quality == "usable"
assert workspace.action_candidate_ledger
candidate = workspace.action_candidate_ledger[0]
assert candidate.title == "审查任务 194 的 PR 结果"
assert candidate.href == "/execution-bridge?projectId=26&taskId=194"
assert "PR 审查完成" in candidate.completion_criteria
assert workspace.affordance_ledger[0].object_ref == "project:26"
def test_terminal_answer_uses_action_candidate_without_generic_industry_fallback() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="儿童那个项目下一步我要做什么呢")
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="project_execution_package",
claim_text="儿童项目当前选中任务 194,执行状态是 PR 已提交。",
customer_value="支撑项目下一步判断。",
confidence="high",
)
]
workspace.action_candidate_ledger = [
PMFlowAIAgentActionCandidateLedgerEntry(
title="审查任务 194 的 PR 结果",
target_ref="project:26",
target_title="儿童项目",
href="/execution-bridge?projectId=26&taskId=194",
reason="执行状态 pr_open,下一步需要确认交付与验收。",
completion_criteria="PR 审查完成,验收状态更新。",
safety="prepare_only",
confidence="high",
)
]
terminal_state = build_agent_terminal_answer_state(
question="儿童那个项目下一步我要做什么呢",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="answer"),
observation=None,
critic_result=PMFlowAIAgentCriticResult(ready_to_answer=True, should_continue=False),
)
answer = render_agent_terminal_state_fallback(terminal_state)
assert "审查任务 194 的 PR 结果" in answer
assert "PR 审查完成" in answer
assert "行业信号" not in answer
assert "受控初判" not in answer
@pytest.mark.asyncio
async def test_project_execution_package_preserves_selected_task_action_detail() -> None:
registry = PMFlowAIAgentToolRegistry(db=object(), tenant_id=12)
package = await registry._build_project_execution_package_payload(
project_id=26,
project_ref="project:26",
project_title="儿童项目",
execution_payload={
"execution": {"taskId": 194, "status": "pr_open", "testSummary": {"status": "passed"}},
"activeDevTasks": [{"id": 194, "title": "修复儿童项目首页登录链路", "status": "in_review"}],
"acceptance": {"status": "pending_review"},
"releaseGate": {"status": "blocked"},
"qualityContract": {"summary": {"releaseBlockingMissing": 1}},
},
truth_payload={"selectedTaskId": 194},
)
# Use the public observation projection shape that the normalizer consumes.
from app.services.pmflow_ai_agent_runtime.agent_tool_registry import _execution_package_facts
projected = _execution_package_facts(package)
selected = next(item for item in projected if item.get("type") == "selected_task")
action = next(item for item in projected if item.get("type") == "action_candidate")
assert selected["task_id"] == "194"
assert selected["title"] == "修复儿童项目首页登录链路"
assert action["target_ref"] == "project:26"
assert action["href"] == "/execution-bridge?projectId=26&taskId=194"
assert "发布阻塞级缺失证据" in action["completion_criteria"]
assert "补齐 1 个发布阻塞级证据" in action["title"]
def test_external_source_detail_rejects_404_and_navigation_noise() -> None:
observation = PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "404 页面不存在",
"url": "https://example.com/missing",
"text": "404 页面不存在 登录 注册 隐私政策 用户协议 Copyright 网站地图",
}
],
sources=[{"label": "404 页面不存在", "url": "https://example.com/missing"}],
confidence="medium",
relevance_to_goal="high",
)
workspace = PMFlowAIAgentWorkspace(current_goal="我这个项目和宝宝树对比怎么样")
normalization = fallback_normalize_observation(workspace=workspace, observation=observation)
assert normalization.source_quality == "noisy"
assert normalization.usable_claims == []
assert normalization.boundaries
@pytest.mark.asyncio
async def test_agent_answer_runtime_returns_chinese_partial_answer_when_safe_claims_exist() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI行业怎么样")
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="market_investment_trend",
claim_text="Global AI investment is projected to reach $300 billion by 2026.",
support_refs=["Morgan Stanley"],
confidence="medium",
),
PMFlowAIAgentClaim(
claim_type="model_performance_trend",
claim_text="Open-weight models are narrowing the performance gap with proprietary models.",
support_refs=["LLM Stats"],
confidence="medium",
),
]
workspace.boundary_ledger = [
PMFlowAIAgentBoundary(
boundary_type="freshness_gap",
blocked_claim="缺少 2026 年 5 月之后的实时材料。",
customer_safe_reason="这轮公开材料还没有覆盖 2026 年 5 月之后的变化。",
allowed_alternative="继续补充最新公开来源后,再把判断更新成完整结论。",
)
]
result = await PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()).run(
question="最近AI行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
observation=None,
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=False,
retry_would_change_state=False,
reason="已有部分 claim,但缺少实时材料。",
),
)
assert result.terminal_state.terminal_status == "partially_answerable"
assert result.deliverable_packet.status == "partial"
assert result.deliverable_packet.deliverable_kind == "decision_packet"
assert result.quality_findings == []
assert "这轮已有材料能支撑一部分判断" in result.answer
assert "Global AI investment" not in result.answer
assert "Open-weight models" not in result.answer
assert any("\u4e00" <= ch <= "\u9fff" for ch in result.answer)
assert "Global AI investment" not in str(result.deliverable_packet.sections)
assert "Open-weight models" not in str(result.deliverable_packet.sections)
def test_agent_answer_critic_allows_longer_customer_safe_blocked_answer() -> None:
terminal_state = PMFlowAIAgentTerminalAnswerState(
user_goal="最近AI会议行业怎么样",
terminal_status="waiting_for_evidence",
boundaries=[
PMFlowAIAgentBoundary(
boundary_type="source_gap",
blocked_claim="缺少会议行业垂直公开来源。",
customer_safe_reason="这轮查到的材料偏向泛 AI 行业,不能直接说明 AI 会议行业最近的发展。",
allowed_alternative="继续换用会议技术、活动平台和标准组织来源补证据。",
)
],
answer_intent_plan=PMFlowAIAgentAnswerIntentPlan(lead_boundary_id="boundary_missing", detail_level="brief"),
)
answer = (
"目前还不能可靠判断最近 AI 会议行业怎么样。已经查到的材料更多是在讲泛 AI 趋势、语音平台或宏观产业变化,"
"没有足够覆盖会议与活动技术场景,例如虚拟/混合会议平台的新功能、实时翻译在会场中的使用、智能议程生成、"
"线下和线上融合的落地进展,也缺少最近三个月内可追溯的垂直来源。"
"所以这轮更稳妥的结论是:不能把现有材料当作行业判断依据。下一步应该换查 PCMA、MPI、Zoom、Teams、Webex、"
"Airmeet、Run The World、IEEE 或 ISO/IEC 这类会议技术、平台公告和标准组织来源,再用发布时间和具体事件交叉确认。"
)
assert critique_agent_customer_answer(terminal_state=terminal_state, answer=answer) == []
@pytest.mark.asyncio
async def test_agent_state_critic_respects_recovery_veto_when_more_reading_would_change_state() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="了解最近AI会议行业怎么样",
desired_output="给出方向性判断并说明边界。",
answer_policy="directional_answer",
source_policy="credible_public_sources",
freshness_policy="prefer_recent_not_strict",
missing_evidence_policy="answer_with_boundaries",
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="vendor_product_update",
claim_text="公开来源显示会议平台正在把 AI 会议能力从摘要扩展到工作流和后续执行。",
customer_value="支撑方向性行业判断。",
support_refs=["公开来源"],
confidence="medium",
)
]
workspace.gap_ledger = [
# The remaining gap should become a boundary, not a hard blocker.
PMFlowAIAgentGapLedgerEntry(
gap_type="market_size_gap",
description="缺少可用于判断精确市场规模和份额的独立报告。",
blocking=True,
user_actionable=False,
)
]
generator = _SequenceGenerator(
[
"""
{
"ready_to_answer": false,
"should_continue": true,
"needs_user_input": false,
"retry_would_change_state": true,
"state_delta": {"remaining_gaps": ["缺少完整报告"]},
"findings": ["还没覆盖全部维度"],
"missing_state": ["完整行业报告"],
"recover_or_continue": "continue_with_tool",
"next_step_hint": "select_next_read_tool",
"reason": "仍缺少完整证据。"
}
"""
]
)
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
question="最近AI会议行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
observation=PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
confidence="medium",
relevance_to_goal="high",
),
)
assert result.ready_to_answer is False
assert result.should_continue is True
assert result.retry_would_change_state is True
assert result.recover_or_continue == "continue_with_tool"
assert result.next_step_hint == "select_next_read_tool"
assert any("缺少可用于判断精确市场规模和份额的独立报告" in item for item in result.state_delta["remaining_gaps"])
@pytest.mark.asyncio
async def test_agent_state_critic_does_not_answer_from_source_candidate_only(monkeypatch: pytest.MonkeyPatch) -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="了解最近AI会议行业怎么样",
desired_output="给出方向性判断并说明边界。",
answer_policy="directional_answer",
source_policy="credible_public_sources",
freshness_policy="prefer_recent_not_strict",
missing_evidence_policy="answer_with_boundaries",
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="external_source_candidate",
claim_text="公开来源《AI Meeting Assistants 2026》提供了与本轮目标相关的外部材料,但仍需要结合其它来源交叉验证后再下强结论。",
customer_value="确认本轮有可追溯外部材料可进入后续抽取和交叉验证。",
support_refs=["https://example.com/ai-meeting-assistants"],
confidence="medium",
)
]
generator = _SequenceGenerator(
[
"""
{
"ready_to_answer": false,
"should_continue": true,
"needs_user_input": false,
"retry_would_change_state": true,
"state_delta": {"remaining_gaps": ["还没有业务事实 claim"]},
"findings": ["只有候选来源"],
"missing_state": ["需要读取正文或换来源抽取业务事实"],
"recover_or_continue": "continue_with_tool",
"next_step_hint": "select_next_read_tool",
"reason": "候选来源不能支撑最终回答。"
}
"""
]
)
monkeypatch.setattr(PMFlowAIAgentStateCritic, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC"))
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
question="最近AI会议行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
observation=PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
confidence="medium",
relevance_to_goal="high",
),
)
assert result.ready_to_answer is False
assert result.should_continue is True
assert result.recover_or_continue == "continue_with_tool"
assert result.next_step_hint == "select_next_read_tool"
assert any("业务事实" in item or "读取正文" in item for item in result.missing_state)
@pytest.mark.asyncio
async def test_agent_state_critic_vetoes_raw_page_fragment_claim(monkeypatch: pytest.MonkeyPatch) -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="做一轮公开市场研究")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="做一轮公开市场研究",
desired_output="给出方向性判断并说明边界。",
answer_policy="directional_answer",
source_policy="credible_public_sources",
freshness_policy="prefer_recent_not_strict",
missing_evidence_policy="answer_with_boundaries",
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="external_source_evidence",
claim_text="Forbes Councils Paid Program Subscribe Sign In Newsletter Share This Article Contact Us Privacy Terms Copyright Advertisement",
customer_value="支撑公开研究。",
support_refs=["https://example.com/noisy"],
confidence="medium",
)
]
generator = _SequenceGenerator(
[
"""
{
"ready_to_answer": true,
"should_continue": false,
"needs_user_input": false,
"retry_would_change_state": false,
"state_delta": {},
"findings": ["已有材料"],
"missing_state": [],
"recover_or_continue": "compose_answer",
"next_step_hint": "compose_answer",
"reason": "模型误判为可回答。"
}
"""
]
)
monkeypatch.setattr(PMFlowAIAgentStateCritic, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STATE_CRITIC"))
result = await PMFlowAIAgentStateCritic(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).review(
question="做一轮公开市场研究",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="inspect_source_detail"),
observation=PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
confidence="medium",
relevance_to_goal="high",
),
)
assert result.ready_to_answer is False
assert result.should_continue is False
assert result.recover_or_continue == "stop_blocked"
assert any("Claim Ledger" in item for item in result.missing_state)
def test_goal_frame_context_sanitizer_drops_prior_blocked_research_hypotheses() -> None:
safe = _goal_frame_safe_conversation_context(
{
"session_id": 654,
"last_subject": "最近AI会议行业怎么样",
"context_capsules": [
{
"capsule_type": "pmflow_agent_run",
"status": "blocked",
"research_goal": "理解最近三个月内AI会议行业的整体发展状况",
"agent_workspace": {"current_goal": "围绕 NeurIPS/ICML 搜索"},
},
{
"capsule_type": "external_research",
"topic": "AI conferences 2026",
"research_goal": "学术会议行业",
},
{
"capsule_type": "pmflow_ai_object_ledger",
"current_focus_object": {
"object_id": "project:1",
"object_type": "project",
"display_name": "童析一学龄前儿童行为评估助手",
"binding_strength": "session_object_ledger",
},
},
],
},
question="最近AI会议市场怎么样",
)
rendered = str(safe)
assert safe["current_user_question"] == "最近AI会议市场怎么样"
assert "research_goal" not in rendered
assert "NeurIPS" not in rendered
assert "external_research" not in rendered
assert "pmflow_agent_run" not in rendered
assert safe["context_capsules"][0]["current_focus_object"]["display_name"] == "童析一学龄前儿童行为评估助手"
def test_goal_frame_repairs_compact_modifier_phrase_with_context_product_branch() -> None:
frame = PMFlowAIAgentGoalFrame(
user_goal="理解最近XR教育市场怎么样",
desired_output="给出方向性判断。",
interpretation_branches=[
{
"branch_id": "b_academic",
"meaning": "围绕 XR 主题举办的教育活动和内容组织生态",
"confidence": "high",
"user_language_prior": "high",
},
{
"branch_id": "b_event",
"meaning": "商业 XR 教育论坛和展会的活动市场",
"confidence": "medium",
"user_language_prior": "medium",
},
],
selected_interpretation="围绕 XR 主题举办的教育活动和内容组织生态",
)
repaired = _ensure_compound_phrase_interpretation_branches(frame=frame, question="最近XR教育市场怎么样")
assert repaired.interpretation_branches[0]["branch_id"] == "b_context_product_ecosystem"
assert repaired.interpretation_branches[0]["user_language_prior"] == "high"
assert "产品、服务和工具生态" in repaired.interpretation_branches[0]["meaning"]
assert any("XR教育" in item for item in repaired.interpretation_branches[0]["anchor_terms"])
@pytest.mark.asyncio
async def test_agent_step_planner_expands_external_search_across_interpretation_branches(monkeypatch: pytest.MonkeyPatch) -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="了解最近AI会议行业怎么样",
desired_output="给出方向性判断并说明边界。",
interpretation_branches=[
{
"branch_id": "b_academic",
"meaning": "围绕 AI 主题举办的学术会议和投稿组织生态",
"confidence": "medium",
"user_language_prior": "medium",
},
{
"branch_id": "b_product",
"meaning": "由 AI 驱动或改造会议场景的产品、服务和工具生态",
"confidence": "medium",
"user_language_prior": "high",
},
],
selected_interpretation="围绕 AI 主题举办的学术会议和投稿组织生态",
answer_policy="directional_answer",
source_policy="credible_public_sources",
freshness_policy="prefer_recent_not_strict",
missing_evidence_policy="answer_with_boundaries",
)
generator = _SequenceGenerator(
[
"""
{
"action": "tool",
"tool_name": "external_search",
"tool_input": {
"search_queries": ["NeurIPS 2026 conference updates submission"],
"freshness_required": false
},
"rationale": "读取学术会议证据。",
"expected_observation": "公开来源。",
"stop_condition": "有证据。",
"risks_if_answer_now": ["证据不足"],
"confidence": "high"
}
"""
]
)
monkeypatch.setattr(PMFlowAIAgentStepPlanner, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_STEP_PLANNER"))
proposal = await PMFlowAIAgentStepPlanner(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).plan(
question="最近AI会议行业怎么样",
workspace=workspace,
conversation_context={},
tenant_project_context={},
)
queries = proposal.tool_input["search_queries"]
assert "NeurIPS 2026 conference updates submission" in queries
assert any("产品、服务和工具生态" in query for query in queries)
assert any(item.get("branch_id") == "b_product" for item in proposal.tool_input["branch_searches"])
@pytest.mark.asyncio
async def test_agent_state_critic_does_not_answer_when_high_prior_branch_is_uncovered() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="了解最近AI会议行业怎么样",
desired_output="给出方向性判断并说明边界。",
interpretation_branches=[
{
"branch_id": "b_academic",
"meaning": "围绕 AI 主题举办的学术会议和投稿组织生态",
"confidence": "medium",
"user_language_prior": "medium",
},
{
"branch_id": "b_product",
"meaning": "由 AI 驱动或改造会议场景的产品、服务和工具生态",
"confidence": "medium",
"user_language_prior": "high",
},
],
answer_policy="directional_answer",
source_policy="credible_public_sources",
freshness_policy="prefer_recent_not_strict",
missing_evidence_policy="answer_with_boundaries",
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="conference_scope_expansion",
claim_text="NeurIPS 2026 征稿范围覆盖多个跨学科方向。",
customer_value="支撑学术会议解释分支。",
support_refs=["https://neurips.cc/Conferences/2026/CallForPapers"],
interpretation_branch_id="b_academic",
relevance_to_user_goal="partial",
claim_kind="substantive_fact",
confidence="high",
)
]
generator = _SequenceGenerator(
[
"""
{
"ready_to_answer": true,
"should_continue": false,
"needs_user_input": false,
"retry_would_change_state": false,
"state_delta": {"remaining_gaps": []},
"findings": ["已有 NeurIPS claim"],
"missing_state": [],
"recover_or_continue": "compose_answer",
"next_step_hint": "compose_answer",
"reason": "已有局部证据。"
}
"""
]
)
result = await PMFlowAIAgentStateCritic(db=object(), tenant_id=12, generator=generator).review(
question="最近AI会议行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
observation=PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源。",
confidence="medium",
relevance_to_goal="high",
),
)
assert result.ready_to_answer is False
assert result.should_continue is True
assert result.recover_or_continue == "continue_with_tool"
assert result.next_step_hint == "select_next_read_tool"
assert result.retry_would_change_state is True
assert "产品、服务和工具生态" in result.missing_state[0]
def test_agent_answer_critic_rejects_mechanical_fallback_answer() -> None:
terminal_state = PMFlowAIAgentTerminalAnswerState(
user_goal="最近AI会议行业怎么样",
terminal_status="partially_answerable",
claims=[
PMFlowAIAgentClaim(
claim_type="trend",
claim_text="会议 AI 产品正在从纪要转向执行工作流。",
customer_value="支撑方向性判断。",
confidence="medium",
)
],
)
findings = critique_agent_customer_answer(
terminal_state=terminal_state,
answer="这轮已有材料能支撑一部分判断:可展示事实已有公开材料支撑,置信度较高;来源包括neurips.cc、openreview.net。。但还有缺口。",
)
assert "mechanical_fallback_answer" in findings
def test_agent_terminal_state_waits_for_evidence_when_critic_vetoes_even_with_claims() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="external_source_evidence",
claim_text="某 HR webinar 提到了 AI 管理趋势。",
customer_value="只能说明一个偏窄材料。",
support_refs=["https://example.com/hr-webinar"],
interpretation_branch_id="b_hr",
relevance_to_user_goal="partial",
claim_kind="substantive_fact",
confidence="medium",
)
]
workspace.boundary_ledger = [
PMFlowAIAgentBoundary(
boundary_type="source_gap",
blocked_claim="缺少 AI 会议活动市场主分支证据。",
customer_safe_reason="现有材料没有覆盖用户真正问的 AI 会议市场。",
allowed_alternative="继续读取更贴近会议活动市场的公开来源。",
)
]
terminal_state = build_agent_terminal_answer_state(
question="最近AI会议市场怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="tool", tool_name="external_search"),
observation=None,
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
retry_would_change_state=True,
state_delta={"remaining_gaps": ["主分支证据不足"]},
),
)
assert terminal_state.terminal_status == "waiting_for_evidence"
assert terminal_state.answer_intent_plan.lead_claim_id == ""
assert terminal_state.answer_intent_plan.lead_boundary_id
def test_recovery_query_diversifies_when_existing_claim_is_off_branch() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议市场怎么样")
workspace.goal_frame = PMFlowAIAgentGoalFrame(
user_goal="最近AI会议市场怎么样",
desired_output="判断 AI 会议活动市场趋势。",
evidence_requirements=["参会规模", "赞助需求", "会议活动供给"],
interpretation_branches=[
{
"branch_id": "b_activity",
"meaning": "AI 会议活动市场和峰会生态",
"user_language_prior": "high",
"anchor_terms": ["AI conference", "summit", "attendance", "sponsorship"],
},
{
"branch_id": "b_hr",
"meaning": "HR 领域 AI webinar",
"user_language_prior": "low",
"anchor_terms": ["HR", "talent acquisition"],
},
],
)
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="external_source_evidence",
claim_text="HR webinar 讨论了 AI 对人才招聘的影响。",
interpretation_branch_id="b_hr",
relevance_to_user_goal="partial",
claim_kind="substantive_fact",
confidence="medium",
)
]
proposal = _recovery_proposal_from_critic(
question="最近AI会议市场怎么样",
workspace=workspace,
latest_observation=PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取偏题来源正文。",
confidence="medium",
relevance_to_goal="medium",
),
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
retry_would_change_state=True,
state_delta={"remaining_gaps": ["主分支 AI 会议活动市场缺少参会和赞助证据"]},
missing_state=["AI 会议活动市场主分支未覆盖"],
),
tool_call_count=2,
run_budget=SimpleNamespace(max_tool_calls=8),
)
assert proposal is not None
assert proposal.tool_name == "external_search"
queries = proposal.tool_input["search_queries"]
assert len(queries) >= 2
assert any("AI 会议活动市场和峰会生态" in query for query in queries)
assert any("参会规模" in query or "赞助需求" in query for query in queries)
assert proposal.tool_input["result_limit"] == 8
def test_operating_claim_projection_ignores_claims_when_terminal_state_waits_for_evidence() -> None:
workspace = {
"claim_ledger": [
{
"claim_type": "external_source_evidence",
"claim_text": "HR webinar 讨论了 AI 对人才招聘的影响。",
"confidence": "medium",
}
]
}
deliverable = {
"sections": [
{
"section_kind": "claims",
"items": [{"summary": "HR webinar 讨论了 AI 对人才招聘的影响。"}],
}
]
}
claims = _claim_texts(workspace, deliverable, terminal_state={"terminal_status": "waiting_for_evidence"})
assert claims == []
@pytest.mark.asyncio
async def test_agent_external_planner_defaults_to_non_strict_freshness_gate() -> None:
planner = _AgentRuntimeExternalPlanner(
query="最近AI会议行业怎么样",
tool_input={"search_queries": ["AI meeting assistant industry trends"]},
)
plan = await planner.build_plan(question="最近AI会议行业怎么样")
assert plan.freshness_required is False
assert plan.search_queries == ["AI meeting assistant industry trends"]
@pytest.mark.asyncio
async def test_agent_external_planner_can_request_broader_bounded_result_limit() -> None:
planner = _AgentRuntimeExternalPlanner(
query="开放行业研究",
tool_input={
"search_queries": [f"query {index}" for index in range(8)],
"result_limit": 10,
},
)
plan = await planner.build_plan(question="开放行业研究")
assert len(plan.search_queries) == 8
assert plan.result_limit == 10
@pytest.mark.asyncio
async def test_agent_answer_runtime_revises_bad_draft_before_terminal_fallback(monkeypatch: pytest.MonkeyPatch) -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI会议行业怎么样")
workspace.boundary_ledger = [
PMFlowAIAgentBoundary(
boundary_type="source_gap",
blocked_claim="缺少会议行业垂直公开来源。",
customer_safe_reason="这轮查到的材料偏向泛 AI 行业,不能直接说明 AI 会议行业最近的发展。",
allowed_alternative="继续换用会议技术、活动平台和标准组织来源补证据。",
)
]
generator = _SequenceGenerator(
[
"Refine search using terms like 'AI conference platform update' or 'hybrid meeting AI feature launch'.",
"目前还不能可靠判断最近 AI 会议行业怎么样。已查到的材料偏向泛 AI 行业,不能直接说明会议与活动技术场景的最近变化;下一步需要换用会议技术、活动平台和标准组织来源补证据。",
]
)
monkeypatch.setattr(PMFlowAIAgentAnswerRuntime, "_render_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_ANSWER_COMPOSER"))
monkeypatch.setattr(PMFlowAIAgentAnswerRuntime, "_render_reviser_prompt", _fake_agent_prompt_renderer("PROMPT_PMFLOW_AI_AGENT_ANSWER_REVISER"))
result = await PMFlowAIAgentAnswerRuntime(db=SimpleNamespace(execute=lambda *_args, **_kwargs: None), tenant_id=12, generator=generator).run(
question="最近AI会议行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(action="stop_blocked", rationale="停止"),
observation=None,
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
retry_would_change_state=False,
reason="claim_ledger 为空,需要换会议行业垂直来源。",
),
)
assert generator.calls == 2
assert result.answer_runtime_packet.draft.source == "llm_reviser"
assert result.quality_findings == []
assert "目前还不能可靠判断最近 AI 会议行业怎么样" in result.answer
assert "Refine search" not in result.answer
def test_agent_deliverable_actions_do_not_expose_planner_rationale() -> None:
workspace = PMFlowAIAgentWorkspace(current_goal="最近AI行业怎么样")
workspace.claim_ledger = [
PMFlowAIAgentClaim(
claim_type="market_trend",
claim_text="公开材料显示市场投入仍在增加。",
support_refs=["公开来源"],
confidence="medium",
)
]
terminal_state = build_agent_terminal_answer_state(
question="最近AI行业怎么样",
workspace=workspace,
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="external_search",
rationale="User goal requires recent, authoritative, external public information on AI industry status.",
),
observation=None,
critic_result=PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=False,
retry_would_change_state=False,
reason="已有部分 claim。",
),
)
result_text = render_agent_terminal_state_fallback(terminal_state)
assert terminal_state.terminal_status == "partially_answerable"
assert "User goal requires" not in result_text
assert "external_search" not in result_text
assert all("User goal requires" not in item for item in terminal_state.next_possible_actions)
@pytest.mark.asyncio
async def test_agent_runtime_v2_stops_repeated_tool_without_state_delta() -> None:
payload = await PMFlowAIAgentRunService(
object(),
tenant_id=12,
planner=_SequencePlanner(["inspect_space_collections", "inspect_space_collections"]),
tool_registry=_FakeToolRegistry(),
critic=_SequenceCritic(
[
PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
state_delta={},
reason="还要继续。",
),
PMFlowAIAgentCriticResult(
ready_to_answer=False,
should_continue=True,
state_delta={},
reason="不应该执行到第二次 critic。",
),
]
),
answer_runtime=PMFlowAIAgentAnswerRuntime(db=object(), tenant_id=12, generator=object()),
).run_turn(
request=RAGAskRequest(question="现在有什么项目"),
current_user=SimpleNamespace(id=7, tenant_id=12),
conversation_context={},
tenant_project_context={"surface_context": {"visible_objects": [{"object_ref": "project:141"}]}},
started_at=None,
)
assert payload["agent_run_packet"]["stop_reason"] == "repeated_action_without_state_delta"
assert payload["deliverable_packet"]["deliverable_kind"] == "blocked_state"
assert payload["deliverable_packet"]["status"] == "blocked"
assert payload["agent_workspace"]["deliverable_state"]["deliverable_kind"] == payload["deliverable_packet"]["deliverable_kind"]
assert payload["agent_run_packet"]["iterations"] == 2
assert payload["agent_run_packet"]["tool_call_count"] == 1
assert payload["agent_run_packet"]["event_log"][-2]["stop_reason"] == "repeated_action_without_state_delta"
assert [step["key"] for step in payload["answer_process_packet"]["steps"]].count("agent_tool_inspect_space_collections") == 1
@pytest.mark.asyncio
async def test_agent_runtime_project_status_without_anchor_returns_world_state_scope_gap() -> None:
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="inspect_project_status",
tool_input={"query": "list_all"},
),
request=RAGAskRequest(question="现在有什么项目"),
workspace=workspace_from_conversation_context(question="现在有什么项目", conversation_context={}),
current_user=SimpleNamespace(id=38, tenant_id=12),
conversation_context={},
tenant_project_context={
"surface_context": {
"workbench": {
"recent_projects": [
{
"id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
"status": "active",
}
]
}
}
},
)
assert observation.observation_type == "project_world_state_missing_scope"
assert "项目数量" in observation.summary or "不能读取项目世界状态" in observation.summary
assert any(fact.get("type") == "project_item" and fact.get("project_ref") == "project:141" for fact in observation.facts)
assert not any(fact.get("type") == "project_world_state" for fact in observation.facts)
@pytest.mark.asyncio
async def test_agent_runtime_project_status_consumes_planner_project_ref() -> None:
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="inspect_project_status",
tool_input={"query": "project:141"},
),
request=RAGAskRequest(question="儿童的那个项目你知道吗"),
workspace=workspace_from_conversation_context(question="儿童的那个项目你知道吗", conversation_context={}),
current_user=SimpleNamespace(id=38, tenant_id=12),
conversation_context={},
tenant_project_context={
"surface_context": {
"workbench": {
"recent_projects": [
{
"id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
"status": "active",
}
]
}
}
},
)
assert observation.observation_type == "project_world_state"
assert "童析—学龄前儿童行为特质AI评估助手" in observation.summary
assert any(fact.get("type") == "project_anchor" and fact.get("project_ref") == "project:141" for fact in observation.facts)
assert any(fact.get("type") == "project_world_state" and fact.get("project_refs") == ["project:141"] for fact in observation.facts)
assert any(fact.get("type") == "project_world_facet" for fact in observation.facts)
assert any(fact.get("type") == "agent_tool_contract" and fact.get("tool_name") == "inspect_project_operating_state" for fact in observation.facts)
@pytest.mark.asyncio
async def test_agent_runtime_project_status_does_not_use_visible_collection_as_operating_state() -> None:
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="inspect_project_status",
tool_input={},
),
request=RAGAskRequest(question="现在有哪些项目"),
workspace=workspace_from_conversation_context(question="现在有哪些项目", conversation_context={}),
current_user=SimpleNamespace(id=38, tenant_id=12),
conversation_context={},
tenant_project_context={
"surface_context": {
"workbench": {
"recent_projects": [
{
"id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
"status": "active",
}
]
}
}
},
)
assert observation.observation_type == "project_world_state_missing_scope"
assert any(fact.get("type") == "project_world_state_gap" for fact in observation.facts)
assert not any(fact.get("type") == "project_world_state" for fact in observation.facts)
@pytest.mark.asyncio
async def test_agent_tool_environment_resolves_explicit_customer_object() -> None:
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="resolve_customer_object",
tool_input={"object_ref": "project:141"},
),
request=RAGAskRequest(question="儿童的那个项目你知道吗"),
workspace=workspace_from_conversation_context(question="儿童的那个项目你知道吗", conversation_context={}),
current_user=SimpleNamespace(id=38, tenant_id=12),
conversation_context={},
tenant_project_context={
"surface_context": {
"workbench": {
"recent_projects": [
{
"id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
"status": "active",
}
]
}
}
},
)
assert observation.observation_type == "object_resolution"
assert any(fact.get("type") == "resolved_object" and fact.get("object_ref") == "project:141" for fact in observation.facts)
@pytest.mark.asyncio
async def test_agent_tool_environment_returns_candidates_without_auto_binding() -> None:
observation = await PMFlowAIAgentToolRegistry(db=object(), tenant_id=12).execute(
proposal=PMFlowAIAgentStepProposal(
action="tool",
tool_name="resolve_customer_object",
tool_input={},
),
request=RAGAskRequest(question="它现在怎么样"),
workspace=workspace_from_conversation_context(question="它现在怎么样", conversation_context={}),
current_user=SimpleNamespace(id=38, tenant_id=12),
conversation_context={},
tenant_project_context={
"surface_context": {
"workbench": {
"recent_projects": [
{
"id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
"status": "active",
}
]
}
}
},
)
assert observation.observation_type == "object_candidates"
assert "尚未自动绑定单一对象" in observation.summary
assert any(fact.get("type") == "object_candidate" and fact.get("binding_strength") == "candidate" for fact in observation.facts)
def test_agent_runtime_step4_restores_workspace_from_capsule_and_updates_current_goal() -> None:
first = workspace_from_conversation_context(
question="先分析 AI 采购监督系统",
conversation_context={},
)
first.known_facts = [{"type": "external_evidence", "title": "公开材料"}]
capsules = upsert_agent_workspace_capsule(capsules=[], workspace=first)
restored = agent_workspace_from_context_capsules(capsules)
assert restored["workspace_id"] == first.workspace_id
assert restored["known_facts"][0]["title"] == "公开材料"
second = workspace_from_conversation_context(
question="继续看落地风险",
conversation_context={"context_capsules": capsules},
)
assert second.workspace_id == first.workspace_id
assert second.current_goal == "继续看落地风险"
assert "先分析 AI 采购监督系统" in second.goal_history
assert second.fact_ledger[0].fact_text == "公开材料"
next_capsules = upsert_agent_workspace_capsule(capsules=capsules, workspace=second)
next_workspace_capsule = agent_workspace_from_context_capsules(next_capsules)
assert next_workspace_capsule["fact_ledger"][0]["fact_text"] == "公开材料"
class _FakePlanner:
def __init__(self, tool_name: str) -> None:
self.tool_name = tool_name
async def plan(self, **kwargs):
return PMFlowAIAgentStepProposal(
action="tool",
tool_name=self.tool_name,
tool_input={"query": kwargs.get("question", ""), "freshness_required": True},
rationale="读取下一步所需证据。",
expected_observation="客户安全观察。",
confidence="medium",
)
class _SequencePlanner:
def __init__(self, tool_names: list[str]) -> None:
self.tool_names = list(tool_names)
self.index = 0
async def plan(self, **kwargs):
tool_name = self.tool_names[min(self.index, len(self.tool_names) - 1)]
self.index += 1
return PMFlowAIAgentStepProposal(
action="tool",
tool_name=tool_name,
tool_input={"query": kwargs.get("question", ""), "freshness_required": True},
rationale=f"执行 {tool_name} 读取证据。",
expected_observation="客户安全观察。",
confidence="medium",
)
class _FakeToolRegistry:
async def execute(self, *, proposal, **kwargs):
if proposal.tool_name == "external_search":
return PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源,得到 1 条可用证据。",
facts=[{"type": "external_evidence", "title": "公开资料"}],
sources=[{"label": "公开来源", "url": "https://example.com/source"}],
confidence="high",
relevance_to_goal="high",
)
if proposal.tool_name == "inspect_source_detail":
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "公开来源",
"url": "https://example.com/source",
"text": "公开来源正文:该方向已有公开材料支撑,市场需求和应用探索正在增加。",
}
],
sources=[{"label": "公开来源", "url": "https://example.com/source"}],
confidence="medium",
relevance_to_goal="high",
)
return PMFlowAIAgentObservation(
source="inspect_space_collections",
observation_type="space_context",
summary="已读取客户安全的空间上下文摘要。",
facts=[{"type": "surface_collection_count", "collection": "projects", "count": 1}],
confidence="medium",
relevance_to_goal="medium",
)
class _NoisyExternalToolRegistry:
async def execute(self, *, proposal, **kwargs):
if proposal.tool_name == "external_search":
return PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源,得到 1 条候选材料。",
facts=[
{
"type": "external_evidence",
"title": "行业报告页面",
"summary": (
"行业报告页面 联系我们 订阅邮箱 020-8922-4700 news@iimedia.cn "
"&emsp;&emsp;正文片段 导航 市场地位 商业趋势 网站投稿"
),
"url": "https://example.com/noisy",
}
],
sources=[{"label": "行业报告页面", "url": "https://example.com/noisy"}],
confidence="high",
relevance_to_goal="high",
)
if proposal.tool_name == "inspect_source_detail":
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="已读取公开来源正文。",
facts=[
{
"type": "external_source_text",
"title": "行业报告页面",
"url": "https://example.com/noisy",
"text": (
"联系我们 订阅邮箱 020-8922-4700 news@iimedia.cn &emsp;&emsp; "
"网站投稿 媒体合作 导航 首页 报告定制 行业白皮书"
),
}
],
sources=[{"label": "行业报告页面", "url": "https://example.com/noisy"}],
confidence="medium",
relevance_to_goal="medium",
)
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="source_detail_missing_url",
summary="外部来源读取缺少可执行 URL,本轮未读取网页正文。",
facts=[{"type": "missing_external_source_url", "user_actionable": False}],
confidence="medium",
relevance_to_goal="medium",
)
class _RecoveryExternalToolRegistry:
def __init__(self) -> None:
self.inspect_calls: list[list[str]] = []
async def execute(self, *, proposal, **kwargs):
if proposal.tool_name == "external_search":
return PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源,得到 3 条候选材料。",
facts=[
{
"type": "external_evidence",
"title": "AI market report",
"summary": "Broad artificial intelligence market size report.",
"url": "https://example.com/broad-ai-market",
},
{
"type": "external_evidence",
"title": "AI conference attendance and sponsorship 2026",
"summary": "Conference attendance, sponsorship and event market signals.",
"url": "https://example.com/ai-conference-attendance-2026",
},
{
"type": "external_evidence",
"title": "AI event technology alternatives",
"summary": "Event tech, community events and conference software alternatives.",
"url": "https://example.com/ai-event-technology",
},
],
sources=[
{"label": "AI market report", "url": "https://example.com/broad-ai-market"},
{"label": "AI conference attendance and sponsorship 2026", "url": "https://example.com/ai-conference-attendance-2026"},
{"label": "AI event technology alternatives", "url": "https://example.com/ai-event-technology"},
],
confidence="high",
relevance_to_goal="high",
)
if proposal.tool_name == "inspect_source_detail":
urls = [str(item) for item in list(proposal.tool_input.get("urls") or [])]
self.inspect_calls.append(urls)
if len(self.inspect_calls) == 1:
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="source_detail_failed",
summary="第一个来源不可读。",
facts=[
{
"type": "external_source_unreadable",
"url": urls[0] if urls else "https://example.com/broad-ai-market",
"summary": "403 Forbidden",
}
],
confidence="unknown",
relevance_to_goal="medium",
)
url = urls[0] if urls else "https://example.com/ai-conference-attendance-2026"
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="补读了未尝试来源正文。",
facts=[
{
"type": "external_source_text",
"title": "AI conference attendance and sponsorship 2026",
"url": url,
"text": "AI conferences are showing stronger attendance, sponsorship demand and event tooling needs.",
}
],
sources=[{"label": "AI conference attendance and sponsorship 2026", "url": url}],
confidence="medium",
relevance_to_goal="high",
)
return PMFlowAIAgentObservation(
source="inspect_space_collections",
observation_type="space_context",
summary="已读取空间上下文。",
facts=[],
)
class _CandidateClaimRecoveryToolRegistry:
async def execute(self, *, proposal, **kwargs):
if proposal.tool_name == "external_search":
return PMFlowAIAgentObservation(
source="external_search",
observation_type="external_public_evidence",
summary="已查询外部公开来源,得到候选材料。",
facts=[
{
"type": "external_evidence",
"title": "AI market report",
"summary": "Broad artificial intelligence market size report.",
"url": "https://example.com/broad-ai-market",
}
],
sources=[{"label": "AI market report", "url": "https://example.com/broad-ai-market"}],
confidence="high",
relevance_to_goal="medium",
)
if proposal.tool_name == "inspect_source_detail":
return PMFlowAIAgentObservation(
source="inspect_source_detail",
observation_type="external_source_document",
summary="读取到候选正文,但仍没有可入账事实。",
facts=[
{
"type": "external_source_text",
"title": "AI market report",
"url": "https://example.com/broad-ai-market",
"text": "Broad artificial intelligence market size report.",
}
],
sources=[{"label": "AI market report", "url": "https://example.com/broad-ai-market"}],
confidence="medium",
relevance_to_goal="medium",
)
return PMFlowAIAgentObservation(
source="inspect_space_collections",
observation_type="space_context",
summary="已读取空间上下文。",
facts=[],
)
class _FakeCritic:
def __init__(self, *, ready: bool) -> None:
self.ready = ready
async def review(self, **kwargs):
return PMFlowAIAgentCriticResult(
ready_to_answer=self.ready,
should_continue=not self.ready,
state_delta={"test": True},
findings=["状态已检查。"],
next_step_hint="compose_answer" if self.ready else "select_next_read_tool",
reason="状态检查完成。",
)
class _SequenceCritic:
def __init__(self, results: list[PMFlowAIAgentCriticResult]) -> None:
self.results = list(results)
self.index = 0
async def review(self, **kwargs):
result = self.results[min(self.index, len(self.results) - 1)]
self.index += 1
return result
class _SequenceGenerator:
def __init__(self, responses: list[str]) -> None:
self.responses = list(responses)
self.calls = 0
async def generate(self, *args, **kwargs) -> str:
index = min(self.calls, len(self.responses) - 1)
self.calls += 1
return self.responses[index]
class _CandidateOnlyNormalizer:
async def normalize(self, **kwargs):
observation = kwargs.get("observation")
return PMFlowAIAgentEvidenceNormalization(
source_observation_id=getattr(observation, "observation_id", None),
source_quality="noisy",
usable_claims=[
PMFlowAIAgentClaim(
claim_type="external_source_candidate",
claim_kind="source_candidate",
claim_text="候选外部材料可继续读取,但还不能作为业务事实陈述。",
allowed_to_state=True,
)
],
gaps=["needs_conference_specific_sources"],
rejected_reason="source_candidate_without_substantive_claim",
)
def _fake_agent_prompt_renderer(prompt_key: str):
async def _render(self, **kwargs):
return PromptRuntimeRenderResult(
prompt=f"{prompt_key} test prompt",
prompt_key=prompt_key,
source="unit_test",
runtime_tenant_id=12,
)
return _render
class _FakeProjectWorldStateService:
def __init__(self, *, blocked_actions: list[dict[str, object]] | None = None) -> None:
self.blocked_actions = list(blocked_actions or [])
async def build_packet(self, **kwargs):
return PMFlowAIProjectWorldStatePacket(
primary_project_ref="project:141",
project_refs=["project:141"],
status="ready",
facets=[
PMFlowAIProjectWorldFacet(
facet_key="project_identity",
display_label="项目身份与目标",
status="ready",
summary="项目身份与目标 已读取:project_title: 童析;loop_stage: command_idle",
facts=[{"type": "world_state_fact", "key": "project_title", "value": "童析"}],
evidence_refs=["project:141:project_identity"],
)
],
project_operating_evidence_packets=[
{
"packet_version": "project_operating_evidence.v1",
"project_ref": "project:141",
"project_title": "童析",
"safe_next_actions": [
{
"display_label": "先处理当前任务阻塞",
"target_ref": "project:141",
}
],
"blocked_actions": self.blocked_actions,
}
],
evidence_ledger=[
PMFlowAIEvidenceLedgerEntry(
evidence_id="evd_test",
project_ref="project:141",
facet_key="project_identity",
source_ref="project:141:project_identity",
summary="项目身份与目标 已读取。",
confidence="high",
)
],
coverage={"coverage_score": 1.0, "covered_facets": ["project_identity"], "partial_facets": [], "missing_facets": []},
)
class _FakeOpenResearchProjectWorldStateService:
async def build_packet(self, **kwargs):
return PMFlowAIProjectWorldStatePacket(
primary_project_ref="",
project_refs=[],
status="partial",
facets=[],
evidence_ledger=[
PMFlowAIEvidenceLedgerEntry(
evidence_id="evd_open_research_seed",
project_ref="space:research",
facet_key="research_scope",
source_ref="user_question",
summary="用户提出开放市场研究问题。",
confidence="medium",
)
],
coverage={
"coverage_score": 0.1,
"covered_facets": [],
"partial_facets": [],
"missing_facets": ["external_research_evidence"],
},
remaining_gaps=["external_public_sources_need_agent_reading"],
)
class _FakeDB:
def __init__(self) -> None:
self.items = []
self._next_id = 1
def add(self, item) -> None:
self.items.append(item)
async def flush(self) -> None:
for item in self.items:
if getattr(item, "id", None) is None:
item.id = self._next_id
self._next_id += 1
class _RecordingAgentOSToolRegistry:
def __init__(self) -> None:
self.calls: list[dict[str, object]] = []
async def execute(self, *, proposal, **kwargs):
self.calls.append({"tool_name": proposal.tool_name, "tool_input": dict(proposal.tool_input or {})})
if proposal.tool_name == "inspect_project_operating_state":
return PMFlowAIAgentObservation(
source="inspect_project_operating_state",
observation_type="project_world_state",
summary="已读取项目世界状态。",
facts=[{"type": "project_world_state", "project_refs": ["project:141"], "status": "ready"}],
confidence="high",
relevance_to_goal="high",
)
return PMFlowAIAgentObservation(
source="inspect_space_collections",
observation_type="space_context",
summary="已读取当前空间集合。",
facts=[{"type": "surface_collection_count", "collection": "projects", "count": 1}],
confidence="medium",
relevance_to_goal="medium",
)
class _FakeDeliveryRuntime:
async def run_turn(self, **kwargs):
return {
"answer": "项目当前需要先收口核心验证,再准备可确认的下一步。",
"reply_text": "项目当前需要先收口核心验证,再准备可确认的下一步。",
"processing_stage": "agent_runtime",
"answer_mode": "agent_runtime",
"grounding_status": "grounded",
"answer_canvas": {
"action_candidates": [
{
"title": "准备本周项目推进计划",
"summary": "进入项目工作台准备本周推进计划。",
"href": "/pmflow-os?projectId=141",
"action_type": "suggest",
"requires_user_confirm": False,
}
],
"next_actions": [],
},
"deliverable_packet": {
"summary": "项目当前需要先收口核心验证,再准备可确认的下一步。",
"blocked_reasons": ["真实写入项目记忆前需要用户确认。"],
},
"terminal_answer_state": {
"claims": [
{
"claim_text": "项目当前需要先收口核心验证。",
"confidence": "medium",
}
]
},
"customer_interaction_context_packet": {
"current_focus_object": {
"object_ref": "project:141",
"object_type": "project",
"object_id": 141,
"title": "童析—学龄前儿童行为特质AI评估助手",
}
},
"agent_run_packet": {
"run_id": "arun_test",
"observations": [{"observation_id": "obs_test"}],
},
"answer_process_packet": {
"packet_version": "customer_answer_process.v1",
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
"total_duration_ms": 10,
},
}
class _FakeOpenResearchDeliveryRuntime:
async def run_turn(self, **kwargs):
answer = "这轮先进入研究补证,不能把候选来源包装成市场结论。"
return {
"answer": answer,
"reply_text": answer,
"processing_stage": "agent_runtime",
"answer_mode": "agent_runtime",
"grounding_status": "partial",
"agent_os_observations": [
{
"observation_id": "obs_external_search_1",
"source_kind": "external_search",
"facts": [
{
"type": "external_evidence",
"title": "AI events market candidate",
"url": "https://example.com/ai-events",
"summary": "公开来源候选显示 AI 会议活动和赞助需求需要继续交叉验证。",
},
{
"type": "external_evidence",
"title": "AI event tools candidate",
"url": "https://example.com/ai-event-tools",
"summary": "公开来源候选显示 AI 会务工具和活动运营工具正在出现。",
},
],
}
],
"agent_workspace": {
"claim_ledger": [
{
"claim_text": "AI 会议市场判断需要同时覆盖活动需求、会务工具和商业化信号。",
"confidence": "medium",
}
],
"gap_ledger": [
{
"summary": "还缺少近期参会规模、赞助收入和工具竞品的交叉来源。",
}
],
},
"deliverable_packet": {
"deliverable_kind": "research_note",
"summary": answer,
"sections": [
{
"section_kind": "gaps",
"items": [{"summary": "需要继续补三类来源后再形成机会结论。"}],
}
],
},
"answer_process_packet": {
"packet_version": "customer_answer_process.v1",
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
"total_duration_ms": 10,
},
}
class _FakeNestedResearchDeliveryRuntime:
async def run_turn(self, **kwargs):
answer = "内部 Agent OS 已读取公开来源,但还没形成足够市场结论。"
observations = [
{
"observation_id": "obs_nested_search_1",
"source": "external_search",
"observation_type": "external_public_evidence",
"facts": [
{
"type": "external_evidence",
"title": "AI conference attendance candidate",
"url": "https://example.com/ai-conference-attendance",
"summary": "AI conference attendance and sponsor demand candidate.",
},
{
"type": "external_evidence",
"title": "AI event tools candidate",
"url": "https://example.com/ai-event-tools",
"summary": "AI event technology and meeting software candidate.",
},
],
}
]
return {
"answer": answer,
"reply_text": answer,
"processing_stage": "agent_runtime",
"answer_mode": "agent_runtime",
"grounding_status": "partial",
"answer_canvas": {"direct_answer": "旧 inner answer 不应该覆盖 operating answer。"},
"agent_run_packet": {
"run_id": "arun_nested",
"observations": observations,
},
"agent_workspace": {
"claim_ledger": [
{
"claim_text": "AI 会议市场判断需要先分开活动需求、会务工具和商业化信号。",
"confidence": "medium",
}
],
"gap_ledger": [{"summary": "还缺少赞助、参会规模和工具竞品的交叉来源。"}],
},
"deliverable_packet": {
"deliverable_kind": "research_note",
"sections": [{"section_kind": "gaps", "items": [{"summary": "需要继续补三类来源。"}]}],
},
"answer_process_packet": {
"packet_version": "customer_answer_process.v1",
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
"total_duration_ms": 10,
},
}
class _RecordingEventSinkDeliveryRuntime(_FakeDeliveryRuntime):
def __init__(self) -> None:
self.saw_event_sink = False
async def run_turn(self, **kwargs):
event_sink = kwargs.get("event_sink")
self.saw_event_sink = callable(event_sink)
if callable(event_sink):
result = event_sink({"type": "step.completed", "step": {"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}})
if inspect.isawaitable(result):
await result
return await super().run_turn(**kwargs)
class _FakeDirectDeliveryRuntime:
async def run_turn(self, **kwargs):
answer = "我是 PMFlow AI,可以帮你查看空间、项目和下一步,但不会替你直接写入或执行。"
return {
"answer": answer,
"reply_text": answer,
"processing_stage": "agent_runtime",
"answer_mode": "agent_runtime",
"grounding_status": "grounded",
"answer_canvas": {"direct_answer": answer, "next_actions": []},
"deliverable_packet": {
"deliverable_kind": "direct_answer",
"summary": answer,
"next_actions": [],
},
"customer_evidence_packet": {"answer_type": "direct_answer"},
"terminal_answer_state": {
"terminal_status": "answerable",
"claims": [{"claim_text": answer, "confidence": "medium"}],
},
"agent_run_packet": {
"run_id": "arun_direct",
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
"observations": [],
},
"answer_process_packet": {
"packet_version": "customer_answer_process.v1",
"steps": [{"key": "agent_answer_runtime", "label": "生成回答", "status": "completed"}],
"total_duration_ms": 10,
},
}