feat: integrate invite API and improve notification handling
- Add invite code display and binding functionality via API - Fix notification unread count sync on auth state change - Improve notification mark read with server state validation - Add auth state listener to trigger notification refresh - Add YaoCoinConverter for coin-to-yao type conversion - Remove YaoLegend from divination screens (UI cleanup) - Abbreviate relation labels in yao detail view - Add re-register notice to account delete screen - Update 'coins' terminology to 'points' in localization - Fix backend points consumption to only run in CHAT mode - Add HttpxAuthNoiseFilter to suppress auth endpoint logging - Fix notification static_schema import path - Add test coverage for notification bloc error handling - Update AGENTS.md page header rules and image handling - Delete deprecated run-dev.sh script
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import TypedDict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
|
||||
class IdentityData(TypedDict):
|
||||
email: str
|
||||
code: str
|
||||
|
||||
|
||||
async def _create_email_session(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
) -> dict[str, object]:
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/email-session",
|
||||
json={"email": email, "token": code},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _wait_terminal_event(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
access_token: str,
|
||||
thread_id: str,
|
||||
run_id: str,
|
||||
timeout_s: int = 180,
|
||||
) -> str:
|
||||
headers = {"Authorization": f"Bearer {access_token}"}
|
||||
params = {"runId": run_id, "idle_limit": 120}
|
||||
started = time.time()
|
||||
|
||||
async with client.stream(
|
||||
"GET",
|
||||
f"/api/v1/agent/runs/{thread_id}/events",
|
||||
headers=headers,
|
||||
params=params,
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if time.time() - started > timeout_s:
|
||||
raise TimeoutError("SSE timed out")
|
||||
if not line or not line.startswith("data: "):
|
||||
continue
|
||||
event = json.loads(line[6:])
|
||||
event_type = event.get("type")
|
||||
if event_type in {"RUN_FINISHED", "RUN_ERROR"}:
|
||||
return str(event_type)
|
||||
|
||||
raise RuntimeError("No terminal SSE event")
|
||||
|
||||
|
||||
def _build_run_payload(
|
||||
*,
|
||||
thread_id: str,
|
||||
run_id: str,
|
||||
runtime_mode: str,
|
||||
question: str,
|
||||
) -> dict[str, object]:
|
||||
now = int(time.time() * 1000)
|
||||
return {
|
||||
"threadId": thread_id,
|
||||
"runId": run_id,
|
||||
"state": {},
|
||||
"messages": [
|
||||
{
|
||||
"id": f"msg_{run_id}_user_0",
|
||||
"role": "user",
|
||||
"content": question,
|
||||
}
|
||||
],
|
||||
"tools": [],
|
||||
"context": [],
|
||||
"forwardedProps": {
|
||||
"runtime_mode": runtime_mode,
|
||||
"client_time": {
|
||||
"device_timezone": "Asia/Shanghai",
|
||||
"client_now_iso": "2026-04-10T12:00:00Z",
|
||||
"client_epoch_ms": now,
|
||||
},
|
||||
"divinationPayload": {
|
||||
"divinationMethod": "自动起卦",
|
||||
"questionType": "运势",
|
||||
"question": question,
|
||||
"divinationTimeIso": "2026-04-10T12:00:00Z",
|
||||
"yaoLines": ["少阳", "少阴", "老阳", "少阳", "老阴", "少阴"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_follow_up_run_succeeds_and_limit_uses_assistant_count(
|
||||
api_client: httpx.AsyncClient,
|
||||
test_identity: IdentityData,
|
||||
db_cleanup: list[str],
|
||||
) -> None:
|
||||
email = str(test_identity["email"]).strip().lower()
|
||||
db_cleanup.append(email)
|
||||
|
||||
login = await _create_email_session(
|
||||
api_client,
|
||||
email=email,
|
||||
code=str(test_identity["code"]),
|
||||
)
|
||||
token = str(login["access_token"])
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
thread_id = str(uuid.uuid4())
|
||||
|
||||
first_run_id = f"run_chat_{int(time.time() * 1000)}"
|
||||
first_enqueue = await api_client.post(
|
||||
"/api/v1/agent/runs",
|
||||
headers=headers,
|
||||
json=_build_run_payload(
|
||||
thread_id=thread_id,
|
||||
run_id=first_run_id,
|
||||
runtime_mode="chat",
|
||||
question="这周适合推进新项目吗?",
|
||||
),
|
||||
)
|
||||
first_enqueue.raise_for_status()
|
||||
assert first_enqueue.status_code == 202
|
||||
|
||||
first_terminal = await _wait_terminal_event(
|
||||
api_client,
|
||||
access_token=token,
|
||||
thread_id=thread_id,
|
||||
run_id=first_run_id,
|
||||
)
|
||||
assert first_terminal == "RUN_FINISHED"
|
||||
|
||||
second_run_id = f"run_follow_up_{int(time.time() * 1000)}"
|
||||
second_enqueue = await api_client.post(
|
||||
"/api/v1/agent/runs",
|
||||
headers=headers,
|
||||
json=_build_run_payload(
|
||||
thread_id=thread_id,
|
||||
run_id=second_run_id,
|
||||
runtime_mode="follow_up",
|
||||
question="那我第一步应该先做什么?",
|
||||
),
|
||||
)
|
||||
second_enqueue.raise_for_status()
|
||||
assert second_enqueue.status_code == 202
|
||||
|
||||
second_terminal = await _wait_terminal_event(
|
||||
api_client,
|
||||
access_token=token,
|
||||
thread_id=thread_id,
|
||||
run_id=second_run_id,
|
||||
)
|
||||
assert second_terminal == "RUN_FINISHED"
|
||||
|
||||
history_resp = await api_client.get(
|
||||
"/api/v1/agent/history",
|
||||
headers=headers,
|
||||
params={"threadId": thread_id},
|
||||
)
|
||||
history_resp.raise_for_status()
|
||||
history_payload = history_resp.json()
|
||||
messages = history_payload.get("messages")
|
||||
assert isinstance(messages, list)
|
||||
assistant_messages = [
|
||||
message
|
||||
for message in messages
|
||||
if isinstance(message, dict) and message.get("role") == "assistant"
|
||||
]
|
||||
assert len(assistant_messages) == 2
|
||||
|
||||
third_run_id = f"run_follow_up_blocked_{int(time.time() * 1000)}"
|
||||
third_enqueue = await api_client.post(
|
||||
"/api/v1/agent/runs",
|
||||
headers=headers,
|
||||
json=_build_run_payload(
|
||||
thread_id=thread_id,
|
||||
run_id=third_run_id,
|
||||
runtime_mode="follow_up",
|
||||
question="还有哪些风险要特别注意?",
|
||||
),
|
||||
)
|
||||
assert third_enqueue.status_code == 409
|
||||
error_payload = third_enqueue.json()
|
||||
assert error_payload.get("code") == "AGENT_SESSION_RUN_LIMIT_EXCEEDED"
|
||||
params = error_payload.get("params")
|
||||
assert isinstance(params, dict)
|
||||
assert params.get("maxRuns") == 2
|
||||
@@ -61,6 +61,7 @@ class _FakeNotificationRepository:
|
||||
self._items: list[tuple[_FakeUserNotification, _FakeNotification]] = []
|
||||
self._mark_read_ids: list[UUID] = []
|
||||
self._mark_all_read_user_ids: list[UUID] = []
|
||||
self._commit_count = 0
|
||||
|
||||
def add_item(self, un: _FakeUserNotification, n: _FakeNotification) -> None:
|
||||
self._items.append((un, n))
|
||||
@@ -129,6 +130,9 @@ class _FakeNotificationRepository:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
async def commit(self) -> None:
|
||||
self._commit_count += 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_repo() -> _FakeNotificationRepository:
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from core.agentscope.runtime.tasks import _build_recent_context_messages
|
||||
from schemas.agent.forwarded_props import RuntimeMode
|
||||
from schemas.agent.runtime_config import MessageContextConfig
|
||||
|
||||
|
||||
class _StubContextCache:
|
||||
def __init__(self, messages: list[dict[str, object]]) -> None:
|
||||
self._messages = messages
|
||||
|
||||
async def get(self, **_: object) -> list[dict[str, object]]:
|
||||
return self._messages
|
||||
|
||||
|
||||
class _StubAttachmentCache:
|
||||
async def get(self, **_: object) -> None:
|
||||
return None
|
||||
|
||||
async def set(self, **_: object) -> None:
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_recent_context_messages_accepts_snake_case_ganzhi(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
metadata_payload: dict[str, Any] = {
|
||||
"run_id": "run_1",
|
||||
"agent_output": {
|
||||
"status": "success",
|
||||
"sign_level": "中上签",
|
||||
"conclusion": ["结论"],
|
||||
"focus_points": ["重点"],
|
||||
"advice": ["建议"],
|
||||
"keywords": ["一", "二", "三"],
|
||||
"answer": "这是回答",
|
||||
"divination_derived": {
|
||||
"question": "问题",
|
||||
"question_type": "运势",
|
||||
"divination_method": "自动起卦",
|
||||
"divination_time": "2026-04-10T12:00:00Z",
|
||||
"binary_code": "101010",
|
||||
"changed_binary_code": "010101",
|
||||
"gua_name": "乾为天",
|
||||
"upper_name": "乾",
|
||||
"lower_name": "乾",
|
||||
"target_gua_name": "坤为地",
|
||||
"world_position": 3,
|
||||
"response_position": 6,
|
||||
"has_changing_yao": True,
|
||||
"ganzhi": {
|
||||
"year_gan_zhi": "甲子",
|
||||
"month_gan_zhi": "乙丑",
|
||||
"day_gan_zhi": "丙寅",
|
||||
"time_gan_zhi": "丁卯",
|
||||
"year_kong_wang": "戌亥",
|
||||
"month_kong_wang": "申酉",
|
||||
"day_kong_wang": "午未",
|
||||
"time_kong_wang": "辰巳",
|
||||
"yue_jian": "子月",
|
||||
"ri_chen": "寅日",
|
||||
"yue_po": "午火",
|
||||
"ri_chong": "申金",
|
||||
},
|
||||
"wu_xing_statuses": {"金": "旺"},
|
||||
"yao_info_list": [
|
||||
{
|
||||
"position": 1,
|
||||
"spirit_name": "青龙",
|
||||
"relation_name": "兄弟",
|
||||
"tigan_name": "甲",
|
||||
"element_name": "木",
|
||||
"is_yang": True,
|
||||
"is_changing": False,
|
||||
"special_mark": "",
|
||||
},
|
||||
{
|
||||
"position": 2,
|
||||
"spirit_name": "朱雀",
|
||||
"relation_name": "子孙",
|
||||
"tigan_name": "乙",
|
||||
"element_name": "火",
|
||||
"is_yang": False,
|
||||
"is_changing": False,
|
||||
"special_mark": "",
|
||||
},
|
||||
{
|
||||
"position": 3,
|
||||
"spirit_name": "勾陈",
|
||||
"relation_name": "妻财",
|
||||
"tigan_name": "丙",
|
||||
"element_name": "土",
|
||||
"is_yang": True,
|
||||
"is_changing": True,
|
||||
"special_mark": "世",
|
||||
},
|
||||
{
|
||||
"position": 4,
|
||||
"spirit_name": "腾蛇",
|
||||
"relation_name": "官鬼",
|
||||
"tigan_name": "丁",
|
||||
"element_name": "金",
|
||||
"is_yang": False,
|
||||
"is_changing": False,
|
||||
"special_mark": "",
|
||||
},
|
||||
{
|
||||
"position": 5,
|
||||
"spirit_name": "白虎",
|
||||
"relation_name": "父母",
|
||||
"tigan_name": "戊",
|
||||
"element_name": "水",
|
||||
"is_yang": True,
|
||||
"is_changing": False,
|
||||
"special_mark": "应",
|
||||
},
|
||||
{
|
||||
"position": 6,
|
||||
"spirit_name": "玄武",
|
||||
"relation_name": "兄弟",
|
||||
"tigan_name": "己",
|
||||
"element_name": "木",
|
||||
"is_yang": False,
|
||||
"is_changing": True,
|
||||
"special_mark": "",
|
||||
},
|
||||
],
|
||||
"target_yao_info_list": [],
|
||||
"fushen_positions": [],
|
||||
"fushen_info_list": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cache = _StubContextCache(
|
||||
messages=[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "fallback",
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.agentscope.runtime.tasks.create_context_messages_cache",
|
||||
lambda: cache,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.agentscope.runtime.tasks.create_attachment_content_cache",
|
||||
lambda: _StubAttachmentCache(),
|
||||
)
|
||||
|
||||
converted = await _build_recent_context_messages(
|
||||
session=None,
|
||||
thread_id="thread_1",
|
||||
runtime_mode=RuntimeMode.CHAT,
|
||||
context_config=MessageContextConfig(),
|
||||
)
|
||||
|
||||
assert len(converted) == 1
|
||||
content = converted[0].content
|
||||
assert isinstance(content, str)
|
||||
assert "[assistant_context]" in content
|
||||
assert "gua_name:" in content
|
||||
Reference in New Issue
Block a user