mirror of
https://github.com/TencentCloud/Octop.git
synced 2026-10-20 06:19:17 +08:00
* feat: add a decision model for skills, tools, and teams Let a small model shortlist what the chat model sees, including a local Laya install, and show the result on the turn. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: stop Laya through the POSIX-safe kill helper Windows mypy does not see os.killpg, so process-group shutdown goes through the shared helper. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: compare the Laya install command without a POSIX path Windows quotes the interpreter, so the displayed command is checked against the same argv list. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: show Laya readiness and wait for local decisions Pin the local-CPU timeout fix and distinguish a ready Laya service from the service currently enabled for decisions. Co-authored-by: Cursor <cursoragent@cursor.com> * feat: save each decision service and pick the default on the card Clicking a card selects the default. Endpoint and key stay in the drawer so Jev, Laya, and custom can be kept at once. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: jubaoliang <jubaoliang@tencent.com> Co-authored-by: Cursor <cursoragent@cursor.com>
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
"""Unit tests for chat SSE / WebSocket chunk serialization."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
|
|
from octop.api.routers.chat.serialize import _serialize_history_message
|
|
from octop.api.routers.chat.sse import json_chunk_default
|
|
|
|
|
|
def test_json_chunk_default_serializes_langchain_messages() -> None:
|
|
chunk = {
|
|
"type": "state_update",
|
|
"node": "BootstrapMiddleware.before_agent",
|
|
"data": {
|
|
"messages": [
|
|
SystemMessage(content="bootstrap"),
|
|
HumanMessage(content="hi"),
|
|
AIMessage(content="hello"),
|
|
],
|
|
},
|
|
}
|
|
payload = json.dumps(chunk, default=json_chunk_default)
|
|
parsed = json.loads(payload)
|
|
assert parsed["type"] == "state_update"
|
|
assert len(parsed["data"]["messages"]) == 3
|
|
assert parsed["data"]["messages"][0]["content"] == "bootstrap"
|
|
|
|
|
|
def test_json_chunk_default_keeps_tool_message_artifact() -> None:
|
|
"""Live WS/SSE frames must carry the offloaded octop_ui payload."""
|
|
msg = ToolMessage(
|
|
content='{"octop_ui": {"renderer": "bilibili_player"}, "data_ref": "artifact"}',
|
|
tool_call_id="call_1",
|
|
name="bilibili_search_anime",
|
|
artifact={"results": [{"season_id": 1, "episodes": [{"index": 1}]}]},
|
|
)
|
|
payload = json.dumps({"type": "tool_result", "messages": [msg]}, default=json_chunk_default)
|
|
parsed = json.loads(payload)
|
|
wire_msg = parsed["messages"][0]
|
|
assert wire_msg["artifact"]["results"][0]["season_id"] == 1
|
|
assert "episodes" not in wire_msg["content"]
|
|
|
|
|
|
def test_serialize_history_message_includes_tool_artifact() -> None:
|
|
msg = ToolMessage(
|
|
content='{"octop_ui": {"renderer": "bilibili_player"}, "data_ref": "artifact"}',
|
|
tool_call_id="call_1",
|
|
name="bilibili_search_anime",
|
|
artifact={"results": [{"season_id": 1}]},
|
|
)
|
|
entry = _serialize_history_message(msg)
|
|
assert entry is not None
|
|
block = entry["content"][0]
|
|
assert block["type"] == "tool_result"
|
|
assert block["artifact"]["results"][0]["season_id"] == 1
|
|
|
|
|
|
def test_serialize_history_message_includes_decision_savings() -> None:
|
|
msg = AIMessage(
|
|
content="done",
|
|
usage_metadata={"input_tokens": 100, "output_tokens": 4, "total_tokens": 104},
|
|
additional_kwargs={"context_usage": {"input_tokens": 100, "decision_saved": 5972}},
|
|
)
|
|
entry = _serialize_history_message(msg)
|
|
assert entry is not None
|
|
assert entry["usage"]["decision_saved"] == 5972
|
|
|
|
|
|
def test_serialize_history_message_keeps_decision_savings_without_provider_usage() -> None:
|
|
msg = AIMessage(
|
|
content="done",
|
|
additional_kwargs={"context_usage": {"decision_saved": 5972}},
|
|
)
|
|
entry = _serialize_history_message(msg)
|
|
assert entry is not None
|
|
assert entry["usage"] == {
|
|
"input_tokens": 0,
|
|
"output_tokens": 0,
|
|
"total_tokens": 0,
|
|
"decision_saved": 5972,
|
|
}
|
|
|
|
|
|
def test_serialize_history_message_includes_decision() -> None:
|
|
msg = AIMessage(
|
|
content="done",
|
|
additional_kwargs={
|
|
"octop_decision": {
|
|
"kind": "decision",
|
|
"model": "typesafe/jev-1.13",
|
|
"empty": False,
|
|
"parts": [{"kind": "skill", "kept": [{"name": "pdf", "score": 0.9}]}],
|
|
}
|
|
},
|
|
)
|
|
entry = _serialize_history_message(msg)
|
|
assert entry is not None
|
|
assert entry["decision"]["model"] == "typesafe/jev-1.13"
|
|
assert entry["decision"]["parts"][0]["kept"][0]["name"] == "pdf"
|
|
|
|
|
|
def test_serialize_history_message_omits_artifact_key_when_absent() -> None:
|
|
msg = ToolMessage(content="found", tool_call_id="call_1", name="search")
|
|
entry = _serialize_history_message(msg)
|
|
assert entry is not None
|
|
assert "artifact" not in entry["content"][0]
|