mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* feat(memory): add strong memory editor and surface task failures * fix(memory): explain session snapshot lifetime in strong memory UI * feat(memory): add owner profile editor and manual conflict review * test: verify fault history, turn recovery and memory isolation * fix: persist terminal failures and release stalled chat turns * fix(deps): require released memory runtime packages Require octop-memory 1.0.2, octop-harness 1.0.4 and octop-gateway 1.0.3 for manual document review and terminal stream cleanup. Remove the obsolete harness Git pin and refresh the lockfile so CI installs the matching released APIs.
197 lines
7.6 KiB
Python
197 lines
7.6 KiB
Python
"""Resource-policy stream error projection tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
from collections.abc import AsyncIterator, Callable
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
from octop_gateway.models import ChannelSubject, InboundMessage, TextContent
|
|
|
|
from octop.infra.errors import ErrorCode, OctopError
|
|
from octop.infra.gateway.process.processor import GlobalProcessor, _stream_error
|
|
from octop.infra.gateway.slash.dispatcher import SlashDispatcher
|
|
from octop.infra.gateway.ws import WS_CHANNEL_ID
|
|
from octop.infra.history.projection import _wire_text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("entry", ["dashboard", "im"])
|
|
async def test_auth_exception_is_displayed_as_failure_and_kept_out_of_checkpoint_messages(
|
|
entry: str,
|
|
) -> None:
|
|
import httpx
|
|
from openai import AuthenticationError
|
|
|
|
from octop.i18n.domains.stream import AUTH, stream_error_message
|
|
|
|
error = AuthenticationError(
|
|
"opaque rejection",
|
|
response=httpx.Response(
|
|
401, request=httpx.Request("POST", "https://provider.example/v1/chat/completions")
|
|
),
|
|
body={},
|
|
)
|
|
|
|
async def stream(*_args: object, **_kwargs: object) -> AsyncIterator[dict[str, Any]]:
|
|
raise error
|
|
yield # pragma: no cover - defines the streaming protocol
|
|
|
|
processor, msg, appended = _processor_with_stream(stream)
|
|
processor._thread_registry.get_or_create_by_key = AsyncMock(return_value="thread-1")
|
|
processor._agent_manager.get_thread_model.return_value = None
|
|
processor._user_repo.get.return_value = MagicMock(locale="zh")
|
|
if entry == "dashboard":
|
|
frames = [frame async for frame in processor.iter_turn_chunks(msg)]
|
|
failures = [frame["message"] for frame in frames if frame.get("type") == "error"]
|
|
assert frames[-1]["type"] == "done"
|
|
else:
|
|
msg.channel_id = "feishu-test"
|
|
msg.channel_type = "feishu"
|
|
events = [event async for event in processor(msg)]
|
|
failures = [event.error for event in events if event.type == "error"]
|
|
assert events[-1].type == "completed"
|
|
assert failures == [stream_error_message(AUTH, "zh")]
|
|
processor._agent_manager.append_thread_messages.assert_not_called()
|
|
assert appended # The product archive retains the failed turn for display.
|
|
|
|
|
|
def test_stream_error_preserves_resource_error_code() -> None:
|
|
error = OctopError(
|
|
ErrorCode.TOKEN_QUOTA_EXCEEDED,
|
|
"token quota exceeded",
|
|
details={"used": 12, "quota": 10},
|
|
)
|
|
|
|
message, code = _stream_error(error, "zh")
|
|
|
|
assert code == "TOKEN_QUOTA_EXCEEDED"
|
|
assert "12/10" in message
|
|
|
|
|
|
def _processor_with_stream(
|
|
stream: Callable[..., AsyncIterator[dict[str, Any]]],
|
|
) -> tuple[GlobalProcessor, InboundMessage, list[list[Any]]]:
|
|
appended: list[list[Any]] = []
|
|
|
|
agent_manager = MagicMock()
|
|
agent_manager.stream = stream
|
|
agent_manager.merge_turn_mcp_servers = MagicMock(return_value=None)
|
|
agent_manager.prepare_chat_mcp = AsyncMock(return_value=[])
|
|
|
|
agent_row = MagicMock()
|
|
agent_row.user_id = 1
|
|
agent_row.config_json = "{}"
|
|
agent_row.system_prompt = None
|
|
agent_row.default_model = None
|
|
agent_repo = MagicMock()
|
|
agent_repo.get = MagicMock(return_value=agent_row)
|
|
|
|
thread_message_repo = MagicMock()
|
|
thread_message_repo.append_if_ready.side_effect = lambda _thread_id, inputs: (
|
|
appended.append(list(inputs)) or len(inputs)
|
|
)
|
|
|
|
processor = GlobalProcessor(
|
|
agent_manager=agent_manager,
|
|
thread_registry=MagicMock(),
|
|
audit_repo=MagicMock(),
|
|
agent_repo=agent_repo,
|
|
user_repo=MagicMock(),
|
|
connector_repo=MagicMock(),
|
|
dispatcher=SlashDispatcher(),
|
|
usage_repo=None,
|
|
gateway=None,
|
|
thread_message_repo=thread_message_repo,
|
|
)
|
|
processor._record_stream_error = AsyncMock()
|
|
msg = InboundMessage(
|
|
channel_id=WS_CHANNEL_ID,
|
|
channel_type="dashboard",
|
|
tenant_id="agent-1",
|
|
channel_subject=ChannelSubject(subject_id="1"),
|
|
content=[TextContent(text="continue this")],
|
|
metadata={"session_key": "sk", "thread_id": "thread-1"},
|
|
)
|
|
return processor, msg, appended
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_turn_chunks_persists_partial_output_and_error() -> None:
|
|
async def stream(*_args: object, **_kwargs: object) -> AsyncIterator[dict[str, Any]]:
|
|
yield {"type": "token", "content": "partial answer"}
|
|
raise RuntimeError("Error code: 402 insufficient balance")
|
|
|
|
processor, msg, appended = _processor_with_stream(stream)
|
|
chunks = [chunk async for chunk in processor.iter_turn_chunks(msg)]
|
|
|
|
assert any(chunk.get("type") == "error" for chunk in chunks)
|
|
assert chunks[-1]["type"] == "done"
|
|
assert appended
|
|
texts = [_wire_text(item) for item in appended[0]]
|
|
assert any("continue this" in text for text in texts)
|
|
assert "partial answer" in texts
|
|
assert any("余额" in text or "insufficient" in text.lower() for text in texts)
|
|
assert any(
|
|
json.loads(item.message_json)["data"]["additional_kwargs"].get("octop_stream_error")
|
|
for item in appended[0]
|
|
if "余额" in _wire_text(item) or "insufficient" in _wire_text(item).lower()
|
|
)
|
|
processor._thread_registry.touch_last_active.assert_called_with("thread-1")
|
|
|
|
|
|
@pytest.mark.parametrize("entry", ["dashboard", "im"])
|
|
async def test_terminal_error_is_saved_before_delivery_even_if_consumer_closes(entry: str) -> None:
|
|
async def stream(*_args: object, **_kwargs: object) -> AsyncIterator[dict[str, Any]]:
|
|
raise RuntimeError("simulated terminal failure")
|
|
yield # pragma: no cover
|
|
|
|
processor, msg, appended = _processor_with_stream(stream)
|
|
processor._thread_registry.get_or_create_by_key = AsyncMock(return_value="thread-1")
|
|
processor._agent_manager.get_thread_model.return_value = None
|
|
if entry == "im":
|
|
msg.channel_id, msg.channel_type = "feishu-test", "feishu"
|
|
events = processor.iter_turn_chunks(msg) if entry == "dashboard" else processor(msg)
|
|
try:
|
|
async for event in events:
|
|
if (event.get("type") if isinstance(event, dict) else event.type) == "error":
|
|
assert len(appended) == 1
|
|
assert any("octop_stream_error" in item.message_json for item in appended[0])
|
|
break
|
|
finally:
|
|
await events.aclose()
|
|
assert len(appended) == 1 # Closing must not append a duplicate error turn.
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_turn_chunks_persists_streamed_tokens_without_state() -> None:
|
|
async def stream(*_args: object, **_kwargs: object) -> AsyncIterator[dict[str, Any]]:
|
|
yield {"type": "token", "content": "partial answer"}
|
|
|
|
processor, msg, appended = _processor_with_stream(stream)
|
|
chunks = [chunk async for chunk in processor.iter_turn_chunks(msg)]
|
|
|
|
assert not any(chunk.get("type") == "error" for chunk in chunks)
|
|
assert chunks[-1]["type"] == "done"
|
|
texts = [_wire_text(item) for item in appended[0]]
|
|
assert any("continue this" in text for text in texts)
|
|
assert "partial answer" in texts
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_turn_chunks_persists_partial_when_cancelled() -> None:
|
|
async def stream(*_args: object, **_kwargs: object) -> AsyncIterator[dict[str, Any]]:
|
|
yield {"type": "token", "content": "partial answer"}
|
|
raise asyncio.CancelledError
|
|
|
|
processor, msg, appended = _processor_with_stream(stream)
|
|
with pytest.raises(asyncio.CancelledError):
|
|
_ = [chunk async for chunk in processor.iter_turn_chunks(msg)]
|
|
|
|
texts = [_wire_text(item) for item in appended[0]]
|
|
assert any("continue this" in text for text in texts)
|
|
assert "partial answer" in texts
|