Files
Hy-Memory/tests/coding/test_chat_message_schema.py
T
2026-10-16 16:26:27 +08:00

341 lines
13 KiB
Python

# -*- coding: utf-8 -*-
"""
ChatMessage / ToolCall / WriteRequest schema 扩展 + _parse_input 双风格规范化测试
覆盖:
- ChatMessage / ToolCall 基础字段与 to_dict / from_dict round-trip
- WriteRequest 新增 workspace_id / branch 字段
- _parse_input:纯字符串、旧式 plain messages、OpenAI 风格、Anthropic 风格、
嵌套 tool_result 内容、多 tool_use+tool_result 配对、非法 JSON args、
空 content 防御
- _parse_input 不变量:所有 ChatMessage.content 永远是 string
"""
from typing import List
import pytest
from hy_memory.client import HyMemoryClient
from hy_memory.pipelines.base import ChatMessage, ToolCall, WriteRequest
# ================================================================
# ToolCall / ChatMessage 基础
# ================================================================
class TestToolCall:
def test_default_construction(self):
tc = ToolCall()
assert tc.id == "" and tc.name == "" and tc.arguments == {}
def test_to_dict(self):
tc = ToolCall(id="t1", name="Read", arguments={"path": "a.py"})
assert tc.to_dict() == {"id": "t1", "name": "Read", "arguments": {"path": "a.py"}}
def test_from_dict_round_trip(self):
d = {"id": "x", "name": "Bash", "arguments": {"command": "ls"}}
assert ToolCall.from_dict(d).to_dict() == d
def test_from_dict_handles_missing_keys(self):
tc = ToolCall.from_dict({})
assert tc.id == "" and tc.name == "" and tc.arguments == {}
def test_from_dict_handles_none_arguments(self):
tc = ToolCall.from_dict({"id": "x", "name": "Y", "arguments": None})
assert tc.arguments == {}
class TestChatMessageBackwardCompat:
"""已有 chat 路径用法不能改变行为"""
def test_old_construction_still_works(self):
m = ChatMessage(role="user", content="hi")
assert m.role == "user" and m.content == "hi"
assert m.tool_calls == []
assert m.tool_call_id is None
assert m.tool_name is None
def test_old_to_dict_format_unchanged(self):
# 不带 tool 信息时 to_dict 输出格式与历史完全一致
m = ChatMessage(role="user", content="hi")
assert m.to_dict() == {"role": "user", "content": "hi"}
def test_old_from_dict_unchanged(self):
m = ChatMessage.from_dict({"role": "assistant", "content": "ok"})
assert m.role == "assistant" and m.content == "ok"
assert not m.has_tool_calls() and not m.is_tool_message()
class TestChatMessageNewFields:
def test_to_dict_includes_tool_calls(self):
tc = ToolCall(id="t1", name="Read", arguments={"path": "a.py"})
m = ChatMessage(role="assistant", content="", tool_calls=[tc])
d = m.to_dict()
assert d["role"] == "assistant" and d["content"] == ""
assert d["tool_calls"] == [tc.to_dict()]
assert "tool_call_id" not in d # 默认 None 不输出
assert "tool_name" not in d
def test_to_dict_includes_tool_result_fields(self):
m = ChatMessage(role="tool", content="42 lines", tool_call_id="t1", tool_name="Read")
d = m.to_dict()
assert d["tool_call_id"] == "t1"
assert d["tool_name"] == "Read"
assert "tool_calls" not in d # 空列表不输出
def test_helpers(self):
# has_tool_calls
assert not ChatMessage(role="user").has_tool_calls()
assert ChatMessage(role="assistant",
tool_calls=[ToolCall(id="x", name="Y")]).has_tool_calls()
# is_tool_message
assert ChatMessage(role="tool").is_tool_message()
assert not ChatMessage(role="user").is_tool_message()
def test_from_dict_round_trip_with_tool_calls(self):
d = {
"role": "assistant",
"content": "I'll read it",
"tool_calls": [{"id": "x", "name": "Bash", "arguments": {"cmd": "ls"}}],
}
m = ChatMessage.from_dict(d)
assert m.has_tool_calls()
assert m.tool_calls[0].arguments == {"cmd": "ls"}
# ================================================================
# WriteRequest 扩展
# ================================================================
class TestWriteRequestExtension:
def test_default_workspace_branch_none(self):
wr = WriteRequest(content="x", user_id="u1")
assert wr.workspace_id is None
assert wr.branch is None
def test_set_workspace_and_branch(self):
wr = WriteRequest(content="x", user_id="u1",
workspace_id="github.com/x/y", branch="main")
assert wr.workspace_id == "github.com/x/y"
assert wr.branch == "main"
def test_existing_fields_unaffected(self):
wr = WriteRequest(content="x", user_id="u1", agent_id="a1",
session_id="s1")
assert wr.user_id == "u1" and wr.agent_id == "a1" and wr.session_id == "s1"
# ================================================================
# _parse_input — 主路径
# ================================================================
class TestParseInputBackwardCompat:
def test_string_input(self):
content, msgs, ctype = HyMemoryClient._parse_input("hello")
assert content == "hello" and msgs == [] and ctype == "text"
def test_invalid_input_type(self):
with pytest.raises(TypeError):
HyMemoryClient._parse_input(42)
def test_plain_messages(self):
data = [
{"role": "user", "content": "你好"},
{"role": "assistant", "content": "我帮你"},
]
_, msgs, ctype = HyMemoryClient._parse_input(data)
assert ctype == "messages"
assert len(msgs) == 2
assert msgs[0].role == "user" and msgs[0].content == "你好"
assert msgs[1].role == "assistant" and msgs[1].content == "我帮你"
# 不会因为新字段引入额外消息
assert all(not m.tool_calls for m in msgs)
class TestParseInputOpenAIStyle:
def test_assistant_with_tool_calls_and_tool_message(self):
data = [
{"role": "user", "content": "读 a.py"},
{"role": "assistant", "content": None,
"tool_calls": [{"id": "t1", "type": "function",
"function": {"name": "Read",
"arguments": '{"path": "a.py"}'}}]},
{"role": "tool", "tool_call_id": "t1", "name": "Read",
"content": "file ok\n42 lines"},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 3
# assistant
assert msgs[1].role == "assistant"
assert msgs[1].content == "" # null → ""
assert len(msgs[1].tool_calls) == 1
tc = msgs[1].tool_calls[0]
assert tc.id == "t1" and tc.name == "Read"
# arguments 已被 json.loads 成 dict
assert tc.arguments == {"path": "a.py"}
# tool message
assert msgs[2].role == "tool"
assert msgs[2].tool_call_id == "t1"
assert msgs[2].tool_name == "Read"
assert msgs[2].content == "file ok\n42 lines"
def test_invalid_json_arguments_become_raw(self):
data = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "x",
"function": {"name": "Bad",
"arguments": "{not valid"}}]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert msgs[0].tool_calls[0].arguments == {"_raw": "{not valid"}
def test_empty_arguments_string(self):
data = [
{"role": "assistant", "content": None,
"tool_calls": [{"id": "x",
"function": {"name": "F", "arguments": ""}}]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert msgs[0].tool_calls[0].arguments == {}
def test_tool_message_with_null_content(self):
data = [{"role": "tool", "tool_call_id": "t1", "content": None}]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 1
assert msgs[0].role == "tool"
assert msgs[0].content == ""
class TestParseInputAnthropicStyle:
def test_text_plus_tool_use_block(self):
data = [
{"role": "user", "content": "读 a.py"},
{"role": "assistant", "content": [
{"type": "text", "text": "让我读一下"},
{"type": "tool_use", "id": "t2", "name": "Read",
"input": {"path": "a.py"}},
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t2",
"content": "42 lines"},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
# user1 + assistant(text+tool_call) + tool(从 user 拆出来的)
assert len(msgs) == 3
assert msgs[0].role == "user" and msgs[0].content == "读 a.py"
assert msgs[1].role == "assistant"
assert msgs[1].content == "让我读一下"
assert len(msgs[1].tool_calls) == 1
assert msgs[1].tool_calls[0].id == "t2"
assert msgs[1].tool_calls[0].arguments == {"path": "a.py"}
# tool_result 拆为独立 role=tool 消息
assert msgs[2].role == "tool"
assert msgs[2].tool_call_id == "t2"
assert msgs[2].content == "42 lines"
def test_nested_tool_result_content_list(self):
data = [
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "t3",
"content": [
{"type": "text", "text": "output line 1"},
{"type": "text", "text": "line 2"},
]},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 1
assert msgs[0].role == "tool"
assert msgs[0].content == "output line 1\nline 2"
def test_multiple_tool_use_and_tool_result_pairs(self):
data = [
{"role": "assistant", "content": [
{"type": "tool_use", "id": "a", "name": "Read",
"input": {"path": "x.py"}},
{"type": "tool_use", "id": "b", "name": "Read",
"input": {"path": "y.py"}},
]},
{"role": "user", "content": [
{"type": "tool_result", "tool_use_id": "a", "content": "X"},
{"type": "tool_result", "tool_use_id": "b", "content": "Y"},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
# assistant(空 text + 2 tool_calls) + 2 个独立 tool 消息
assert len(msgs) == 3
assert msgs[0].role == "assistant"
assert msgs[0].content == ""
assert len(msgs[0].tool_calls) == 2
assert msgs[1].role == "tool" and msgs[1].tool_call_id == "a"
assert msgs[1].content == "X"
assert msgs[2].role == "tool" and msgs[2].tool_call_id == "b"
assert msgs[2].content == "Y"
def test_assistant_only_text_block(self):
# 只有 text block 时不应产生空 tool_calls 列表
data = [
{"role": "assistant", "content": [
{"type": "text", "text": "纯文本回复"},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 1
assert msgs[0].role == "assistant"
assert msgs[0].content == "纯文本回复"
assert msgs[0].tool_calls == []
def test_empty_content_block_list_drops_message(self):
# content=[] 且无任何 tool_calls 不应保留这条消息
data = [{"role": "assistant", "content": []}]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 0
def test_unknown_block_type_silently_ignored(self):
data = [
{"role": "assistant", "content": [
{"type": "image", "source": {"data": "..."}}, # 未知 block
{"type": "text", "text": "看图"},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
assert len(msgs) == 1
assert msgs[0].content == "看图"
class TestParseInputInvariants:
"""所有路径下 ChatMessage.content 必须是 string"""
@pytest.mark.parametrize("data", [
[{"role": "user", "content": "plain"}],
[{"role": "assistant", "content": None,
"tool_calls": [{"id": "x", "function":
{"name": "F", "arguments": "{}"}}]}],
[{"role": "assistant", "content":
[{"type": "text", "text": "a"},
{"type": "tool_use", "id": "i", "name": "N", "input": {}}]}],
[{"role": "user", "content":
[{"type": "tool_result", "tool_use_id": "i", "content": "r"}]}],
[{"role": "tool", "tool_call_id": "i", "content": None}],
])
def test_content_always_str(self, data):
_, msgs, _ = HyMemoryClient._parse_input(data)
for m in msgs:
assert isinstance(m.content, str), \
f"role={m.role} content type is {type(m.content).__name__}"
def test_role_tool_arguments_always_dict(self):
data = [
{"role": "assistant", "content": None,
"tool_calls": [
{"id": "1", "function": {"name": "A", "arguments": '{"a": 1}'}},
{"id": "2", "function": {"name": "B", "arguments": "bogus"}},
{"id": "3", "function": {"name": "C", "arguments": ""}},
{"id": "4", "function": {"name": "D", "arguments": None}},
]},
]
_, msgs, _ = HyMemoryClient._parse_input(data)
for tc in msgs[0].tool_calls:
assert isinstance(tc.arguments, dict)