feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -109,7 +109,6 @@ async def test_runtime_emits_started_text_and_finished_events() -> None:
|
||||
"step.start",
|
||||
"step.finish",
|
||||
"step.start",
|
||||
"step.finish",
|
||||
"text.start",
|
||||
"text.delta",
|
||||
"text.end",
|
||||
@@ -117,6 +116,7 @@ async def test_runtime_emits_started_text_and_finished_events() -> None:
|
||||
"text.delta",
|
||||
"text.end",
|
||||
"tool.result",
|
||||
"step.finish",
|
||||
"step.start",
|
||||
"text.start",
|
||||
"text.delta",
|
||||
@@ -127,10 +127,14 @@ async def test_runtime_emits_started_text_and_finished_events() -> None:
|
||||
assert calls[1]["data"]["stepName"] == "intent"
|
||||
assert calls[2]["data"]["stepName"] == "intent"
|
||||
assert calls[3]["data"]["stepName"] == "execution"
|
||||
assert calls[4]["data"]["stepName"] == "execution"
|
||||
assert calls[5]["data"]["stage"] == "intent"
|
||||
assert calls[8]["data"]["stage"] == "execution"
|
||||
assert calls[11]["data"]["toolName"] == "calendar_write"
|
||||
assert calls[4]["data"]["stage"] == "intent"
|
||||
assert calls[7]["data"]["stage"] == "execution"
|
||||
assert calls[10]["data"]["toolName"] == "calendar_write"
|
||||
assert calls[10]["data"]["toolCallId"] == "run-1-t1-1"
|
||||
assert calls[10]["data"]["messageId"] == "tool-result-run-1-t1-1"
|
||||
tool_content = calls[10]["data"]["content"]
|
||||
assert tool_content == "calendar_write 执行完成"
|
||||
assert calls[11]["data"]["stepName"] == "execution"
|
||||
assert calls[12]["data"]["stepName"] == "report"
|
||||
assert calls[14]["data"]["delta"] == "hello world"
|
||||
assert calls[13]["data"]["messageId"] == calls[14]["data"]["messageId"]
|
||||
@@ -305,3 +309,300 @@ async def test_runtime_direct_response_finishes_without_report_stage() -> None:
|
||||
]
|
||||
assert calls[3]["data"]["stage"] == "intent"
|
||||
assert calls[4]["data"]["delta"] == "direct-answer"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_tool_result_parses_json_string_ui_payload() -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakePipeline:
|
||||
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
|
||||
assert session_id == "thread-1"
|
||||
calls.append(event)
|
||||
return f"{len(calls)}-0"
|
||||
|
||||
class _FakeOrchestrator:
|
||||
async def run(self, **_: object) -> RuntimeOutput:
|
||||
return RuntimeOutput(
|
||||
intent=IntentOutput(
|
||||
route="TASK_EXECUTION",
|
||||
intent_summary="summary",
|
||||
direct_response=None,
|
||||
tasks=[IntentTask(task_id="t1", title="exec", objective="do")],
|
||||
complexity="complex",
|
||||
response_metadata={},
|
||||
),
|
||||
execution=ExecutionBatchOutput(
|
||||
task_results=[
|
||||
ExecutionTaskOutput(
|
||||
task_id="t1",
|
||||
status="SUCCESS",
|
||||
execution_summary="execution-ok",
|
||||
execution_data={},
|
||||
user_feedback_needs=[],
|
||||
response_metadata={},
|
||||
tool_calls=[
|
||||
ExecutionToolCall(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "A"},
|
||||
result='{"type":"calendar_card.v1","version":"v1","data":{"ok":true,"title":"A"},"actions":[]}',
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
overall_status="SUCCESS",
|
||||
aggregate_summary="ok",
|
||||
),
|
||||
report=ReportOutput(
|
||||
assistant_text="hello world",
|
||||
response_metadata={},
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRouteRuntime(
|
||||
orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline()
|
||||
)
|
||||
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
|
||||
|
||||
await runtime.run(
|
||||
command=command,
|
||||
owner_id=uuid4(),
|
||||
user_token="token",
|
||||
user_context=_user_context(),
|
||||
session=cast(AsyncSession, object()),
|
||||
)
|
||||
|
||||
tool_events = [item for item in calls if item.get("type") == "tool.result"]
|
||||
assert len(tool_events) == 1
|
||||
data = tool_events[0]["data"]
|
||||
assert isinstance(data, dict)
|
||||
assert isinstance(data.get("ui"), dict)
|
||||
assert data["ui"]["type"] == "calendar_card.v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_tool_result_keeps_plain_text_content() -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakePipeline:
|
||||
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
|
||||
assert session_id == "thread-1"
|
||||
calls.append(event)
|
||||
return f"{len(calls)}-0"
|
||||
|
||||
class _FakeOrchestrator:
|
||||
async def run(self, **_: object) -> RuntimeOutput:
|
||||
return RuntimeOutput(
|
||||
intent=IntentOutput(
|
||||
route="TASK_EXECUTION",
|
||||
intent_summary="summary",
|
||||
direct_response=None,
|
||||
tasks=[IntentTask(task_id="t1", title="exec", objective="do")],
|
||||
complexity="complex",
|
||||
response_metadata={},
|
||||
),
|
||||
execution=ExecutionBatchOutput(
|
||||
task_results=[
|
||||
ExecutionTaskOutput(
|
||||
task_id="t1",
|
||||
status="SUCCESS",
|
||||
execution_summary="execution-ok",
|
||||
execution_data={},
|
||||
user_feedback_needs=[],
|
||||
response_metadata={},
|
||||
tool_calls=[
|
||||
ExecutionToolCall(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "A"},
|
||||
result="created successfully",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
overall_status="SUCCESS",
|
||||
aggregate_summary="ok",
|
||||
),
|
||||
report=ReportOutput(
|
||||
assistant_text="hello world",
|
||||
response_metadata={},
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRouteRuntime(
|
||||
orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline()
|
||||
)
|
||||
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
|
||||
|
||||
await runtime.run(
|
||||
command=command,
|
||||
owner_id=uuid4(),
|
||||
user_token="token",
|
||||
user_context=_user_context(),
|
||||
session=cast(AsyncSession, object()),
|
||||
)
|
||||
|
||||
tool_events = [item for item in calls if item.get("type") == "tool.result"]
|
||||
assert len(tool_events) == 1
|
||||
data = tool_events[0]["data"]
|
||||
assert isinstance(data, dict)
|
||||
assert data["content"] == "created successfully"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_tool_result_sanitizes_sensitive_payload() -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakePipeline:
|
||||
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
|
||||
assert session_id == "thread-1"
|
||||
calls.append(event)
|
||||
return f"{len(calls)}-0"
|
||||
|
||||
class _FakeOrchestrator:
|
||||
async def run(self, **_: object) -> RuntimeOutput:
|
||||
return RuntimeOutput(
|
||||
intent=IntentOutput(
|
||||
route="TASK_EXECUTION",
|
||||
intent_summary="summary",
|
||||
direct_response=None,
|
||||
tasks=[IntentTask(task_id="t1", title="exec", objective="do")],
|
||||
complexity="complex",
|
||||
response_metadata={},
|
||||
),
|
||||
execution=ExecutionBatchOutput(
|
||||
task_results=[
|
||||
ExecutionTaskOutput(
|
||||
task_id="t1",
|
||||
status="SUCCESS",
|
||||
execution_summary="execution-ok",
|
||||
execution_data={},
|
||||
user_feedback_needs=[],
|
||||
response_metadata={},
|
||||
tool_calls=[
|
||||
ExecutionToolCall(
|
||||
tool_name="calendar_write",
|
||||
args={
|
||||
"title": "A",
|
||||
"accessToken": "arg-secret",
|
||||
"author": "alice",
|
||||
},
|
||||
result={
|
||||
"ok": True,
|
||||
"accessToken": "secret-token",
|
||||
"message": "Authorization: Bearer inline-token",
|
||||
"nested": [
|
||||
{
|
||||
"authorizationHeader": "Bearer abc",
|
||||
}
|
||||
],
|
||||
},
|
||||
error="failed authorization=Bearer abc123 detail",
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
overall_status="SUCCESS",
|
||||
aggregate_summary="ok",
|
||||
),
|
||||
report=ReportOutput(
|
||||
assistant_text="hello world",
|
||||
response_metadata={},
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRouteRuntime(
|
||||
orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline()
|
||||
)
|
||||
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
|
||||
|
||||
await runtime.run(
|
||||
command=command,
|
||||
owner_id=uuid4(),
|
||||
user_token="token",
|
||||
user_context=_user_context(),
|
||||
session=cast(AsyncSession, object()),
|
||||
)
|
||||
|
||||
tool_events = [item for item in calls if item.get("type") == "tool.result"]
|
||||
assert len(tool_events) == 1
|
||||
data = tool_events[0]["data"]
|
||||
assert isinstance(data, dict)
|
||||
assert isinstance(data["result"], dict)
|
||||
assert data["result"]["accessToken"] == "[REDACTED]"
|
||||
assert data["result"]["message"] == "Authorization=[REDACTED]"
|
||||
nested = data["result"]["nested"]
|
||||
assert isinstance(nested, list)
|
||||
assert nested[0]["authorizationHeader"] == "[REDACTED]"
|
||||
assert isinstance(data["args"], dict)
|
||||
assert data["args"]["accessToken"] == "[REDACTED]"
|
||||
assert data["args"]["author"] == "alice"
|
||||
assert data["error"] == "failed authorization=[REDACTED] detail"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_tool_result_keeps_non_object_result() -> None:
|
||||
calls: list[dict[str, Any]] = []
|
||||
|
||||
class _FakePipeline:
|
||||
async def emit(self, *, session_id: str, event: dict[str, object]) -> str:
|
||||
assert session_id == "thread-1"
|
||||
calls.append(event)
|
||||
return f"{len(calls)}-0"
|
||||
|
||||
class _FakeOrchestrator:
|
||||
async def run(self, **_: object) -> RuntimeOutput:
|
||||
return RuntimeOutput(
|
||||
intent=IntentOutput(
|
||||
route="TASK_EXECUTION",
|
||||
intent_summary="summary",
|
||||
direct_response=None,
|
||||
tasks=[IntentTask(task_id="t1", title="exec", objective="do")],
|
||||
complexity="complex",
|
||||
response_metadata={},
|
||||
),
|
||||
execution=ExecutionBatchOutput(
|
||||
task_results=[
|
||||
ExecutionTaskOutput(
|
||||
task_id="t1",
|
||||
status="SUCCESS",
|
||||
execution_summary="execution-ok",
|
||||
execution_data={},
|
||||
user_feedback_needs=[],
|
||||
response_metadata={},
|
||||
tool_calls=[
|
||||
ExecutionToolCall(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "A"},
|
||||
result=["evt-1", "evt-2"],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
overall_status="SUCCESS",
|
||||
aggregate_summary="ok",
|
||||
),
|
||||
report=ReportOutput(
|
||||
assistant_text="hello world",
|
||||
response_metadata={},
|
||||
),
|
||||
)
|
||||
|
||||
runtime = AgentRouteRuntime(
|
||||
orchestrator=_FakeOrchestrator(), pipeline=_FakePipeline()
|
||||
)
|
||||
command = RunCommand(threadId="thread-1", runId="run-1", messages=[])
|
||||
|
||||
await runtime.run(
|
||||
command=command,
|
||||
owner_id=uuid4(),
|
||||
user_token="token",
|
||||
user_context=_user_context(),
|
||||
session=cast(AsyncSession, object()),
|
||||
)
|
||||
|
||||
tool_events = [item for item in calls if item.get("type") == "tool.result"]
|
||||
assert len(tool_events) == 1
|
||||
data = tool_events[0]["data"]
|
||||
assert isinstance(data, dict)
|
||||
assert isinstance(data["result"], dict)
|
||||
assert data["result"]["value"] == ["evt-1", "evt-2"]
|
||||
|
||||
@@ -212,6 +212,9 @@ def test_merge_stage_response_metadata_estimates_cost_from_pricing(
|
||||
model="qwen3.5-flash",
|
||||
),
|
||||
latency_ms=50,
|
||||
system_prompt="system",
|
||||
user_prompt="user",
|
||||
assistant_text='{"route":"DIRECT_RESPONSE"}',
|
||||
)
|
||||
|
||||
metadata = payload["response_metadata"]
|
||||
|
||||
@@ -50,6 +50,10 @@ async def test_run_agentscope_task_calls_runtime_run(
|
||||
async def _fake_get_redis_client() -> object:
|
||||
return object()
|
||||
|
||||
async def _empty_context(**kwargs: object) -> list[dict[str, Any]]:
|
||||
del kwargs
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(tasks_module, "AgentRouteRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
tasks_module,
|
||||
@@ -60,7 +64,7 @@ async def test_run_agentscope_task_calls_runtime_run(
|
||||
monkeypatch.setattr(
|
||||
tasks_module,
|
||||
"_build_recent_context_messages",
|
||||
lambda **_: [],
|
||||
_empty_context,
|
||||
)
|
||||
|
||||
result = await tasks_module.run_agentscope_task(
|
||||
@@ -101,6 +105,10 @@ async def test_run_agentscope_task_includes_recent_context_messages(
|
||||
async def _fake_get_redis_client() -> object:
|
||||
return object()
|
||||
|
||||
async def _empty_context(**kwargs: object) -> list[dict[str, Any]]:
|
||||
del kwargs
|
||||
return []
|
||||
|
||||
async def _fake_context(**kwargs: object) -> list[dict[str, Any]]:
|
||||
del kwargs
|
||||
return [{"id": "ctx-1", "role": "assistant", "content": "历史上下文"}]
|
||||
@@ -115,7 +123,7 @@ async def test_run_agentscope_task_includes_recent_context_messages(
|
||||
monkeypatch.setattr(
|
||||
tasks_module,
|
||||
"_build_recent_context_messages",
|
||||
lambda **_: [],
|
||||
_empty_context,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
tasks_module,
|
||||
|
||||
Reference in New Issue
Block a user