feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -40,3 +40,34 @@ def test_reserved_keys_in_data_cannot_override_wire_fields() -> None:
|
||||
assert result["threadId"] == "thread-1"
|
||||
assert result["runId"] == "run-1"
|
||||
assert result["message"] == "ok"
|
||||
|
||||
|
||||
def test_tool_result_wire_event_filters_sensitive_fields() -> None:
|
||||
internal = {
|
||||
"type": "tool.result",
|
||||
"threadId": "thread-1",
|
||||
"runId": "run-1",
|
||||
"data": {
|
||||
"messageId": "tool-result-1",
|
||||
"toolCallId": "call-1",
|
||||
"callId": "call-1",
|
||||
"toolName": "calendar_write",
|
||||
"content": "summary",
|
||||
"ui": {"type": "calendar_operation.v1", "data": {"ok": True}},
|
||||
"args": {"token": "secret"},
|
||||
"result": {"raw": "secret"},
|
||||
"error": "stack trace",
|
||||
},
|
||||
}
|
||||
|
||||
result = to_agui_wire_event(internal)
|
||||
|
||||
assert result["type"] == "TOOL_CALL_RESULT"
|
||||
assert result["messageId"] == "tool-result-1"
|
||||
assert result["toolCallId"] == "call-1"
|
||||
assert result["toolName"] == "calendar_write"
|
||||
assert result["content"] == "summary"
|
||||
assert isinstance(result.get("ui"), dict)
|
||||
assert "args" not in result
|
||||
assert "result" not in result
|
||||
assert "error" not in result
|
||||
|
||||
@@ -28,6 +28,27 @@ class _FakeSessionCtx:
|
||||
del exc_type, exc, tb
|
||||
|
||||
|
||||
class _FakeToolResultStorage:
|
||||
def __init__(self) -> None:
|
||||
self.upload_calls: list[dict[str, object]] = []
|
||||
|
||||
async def upload_json(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
path: str,
|
||||
payload: dict[str, object],
|
||||
) -> str:
|
||||
self.upload_calls.append(
|
||||
{
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"payload": payload,
|
||||
}
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_marks_session_running_on_run_started(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -300,7 +321,12 @@ async def test_store_persists_tool_call_result_as_tool_message(
|
||||
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
|
||||
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
|
||||
|
||||
store = store_module.SqlAlchemyEventStore(session_factory=lambda: _FakeSessionCtx())
|
||||
fake_storage = _FakeToolResultStorage()
|
||||
store = store_module.SqlAlchemyEventStore(
|
||||
session_factory=lambda: _FakeSessionCtx(),
|
||||
tool_result_storage=fake_storage,
|
||||
tool_result_bucket="agent-tool-results",
|
||||
)
|
||||
await store.persist(
|
||||
{
|
||||
"type": "TOOL_CALL_RESULT",
|
||||
@@ -310,7 +336,7 @@ async def test_store_persists_tool_call_result_as_tool_message(
|
||||
"taskId": "t1",
|
||||
"stage": "execution",
|
||||
"args": {"title": "A"},
|
||||
"result": {"event_id": "evt-1"},
|
||||
"result": {"event_id": "evt-1", "token": "secret"},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -318,9 +344,94 @@ async def test_store_persists_tool_call_result_as_tool_message(
|
||||
assert getattr(append_kwargs["role"], "value", None) == "tool"
|
||||
assert append_kwargs["tool_name"] == "calendar_write"
|
||||
assert append_kwargs["metadata"]["task_id"] == "t1"
|
||||
tool_call_id = append_kwargs["metadata"]["tool_call_id"]
|
||||
assert isinstance(tool_call_id, str)
|
||||
assert tool_call_id.startswith("run-1-t1-")
|
||||
assert append_kwargs["metadata"]["storage_bucket"] == "agent-tool-results"
|
||||
assert isinstance(append_kwargs["metadata"]["storage_path"], str)
|
||||
assert append_kwargs["content"].startswith("已创建日程")
|
||||
assert len(fake_storage.upload_calls) == 1
|
||||
uploaded = fake_storage.upload_calls[0]
|
||||
assert uploaded["bucket"] == "agent-tool-results"
|
||||
payload = cast(dict[str, Any], uploaded["payload"])
|
||||
assert payload["toolName"] == "calendar_write"
|
||||
assert "args" not in payload
|
||||
assert isinstance(payload.get("result"), dict)
|
||||
assert payload["result"]["token"] == "[REDACTED]"
|
||||
assert captured["message_delta"] == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_sanitizes_nested_sensitive_fields_in_result_payload(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
fake_chat_session = SimpleNamespace(state_snapshot={}, message_count=0)
|
||||
|
||||
class _FakeSessionRepository:
|
||||
def __init__(self, session: object) -> None:
|
||||
del session
|
||||
|
||||
async def get_session(self, *, session_id): # noqa: ANN001
|
||||
del session_id
|
||||
return fake_chat_session
|
||||
|
||||
async def lock_session_for_update(self, *, session_id): # noqa: ANN001
|
||||
del session_id
|
||||
return fake_chat_session
|
||||
|
||||
async def update_runtime_state(self, **kwargs): # noqa: ANN003
|
||||
captured.update(kwargs)
|
||||
|
||||
class _FakeMessageRepository:
|
||||
def __init__(self, session: object) -> None:
|
||||
del session
|
||||
|
||||
async def append_message(self, **kwargs): # noqa: ANN003
|
||||
captured["append_kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr(store_module, "SessionRepository", _FakeSessionRepository)
|
||||
monkeypatch.setattr(store_module, "MessageRepository", _FakeMessageRepository)
|
||||
monkeypatch.setattr(store_module, "AgentChatSessionStatus", _SessionStatus)
|
||||
|
||||
fake_storage = _FakeToolResultStorage()
|
||||
store = store_module.SqlAlchemyEventStore(
|
||||
session_factory=lambda: _FakeSessionCtx(),
|
||||
tool_result_storage=fake_storage,
|
||||
tool_result_bucket="agent-tool-results",
|
||||
)
|
||||
await store.persist(
|
||||
{
|
||||
"type": "TOOL_CALL_RESULT",
|
||||
"threadId": "00000000-0000-0000-0000-000000000001",
|
||||
"runId": "run-1",
|
||||
"toolName": "calendar_write",
|
||||
"result": {
|
||||
"data": {
|
||||
"ok": True,
|
||||
"accessToken": "secret-a",
|
||||
"nested": {
|
||||
"refresh_token": "secret-b",
|
||||
},
|
||||
"items": [
|
||||
{"authorizationHeader": "secret-c"},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
payload = cast(dict[str, Any], fake_storage.upload_calls[0]["payload"])
|
||||
stored_result = cast(dict[str, Any], payload["result"])
|
||||
data = cast(dict[str, Any], stored_result["data"])
|
||||
assert data["accessToken"] == "[REDACTED]"
|
||||
nested = cast(dict[str, Any], data["nested"])
|
||||
assert nested["refresh_token"] == "[REDACTED]"
|
||||
items = cast(list[Any], data["items"])
|
||||
assert isinstance(items[0], dict)
|
||||
assert items[0]["authorizationHeader"] == "[REDACTED]"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_store_drops_buffer_when_session_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from core.agentscope.events.tool_result_summary import build_tool_content_summary
|
||||
|
||||
|
||||
def test_summary_prioritizes_error() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "A"},
|
||||
result={"message": "ignored"},
|
||||
error={"message": "denied"},
|
||||
)
|
||||
assert text == "calendar_write 执行失败:denied"
|
||||
|
||||
|
||||
def test_summary_for_calendar_write() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "项目评审"},
|
||||
result={"startAt": "明天 10:00"},
|
||||
error=None,
|
||||
)
|
||||
assert text == "已创建日程:项目评审(明天 10:00)"
|
||||
|
||||
|
||||
def test_summary_for_calendar_read() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_read",
|
||||
args={"query": "今天"},
|
||||
result={"data": {"total": 3}},
|
||||
error=None,
|
||||
)
|
||||
assert text == "查询到 3 条日程(今天)"
|
||||
|
||||
|
||||
def test_summary_falls_back_to_result_content() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="unknown_tool",
|
||||
args=None,
|
||||
result={"content": "这是非常长的说明" * 20},
|
||||
error=None,
|
||||
)
|
||||
assert text.startswith("这是非常长的说明")
|
||||
assert len(text) <= 80
|
||||
|
||||
|
||||
def test_summary_default_done() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="unknown_tool",
|
||||
args=None,
|
||||
result=None,
|
||||
error=None,
|
||||
)
|
||||
assert text == "unknown_tool 执行完成"
|
||||
|
||||
|
||||
def test_summary_marks_business_failure_when_ok_false() -> None:
|
||||
text = build_tool_content_summary(
|
||||
tool_name="calendar_write",
|
||||
args={"title": "上学"},
|
||||
result={
|
||||
"type": "calendar_operation.v1",
|
||||
"data": {
|
||||
"ok": False,
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "calendar.write requires validated user token",
|
||||
},
|
||||
},
|
||||
error=None,
|
||||
)
|
||||
assert (
|
||||
text == "calendar_write 执行失败:calendar.write requires validated user token"
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -94,3 +94,46 @@ def test_validate_run_request_messages_contract_requires_single_user_message() -
|
||||
match="RunAgentInput.messages must contain exactly one user message",
|
||||
):
|
||||
validate_run_request_messages_contract(run_input)
|
||||
|
||||
|
||||
def test_validate_run_request_messages_contract_accepts_binary_url_blocks() -> None:
|
||||
payload = _base_payload()
|
||||
payload["messages"] = [
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请分析"},
|
||||
{
|
||||
"type": "binary",
|
||||
"mimeType": "image/png",
|
||||
"url": "https://signed.example/a.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
run_input = parse_run_input(payload)
|
||||
|
||||
validate_run_request_messages_contract(run_input)
|
||||
|
||||
|
||||
def test_validate_run_request_messages_contract_rejects_binary_data_block() -> None:
|
||||
payload = _base_payload()
|
||||
payload["messages"] = [
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请分析"},
|
||||
{
|
||||
"type": "binary",
|
||||
"mimeType": "image/png",
|
||||
"data": "aGVsbG8=",
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
run_input = parse_run_input(payload)
|
||||
|
||||
with pytest.raises(ValueError, match="binary content requires url"):
|
||||
validate_run_request_messages_contract(run_input)
|
||||
|
||||
@@ -54,3 +54,20 @@ def test_build_intent_user_prompt_filters_non_image_binary_block() -> None:
|
||||
assert isinstance(prompt, list)
|
||||
image_blocks = [item for item in prompt if item.get("type") == "image"]
|
||||
assert image_blocks == []
|
||||
|
||||
|
||||
def test_build_intent_user_prompt_includes_previous_context_messages() -> None:
|
||||
prompt = build_intent_user_prompt(
|
||||
user_input=[
|
||||
{"id": "u1", "role": "user", "content": "我的口令是蓝鲸42"},
|
||||
{"id": "a1", "role": "assistant", "content": "已记住"},
|
||||
{"id": "u2", "role": "user", "content": "请重复口令"},
|
||||
]
|
||||
)
|
||||
|
||||
assert isinstance(prompt, list)
|
||||
assert prompt
|
||||
instruction = prompt[0].get("text", "")
|
||||
assert isinstance(instruction, str)
|
||||
assert "[Conversation Context]" in instruction
|
||||
assert "\\u84dd\\u9cb842" in instruction
|
||||
|
||||
@@ -67,10 +67,8 @@ async def test_close_clears_clients(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert await service.initialize() is True
|
||||
assert await service.close() is True
|
||||
assert service.is_initialized is False
|
||||
with pytest.raises(RuntimeError):
|
||||
service.get_client()
|
||||
with pytest.raises(RuntimeError):
|
||||
service.get_admin_client()
|
||||
assert service.get_client() is not None
|
||||
assert service.get_admin_client() is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -117,7 +115,47 @@ def test_get_client_raises_before_init() -> None:
|
||||
settings=SupabaseSettings(public_url="https://test.supabase.co")
|
||||
)
|
||||
|
||||
assert service.get_client() is not None
|
||||
assert service.get_admin_client() is not None
|
||||
|
||||
|
||||
def test_get_client_raises_when_lazy_initialization_fails(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = SupabaseService(
|
||||
settings=SupabaseSettings(public_url="https://test.supabase.co")
|
||||
)
|
||||
|
||||
def _fake_create_client(_: str, __: str) -> object:
|
||||
raise RuntimeError("boom")
|
||||
|
||||
monkeypatch.setattr("services.base.supabase.create_client", _fake_create_client)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
service.get_client()
|
||||
with pytest.raises(RuntimeError):
|
||||
service.get_admin_client()
|
||||
|
||||
|
||||
def test_get_admin_client_lazily_initializes_clients(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service = SupabaseService(
|
||||
settings=SupabaseSettings(public_url="https://test.supabase.co")
|
||||
)
|
||||
anon_client = MagicMock(name="anon")
|
||||
admin_client = MagicMock(name="admin")
|
||||
create_calls: list[tuple[str, str]] = []
|
||||
|
||||
def _fake_create_client(url: str, key: str) -> object:
|
||||
create_calls.append((url, key))
|
||||
return anon_client if len(create_calls) == 1 else admin_client
|
||||
|
||||
monkeypatch.setattr("services.base.supabase.create_client", _fake_create_client)
|
||||
|
||||
resolved_admin = service.get_admin_client()
|
||||
|
||||
assert resolved_admin is admin_client
|
||||
assert service.get_client() is anon_client
|
||||
assert service.is_initialized is True
|
||||
assert len(create_calls) == 2
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import v1.agent.attachment_storage as attachment_storage_module
|
||||
|
||||
|
||||
class _FakeBucket:
|
||||
def __init__(self) -> None:
|
||||
self.upload_calls: list[tuple[str, bytes, dict[str, str]]] = []
|
||||
self.download_calls: list[str] = []
|
||||
|
||||
def upload(self, path: str, content: bytes, options: dict[str, str]) -> object:
|
||||
self.upload_calls.append((path, content, options))
|
||||
return {"path": path}
|
||||
|
||||
def download(self, path: str) -> object:
|
||||
self.download_calls.append(path)
|
||||
return b"ok"
|
||||
|
||||
|
||||
class _FakeStorage:
|
||||
def __init__(self, bucket: _FakeBucket) -> None:
|
||||
self._bucket = bucket
|
||||
|
||||
def from_(self, bucket: str) -> object:
|
||||
del bucket
|
||||
return self._bucket
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_storage_rejects_unexpected_bucket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
storage = attachment_storage_module.AgentAttachmentStorage()
|
||||
monkeypatch.setattr(
|
||||
attachment_storage_module.config.storage,
|
||||
"bucket",
|
||||
"allowed-bucket",
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="Invalid attachment bucket"):
|
||||
await storage.upload_bytes(
|
||||
bucket="other-bucket",
|
||||
path="agent-inputs/u/t/r/file.png",
|
||||
content=b"data",
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attachment_storage_accepts_configured_bucket(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
storage = attachment_storage_module.AgentAttachmentStorage()
|
||||
fake_bucket = _FakeBucket()
|
||||
fake_client = SimpleNamespace(storage=_FakeStorage(fake_bucket))
|
||||
monkeypatch.setattr(
|
||||
attachment_storage_module.config.storage,
|
||||
"bucket",
|
||||
"allowed-bucket",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
attachment_storage_module.supabase_service,
|
||||
"get_admin_client",
|
||||
lambda: fake_client,
|
||||
)
|
||||
|
||||
path = await storage.upload_bytes(
|
||||
bucket="allowed-bucket",
|
||||
path="agent-inputs/u/t/r/file.png",
|
||||
content=b"data",
|
||||
content_type="image/png",
|
||||
)
|
||||
payload = await storage.download_bytes(
|
||||
bucket="allowed-bucket",
|
||||
path=path,
|
||||
)
|
||||
|
||||
assert path == "agent-inputs/u/t/r/file.png"
|
||||
assert payload == b"ok"
|
||||
assert len(fake_bucket.upload_calls) == 1
|
||||
assert fake_bucket.download_calls == ["agent-inputs/u/t/r/file.png"]
|
||||
@@ -6,6 +6,7 @@ from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from core.config.settings import config
|
||||
from models.agent_chat_message import AgentChatMessageRole
|
||||
from v1.agent.repository import AgentRepository
|
||||
|
||||
@@ -62,7 +63,7 @@ async def test_tool_message_hydrates_content_from_object_storage() -> None:
|
||||
content='{"offloaded":true}',
|
||||
metadata_json={
|
||||
"tool_call_id": "call-1",
|
||||
"storage_bucket": "private",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/run-1/call-1.json",
|
||||
},
|
||||
)
|
||||
@@ -73,6 +74,43 @@ async def test_tool_message_hydrates_content_from_object_storage() -> None:
|
||||
assert payload["content"] == "已跳转"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_hydrates_ui_from_ui_schema_field() -> None:
|
||||
repository = AgentRepository(
|
||||
session=SimpleNamespace(), # type: ignore[arg-type]
|
||||
tool_result_storage=_FakeToolResultStorage(
|
||||
{
|
||||
"toolName": "calendar_write",
|
||||
"ui_schema": {
|
||||
"type": "calendar_operation.v1",
|
||||
"version": "v1",
|
||||
"data": {"ok": True, "operation": "create"},
|
||||
"actions": [],
|
||||
},
|
||||
}
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
role=AgentChatMessageRole.TOOL,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
content="已创建日程:项目评审(明天 10:00)",
|
||||
metadata_json={
|
||||
"tool_call_id": "call-3",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/run-1/call-3.json",
|
||||
},
|
||||
)
|
||||
|
||||
payload = await repository._to_snapshot_message(message) # type: ignore[arg-type]
|
||||
|
||||
assert payload["toolCallId"] == "call-3"
|
||||
assert payload["content"] == "已创建日程:项目评审(明天 10:00)"
|
||||
ui = payload.get("ui")
|
||||
assert isinstance(ui, dict)
|
||||
assert ui["type"] == "calendar_operation.v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_keeps_inline_content_when_storage_payload_missing() -> None:
|
||||
repository = AgentRepository(
|
||||
@@ -86,7 +124,7 @@ async def test_tool_message_keeps_inline_content_when_storage_payload_missing()
|
||||
content="inline-tool-content",
|
||||
metadata_json={
|
||||
"tool_call_id": "call-2",
|
||||
"storage_bucket": "private",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/run-1/call-2.json",
|
||||
},
|
||||
)
|
||||
@@ -97,6 +135,111 @@ async def test_tool_message_keeps_inline_content_when_storage_payload_missing()
|
||||
assert payload["content"] == "inline-tool-content"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_skips_storage_when_path_not_matching_session() -> None:
|
||||
repository = AgentRepository(
|
||||
session=SimpleNamespace(), # type: ignore[arg-type]
|
||||
tool_result_storage=_FakeToolResultStorage(
|
||||
{
|
||||
"ui_schema": {
|
||||
"type": "calendar_operation.v1",
|
||||
"version": "v1",
|
||||
"data": {"ok": True},
|
||||
"actions": [],
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
role=AgentChatMessageRole.TOOL,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
content="summary",
|
||||
metadata_json={
|
||||
"tool_call_id": "call-x",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/foreign-session/call-y.json",
|
||||
},
|
||||
)
|
||||
|
||||
payload = await repository._to_snapshot_message(message) # type: ignore[arg-type]
|
||||
|
||||
assert payload["content"] == "summary"
|
||||
assert "ui" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_rejects_path_traversal() -> None:
|
||||
repository = AgentRepository(
|
||||
session=SimpleNamespace(), # type: ignore[arg-type]
|
||||
tool_result_storage=_FakeToolResultStorage(
|
||||
{
|
||||
"ui_schema": {
|
||||
"type": "calendar_operation.v1",
|
||||
"version": "v1",
|
||||
"data": {"ok": True},
|
||||
"actions": [],
|
||||
}
|
||||
}
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
role=AgentChatMessageRole.TOOL,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
content="summary",
|
||||
metadata_json={
|
||||
"tool_call_id": "call-z",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/ok/../../evil/call-z.json",
|
||||
},
|
||||
)
|
||||
|
||||
payload = await repository._to_snapshot_message(message) # type: ignore[arg-type]
|
||||
|
||||
assert payload["content"] == "summary"
|
||||
assert "ui" not in payload
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tool_message_supports_legacy_storage_path() -> None:
|
||||
repository = AgentRepository(
|
||||
session=SimpleNamespace(), # type: ignore[arg-type]
|
||||
tool_result_storage=_FakeToolResultStorage(
|
||||
{
|
||||
"ui_schema": {
|
||||
"type": "calendar_operation.v1",
|
||||
"version": "v1",
|
||||
"data": {"ok": True},
|
||||
"actions": [],
|
||||
},
|
||||
"content": "legacy content",
|
||||
}
|
||||
),
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
role=AgentChatMessageRole.TOOL,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
content='{"offloaded":true}',
|
||||
metadata_json={
|
||||
"tool_call_id": "call-legacy",
|
||||
"storage_bucket": config.storage.bucket,
|
||||
"storage_path": "tool-results/old-run/call-legacy.json",
|
||||
},
|
||||
)
|
||||
|
||||
payload = await repository._to_snapshot_message(message) # type: ignore[arg-type]
|
||||
|
||||
assert payload["content"] == "legacy content"
|
||||
ui = payload.get("ui")
|
||||
assert isinstance(ui, dict)
|
||||
assert ui["type"] == "calendar_operation.v1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_message_snapshot_includes_renderable_attachments() -> None:
|
||||
repository = AgentRepository(
|
||||
@@ -104,6 +247,7 @@ async def test_user_message_snapshot_includes_renderable_attachments() -> None:
|
||||
)
|
||||
message = SimpleNamespace(
|
||||
id=uuid4(),
|
||||
session_id=uuid4(),
|
||||
role=AgentChatMessageRole.USER,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
content="请分析这张图",
|
||||
@@ -122,13 +266,13 @@ async def test_user_message_snapshot_includes_renderable_attachments() -> None:
|
||||
|
||||
assert payload["role"] == "user"
|
||||
assert payload["content"] == "请分析这张图"
|
||||
assert payload["attachments"] == [
|
||||
{
|
||||
"bucket": "agent-chat-attachments",
|
||||
"path": "agent-inputs/u1/t1/r1/m1/att-1.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
attachments = payload.get("attachments")
|
||||
assert isinstance(attachments, list)
|
||||
assert len(attachments) == 1
|
||||
first = attachments[0]
|
||||
assert isinstance(first, dict)
|
||||
assert first["mimeType"] == "image/png"
|
||||
assert isinstance(first.get("previewPath"), str)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -174,3 +318,32 @@ async def test_persist_user_message_keeps_existing_session_title() -> None:
|
||||
|
||||
assert session_row.title == "已有标题"
|
||||
assert session_row.message_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_message_attachment_reference_returns_item() -> None:
|
||||
session_id = str(uuid4())
|
||||
message_id = str(uuid4())
|
||||
message = SimpleNamespace(
|
||||
metadata_json={
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": "bucket-test",
|
||||
"path": "agent-inputs/u/t/r/a.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
fake_session = _FakeSession(message)
|
||||
repository = AgentRepository(session=fake_session) # type: ignore[arg-type]
|
||||
|
||||
ref = await repository.get_message_attachment_reference(
|
||||
session_id=session_id,
|
||||
message_id=message_id,
|
||||
attachment_index=0,
|
||||
)
|
||||
|
||||
assert ref is not None
|
||||
assert ref["bucket"] == "bucket-test"
|
||||
assert ref["mimeType"] == "image/png"
|
||||
|
||||
@@ -225,3 +225,44 @@ async def test_stream_events_retries_on_redis_timeout(
|
||||
|
||||
merged = "".join(chunks)
|
||||
assert "event: RUN_FINISHED" in merged
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_preview_rejects_negative_index() -> None:
|
||||
class _Service:
|
||||
async def get_attachment_preview(self, **kwargs): # noqa: ANN003
|
||||
del kwargs
|
||||
raise AssertionError("get_attachment_preview should not be called")
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await agent_router.get_attachment_preview(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
message_id="00000000-0000-0000-0000-000000000010",
|
||||
attachment_index=-1,
|
||||
service=cast(Any, _Service()),
|
||||
current_user=CurrentUser(id=uuid4(), email="user@example.com"),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_attachment_preview_returns_streaming_response() -> None:
|
||||
class _Service:
|
||||
async def get_attachment_preview(self, **kwargs): # noqa: ANN003
|
||||
del kwargs
|
||||
return b"png-bytes", "image/png"
|
||||
|
||||
response = await agent_router.get_attachment_preview(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
message_id="00000000-0000-0000-0000-000000000010",
|
||||
attachment_index=0,
|
||||
service=cast(Any, _Service()),
|
||||
current_user=CurrentUser(id=uuid4(), email="user@example.com"),
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
async for chunk in response.body_iterator:
|
||||
chunks.append(cast(bytes, chunk))
|
||||
|
||||
assert response.media_type == "image/png"
|
||||
assert b"".join(chunks) == b"png-bytes"
|
||||
|
||||
@@ -6,8 +6,10 @@ from uuid import UUID
|
||||
|
||||
from ag_ui.core import RunAgentInput
|
||||
from fastapi import HTTPException
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from core.auth.models import CurrentUser
|
||||
from core.config.settings import config
|
||||
import v1.agent.service as agent_service_module
|
||||
from v1.agent.service import AgentService, AsrService
|
||||
|
||||
@@ -74,12 +76,32 @@ class _FakeRepository:
|
||||
}
|
||||
)
|
||||
|
||||
async def get_message_attachment_reference(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
attachment_index: int,
|
||||
) -> dict[str, str] | None:
|
||||
del session_id, message_id
|
||||
if attachment_index != 0:
|
||||
return None
|
||||
return {
|
||||
"bucket": config.storage.bucket,
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/run-1/attachment-0-a.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
|
||||
|
||||
class _FakeQueue:
|
||||
def __init__(self) -> None:
|
||||
self.commands: list[dict[str, object]] = []
|
||||
|
||||
async def enqueue(
|
||||
self, *, command: dict[str, object], dedup_key: str | None
|
||||
) -> str:
|
||||
del command, dedup_key
|
||||
self.commands.append(command)
|
||||
del dedup_key
|
||||
return "task-1"
|
||||
|
||||
|
||||
@@ -123,6 +145,33 @@ class _FakeAttachmentStorage:
|
||||
)
|
||||
return path
|
||||
|
||||
async def download_bytes(self, *, bucket: str, path: str) -> bytes:
|
||||
self.calls.append(
|
||||
{
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"download": True,
|
||||
}
|
||||
)
|
||||
return b"png-bytes"
|
||||
|
||||
async def create_signed_url(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
path: str,
|
||||
expires_in_seconds: int,
|
||||
) -> str:
|
||||
self.calls.append(
|
||||
{
|
||||
"bucket": bucket,
|
||||
"path": path,
|
||||
"signed": True,
|
||||
"expires_in_seconds": expires_in_seconds,
|
||||
}
|
||||
)
|
||||
return f"https://signed.example/{path}?exp={expires_in_seconds}"
|
||||
|
||||
|
||||
class _AlwaysFailAttachmentStorage:
|
||||
async def upload_bytes(
|
||||
@@ -136,6 +185,20 @@ class _AlwaysFailAttachmentStorage:
|
||||
del bucket, path, content, content_type
|
||||
raise RuntimeError("upload failed")
|
||||
|
||||
async def download_bytes(self, *, bucket: str, path: str) -> bytes:
|
||||
del bucket, path
|
||||
raise RuntimeError("download failed")
|
||||
|
||||
async def create_signed_url(
|
||||
self,
|
||||
*,
|
||||
bucket: str,
|
||||
path: str,
|
||||
expires_in_seconds: int,
|
||||
) -> str:
|
||||
del bucket, path, expires_in_seconds
|
||||
raise RuntimeError("sign failed")
|
||||
|
||||
|
||||
def _user() -> CurrentUser:
|
||||
return CurrentUser(
|
||||
@@ -186,9 +249,10 @@ async def test_resume_idempotency_uses_redis_lock_and_task_key() -> None:
|
||||
|
||||
async def test_enqueue_run_creates_missing_thread_session() -> None:
|
||||
repository = _FakeRepository()
|
||||
queue = _FakeQueue()
|
||||
service = AgentService(
|
||||
repository=repository,
|
||||
queue=_FakeQueue(),
|
||||
queue=queue,
|
||||
stream=_FakeStream(),
|
||||
)
|
||||
run_input = _build_run_input(
|
||||
@@ -206,6 +270,30 @@ async def test_enqueue_run_creates_missing_thread_session() -> None:
|
||||
assert accepted.created is True
|
||||
assert repository.created_with_session_id == "00000000-0000-0000-0000-000000000999"
|
||||
assert repository.committed is True
|
||||
assert queue.commands[0]["user_token"] is None
|
||||
|
||||
|
||||
async def test_enqueue_run_uses_explicit_user_token() -> None:
|
||||
repository = _FakeRepository()
|
||||
queue = _FakeQueue()
|
||||
service = AgentService(
|
||||
repository=repository,
|
||||
queue=queue,
|
||||
stream=_FakeStream(),
|
||||
)
|
||||
run_input = _build_run_input(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
run_id="run-1",
|
||||
)
|
||||
|
||||
await service.enqueue_run(
|
||||
run_input=run_input,
|
||||
current_user=_user(),
|
||||
user_token="Bearer access-token-1",
|
||||
)
|
||||
|
||||
assert queue.commands
|
||||
assert queue.commands[0]["user_token"] == "access-token-1"
|
||||
|
||||
|
||||
async def test_enqueue_run_keeps_created_session_when_enqueue_fails() -> None:
|
||||
@@ -270,7 +358,7 @@ async def test_enqueue_run_handles_session_create_race() -> None:
|
||||
assert repository.rolled_back is True
|
||||
|
||||
|
||||
async def test_enqueue_run_uploads_user_image_to_supabase_and_injects_metadata(
|
||||
async def test_enqueue_run_uses_forwarded_attachments_and_injects_metadata(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
@@ -297,15 +385,23 @@ async def test_enqueue_run_uploads_user_image_to_supabase_and_injects_metadata(
|
||||
{"type": "text", "text": "帮我看下这张图"},
|
||||
{
|
||||
"type": "binary",
|
||||
"data": "aGVsbG8=",
|
||||
"mimeType": "image/png",
|
||||
"url": "https://signed.example/upload.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {},
|
||||
"forwardedProps": {
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": "agent-test-bucket",
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/upload.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -313,10 +409,9 @@ async def test_enqueue_run_uploads_user_image_to_supabase_and_injects_metadata(
|
||||
|
||||
assert accepted.task_id == "task-1"
|
||||
assert len(attachment_storage.calls) == 1
|
||||
upload = attachment_storage.calls[0]
|
||||
assert upload["bucket"] == "agent-test-bucket"
|
||||
assert upload["content"] == b"hello"
|
||||
assert upload["content_type"] == "image/png"
|
||||
download = attachment_storage.calls[0]
|
||||
assert download["bucket"] == "agent-test-bucket"
|
||||
assert download["download"] is True
|
||||
assert repository.persisted_user_messages
|
||||
persisted = repository.persisted_user_messages[0]
|
||||
assert persisted["session_id"] == "00000000-0000-0000-0000-000000000001"
|
||||
@@ -330,7 +425,7 @@ async def test_enqueue_run_uploads_user_image_to_supabase_and_injects_metadata(
|
||||
assert isinstance(attachments[0]["path"], str)
|
||||
|
||||
|
||||
async def test_enqueue_run_raises_when_attachment_upload_fails_without_fallback(
|
||||
async def test_enqueue_run_raises_when_attachment_download_fails_without_fallback(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
@@ -356,15 +451,23 @@ async def test_enqueue_run_raises_when_attachment_upload_fails_without_fallback(
|
||||
{"type": "text", "text": "帮我看下这张图"},
|
||||
{
|
||||
"type": "binary",
|
||||
"data": "aGVsbG8=",
|
||||
"mimeType": "image/png",
|
||||
"url": "https://signed.example/upload.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {},
|
||||
"forwardedProps": {
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": "agent-test-bucket",
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/upload.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -373,11 +476,183 @@ async def test_enqueue_run_raises_when_attachment_upload_fails_without_fallback(
|
||||
raise AssertionError("expected HTTPException")
|
||||
except HTTPException as exc:
|
||||
assert exc.status_code == 502
|
||||
assert exc.detail == "Failed to upload attachment"
|
||||
assert exc.detail == "Failed to fetch attachment"
|
||||
|
||||
assert repository.persisted_user_messages == []
|
||||
|
||||
|
||||
async def test_enqueue_run_rejects_unsupported_attachment_type(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(
|
||||
agent_service_module.config.storage, "bucket", "agent-test-bucket"
|
||||
)
|
||||
repository = _FakeRepository()
|
||||
attachment_storage = _FakeAttachmentStorage()
|
||||
service = AgentService(
|
||||
repository=repository,
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=attachment_storage,
|
||||
)
|
||||
run_input = RunAgentInput.model_validate(
|
||||
{
|
||||
"threadId": "00000000-0000-0000-0000-000000000001",
|
||||
"runId": "run-with-bad-image",
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请看附件"},
|
||||
{
|
||||
"type": "binary",
|
||||
"mimeType": "image/gif",
|
||||
"url": "https://signed.example/upload.gif",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": "agent-test-bucket",
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/upload.gif",
|
||||
"mimeType": "image/gif",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.enqueue_run(run_input=run_input, current_user=_user())
|
||||
|
||||
assert exc_info.value.status_code == 422
|
||||
assert exc_info.value.detail == "Unsupported attachment type"
|
||||
assert attachment_storage.calls == []
|
||||
|
||||
|
||||
async def test_enqueue_run_rejects_attachment_too_large(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(agent_service_module, "_MAX_ATTACHMENT_BYTES", 4)
|
||||
monkeypatch.setattr(
|
||||
agent_service_module.config.storage, "bucket", "agent-test-bucket"
|
||||
)
|
||||
repository = _FakeRepository()
|
||||
attachment_storage = _FakeAttachmentStorage()
|
||||
service = AgentService(
|
||||
repository=repository,
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=attachment_storage,
|
||||
)
|
||||
run_input = RunAgentInput.model_validate(
|
||||
{
|
||||
"threadId": "00000000-0000-0000-0000-000000000001",
|
||||
"runId": "run-with-big-image",
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请看附件"},
|
||||
{
|
||||
"type": "binary",
|
||||
"mimeType": "image/png",
|
||||
"url": "https://signed.example/upload.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": "agent-test-bucket",
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/upload.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.enqueue_run(run_input=run_input, current_user=_user())
|
||||
|
||||
assert exc_info.value.status_code == 413
|
||||
assert exc_info.value.detail == "Attachment too large"
|
||||
assert len(attachment_storage.calls) == 1
|
||||
assert attachment_storage.calls[0]["download"] is True
|
||||
|
||||
|
||||
async def test_enqueue_run_accepts_binary_url_and_persists_metadata() -> None:
|
||||
repository = _FakeRepository()
|
||||
queue = _FakeQueue()
|
||||
attachment_storage = _FakeAttachmentStorage()
|
||||
service = AgentService(
|
||||
repository=repository,
|
||||
queue=queue,
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=attachment_storage,
|
||||
)
|
||||
run_input = RunAgentInput.model_validate(
|
||||
{
|
||||
"threadId": "00000000-0000-0000-0000-000000000001",
|
||||
"runId": "run-with-binary-url",
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": "u1",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "请分析"},
|
||||
{
|
||||
"type": "binary",
|
||||
"mimeType": "image/png",
|
||||
"url": "https://signed.example/upload-1.png",
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"attachments": [
|
||||
{
|
||||
"bucket": config.storage.bucket,
|
||||
"path": "agent-inputs/00000000-0000-0000-0000-000000000001/00000000-0000-0000-0000-000000000001/upload-1.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
accepted = await service.enqueue_run(run_input=run_input, current_user=_user())
|
||||
|
||||
assert accepted.task_id == "task-1"
|
||||
persisted = repository.persisted_user_messages[-1]
|
||||
metadata = persisted["metadata"]
|
||||
assert isinstance(metadata, dict)
|
||||
attachments = metadata.get("attachments")
|
||||
assert isinstance(attachments, list)
|
||||
assert attachments[0]["path"].endswith("upload-1.png")
|
||||
queue_input = queue.commands[-1]["run_input"]
|
||||
assert isinstance(queue_input, dict)
|
||||
content = queue_input["messages"][0]["content"]
|
||||
assert isinstance(content, list)
|
||||
assert content[1]["type"] == "binary"
|
||||
assert content[1]["url"] == "https://signed.example/upload-1.png"
|
||||
|
||||
|
||||
async def test_get_history_snapshot_wraps_history_day_as_state_snapshot_event() -> None:
|
||||
service = AgentService(
|
||||
repository=_FakeRepository(),
|
||||
@@ -415,6 +690,59 @@ async def test_get_user_history_snapshot_uses_latest_thread_when_absent() -> Non
|
||||
assert event["threadId"] == "00000000-0000-0000-0000-000000000001"
|
||||
|
||||
|
||||
async def test_get_attachment_preview_returns_payload_and_mime() -> None:
|
||||
service = AgentService(
|
||||
repository=_FakeRepository(),
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=_FakeAttachmentStorage(),
|
||||
)
|
||||
|
||||
payload, mime_type = await service.get_attachment_preview(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
message_id="00000000-0000-0000-0000-000000000010",
|
||||
attachment_index=0,
|
||||
current_user=_user(),
|
||||
)
|
||||
|
||||
assert payload == b"png-bytes"
|
||||
assert mime_type == "image/png"
|
||||
|
||||
|
||||
async def test_get_attachment_preview_rejects_invalid_path() -> None:
|
||||
class _BadPathRepository(_FakeRepository):
|
||||
async def get_message_attachment_reference(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
message_id: str,
|
||||
attachment_index: int,
|
||||
) -> dict[str, str] | None:
|
||||
del session_id, message_id, attachment_index
|
||||
return {
|
||||
"bucket": "bucket-test",
|
||||
"path": "agent-inputs/other-user/other-thread/run-1/a.png",
|
||||
"mimeType": "image/png",
|
||||
}
|
||||
|
||||
service = AgentService(
|
||||
repository=_BadPathRepository(),
|
||||
queue=_FakeQueue(),
|
||||
stream=_FakeStream(),
|
||||
attachment_storage=_FakeAttachmentStorage(),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await service.get_attachment_preview(
|
||||
thread_id="00000000-0000-0000-0000-000000000001",
|
||||
message_id="00000000-0000-0000-0000-000000000010",
|
||||
attachment_index=0,
|
||||
current_user=_user(),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
async def test_asr_service_parses_dict_output_sentence(monkeypatch) -> None:
|
||||
result = SimpleNamespace(
|
||||
status_code=200,
|
||||
|
||||
Reference in New Issue
Block a user