Skip to content

Commit 6caa002

Browse files
committed
支持系统消息多模型序列化与顺序还原(支持新版claude code)
统一引入 NeutralChatRole.System,完善消息序列化/反序列化,确保 outer/inner system 消息在 Anthropic、GoogleAI2、MiniMax、AzureResponseApi 等服务中顺序一致、内容保留。调整 OpenAI 消息解析逻辑,仅提取前缀 system/developer 作为 outer system,其余按 System 角色保留。补充相关单元测试,新增测试数据文件 claude-code.req.json。
1 parent 8d056d6 commit 6caa002

16 files changed

Lines changed: 1275 additions & 7 deletions

src/BE/tests/Chats.BE.UnitTest/ChatServices/Anthropic/AnthropicChatServiceRequestTests.cs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,30 @@ public void ConvertMessages_ApiSource_PreservesThinkingBlocks()
3434
Assert.Equal("thinking-signature", (string?)thinking["signature"]);
3535
}
3636

37+
[Fact]
38+
public void ConvertMessages_SystemMessage_SerializesInPlace()
39+
{
40+
MethodInfo method = typeof(AnthropicChatService).GetMethod("ConvertMessages", BindingFlags.Static | BindingFlags.NonPublic)
41+
?? throw new InvalidOperationException("ConvertMessages method not found.");
42+
43+
IList<NeutralMessage> messages =
44+
[
45+
NeutralMessage.FromUserText("first"),
46+
NeutralMessage.FromSystemText("inner system"),
47+
NeutralMessage.FromAssistantText("answer")
48+
];
49+
50+
JsonArray result = (JsonArray?)method.Invoke(null, [messages, true, UsageSource.Api])
51+
?? throw new InvalidOperationException("ConvertMessages returned null.");
52+
53+
Assert.Equal(["user", "system", "assistant"], result.Select(x => x!["role"]!.GetValue<string>()).ToArray());
54+
55+
JsonObject systemMessage = Assert.IsType<JsonObject>(result[1]);
56+
JsonArray content = Assert.IsType<JsonArray>(systemMessage["content"]);
57+
Assert.Equal("text", (string?)content[0]?["type"]);
58+
Assert.Equal("inner system", (string?)content[0]?["text"]);
59+
}
60+
3761
[Fact]
3862
public void ConvertMessages_AssistantToolCallWithEmptyParameters_UsesEmptyObjectInput()
3963
{
@@ -92,4 +116,4 @@ public void ConvertMessages_ToolMessageWithImage_NestsImageInsideToolResult()
92116
Assert.Equal("image", (string?)nestedContent[1]?["type"]);
93117
Assert.Equal("https://example.com/chart.png", (string?)nestedContent[1]?["source"]?["url"]);
94118
}
95-
}
119+
}

src/BE/tests/Chats.BE.UnitTest/ChatServices/Anthropic/claude-code.req.json

Lines changed: 1014 additions & 0 deletions
Large diffs are not rendered by default.

src/BE/tests/Chats.BE.UnitTest/ChatServices/AnthropicConversionsTests.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,53 @@ namespace Chats.BE.UnitTest.ChatServices;
66

77
public class AnthropicConversionsTests
88
{
9+
[Fact]
10+
public void ParseAnthropicMessages_ClaudeCodeRequest_PreservesOuterAndInnerSystem()
11+
{
12+
string json = File.ReadAllText(Path.Combine("ChatServices", "Anthropic", "claude-code.req.json"));
13+
JsonObject root = JsonNode.Parse(json)!.AsObject();
14+
15+
NeutralSystemMessage? outerSystem = AnthropicConversions.ParseAnthropicSystem(root["system"]);
16+
IList<NeutralMessage> messages = AnthropicConversions.ParseAnthropicMessages(root["messages"]);
17+
18+
Assert.NotNull(outerSystem);
19+
Assert.True(outerSystem!.Contents.Count > 0);
20+
Assert.Equal([NeutralChatRole.User, NeutralChatRole.System], messages.Select(x => x.Role).ToArray());
21+
22+
NeutralMessage innerSystem = messages[1];
23+
NeutralTextContent text = Assert.Single(innerSystem.Contents.OfType<NeutralTextContent>());
24+
Assert.Contains("The following skills are available", text.Content);
25+
}
26+
27+
[Fact]
28+
public void ParseAnthropicMessages_InnerSystemTextBlock_PreservesCacheControl()
29+
{
30+
JsonNode? messagesNode = JsonNode.Parse("""
31+
[
32+
{
33+
"role": "system",
34+
"content": [
35+
{
36+
"type": "text",
37+
"text": "inner cached system",
38+
"cache_control": { "type": "ephemeral" }
39+
}
40+
]
41+
}
42+
]
43+
""");
44+
45+
IList<NeutralMessage> messages = AnthropicConversions.ParseAnthropicMessages(messagesNode);
46+
47+
NeutralMessage message = Assert.Single(messages);
48+
Assert.Equal(NeutralChatRole.System, message.Role);
49+
50+
NeutralTextContent content = Assert.Single(message.Contents.OfType<NeutralTextContent>());
51+
Assert.Equal("inner cached system", content.Content);
52+
Assert.NotNull(content.CacheControl);
53+
Assert.Equal("ephemeral", content.CacheControl!.Type);
54+
}
55+
956
[Fact]
1057
public void ParseAnthropicMessages_MultipleToolResultsInSingleUserMessage_SplitsIntoDistinctToolMessages()
1158
{
@@ -76,4 +123,4 @@ public void ParseAnthropicMessages_MultipleToolResultsInSingleUserMessage_Splits
76123
Assert.Equal("typescript files", response.Response);
77124
});
78125
}
79-
}
126+
}

src/BE/tests/Chats.BE.UnitTest/ChatServices/ChatCompletions/MiniMaxChatServiceTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,4 +139,31 @@ public void BuildRequestBody_ShouldIncludeReasoningSplitTrue()
139139
JsonObject body = service.ToUpstreamRequestBody(req, stream: true);
140140
Assert.True((bool?)body["reasoning_split"]);
141141
}
142+
143+
[Fact]
144+
public void BuildRequestBody_WithOuterAndInnerSystem_ShouldKeepOuterFirstAndInnerOrdered()
145+
{
146+
TestMiniMaxChatService service = new(new DummyHttpClientFactory());
147+
ChatRequest baseRequest = CreateBaseChatRequest();
148+
baseRequest.ChatConfig.SystemPrompt = "outer system";
149+
150+
ChatRequest request = baseRequest with
151+
{
152+
Messages =
153+
[
154+
NeutralMessage.FromUserText("first user"),
155+
NeutralMessage.FromSystemText("inner system"),
156+
NeutralMessage.FromAssistantText("answer")
157+
]
158+
};
159+
160+
JsonObject body = service.ToUpstreamRequestBody(request, stream: true);
161+
JsonArray messages = Assert.IsType<JsonArray>(body["messages"]);
162+
163+
Assert.Equal(["system", "user", "system", "assistant"], messages.Select(x => x!["role"]!.GetValue<string>()).ToArray());
164+
Assert.Equal("outer system", (string?)messages[0]?["content"]);
165+
Assert.Equal("first user", (string?)messages[1]?["content"]);
166+
Assert.Equal("inner system", (string?)messages[2]?["content"]);
167+
Assert.Equal("answer", (string?)messages[3]?["content"]);
168+
}
142169
}

src/BE/tests/Chats.BE.UnitTest/ChatServices/GoogleAI/GoogleAI2ChatServiceTest.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,36 @@ public void BuildNativeRequestBody_AssistantToolCallWithEmptyParameters_UsesEmpt
191191
Assert.Empty(args);
192192
}
193193

194+
[Fact]
195+
public void BuildNativeRequestBody_WithOuterAndInnerSystem_KeepsSystemInstructionAndInnerText()
196+
{
197+
GoogleAI2ChatService service = new(new DummyHttpClientFactory());
198+
ChatRequest request = CreateBaseChatRequest("gemini-3-flash-preview", "first user", cfg =>
199+
{
200+
cfg.SystemPrompt = "outer system";
201+
}) with
202+
{
203+
Messages =
204+
[
205+
NeutralMessage.FromUserText("first user"),
206+
NeutralMessage.FromSystemText("inner system"),
207+
NeutralMessage.FromUserText("second user")
208+
]
209+
};
210+
211+
JsonObject body = BuildNativeRequestBody(service, request, allowImageGeneration: false);
212+
213+
JsonObject systemInstruction = Assert.IsType<JsonObject>(body["systemInstruction"]);
214+
JsonArray systemParts = Assert.IsType<JsonArray>(systemInstruction["parts"]);
215+
Assert.Equal("outer system", (string?)systemParts[0]?["text"]);
216+
217+
JsonArray contents = Assert.IsType<JsonArray>(body["contents"]);
218+
Assert.Equal(["user", "user", "user"], contents.Select(x => x!["role"]!.GetValue<string>()).ToArray());
219+
Assert.Equal("first user", (string?)contents[0]?["parts"]?[0]?["text"]);
220+
Assert.Equal("inner system", (string?)contents[1]?["parts"]?[0]?["text"]);
221+
Assert.Equal("second user", (string?)contents[2]?["parts"]?[0]?["text"]);
222+
}
223+
194224
[Fact]
195225
public void BuildNativeRequestBody_FunctionToolWithNullableSchema_UsesGoogleNullableFields()
196226
{
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Chats.BE.Services.Models.Neutral;
2+
using System.Text.Json.Nodes;
3+
4+
namespace Chats.BE.UnitTest.ChatServices;
5+
6+
public class NeutralConversionsTests
7+
{
8+
[Fact]
9+
public void OpenAIParsing_ExtractsOnlyLeadingSystemPrefix_AndKeepsLaterSystemMessagesOrdered()
10+
{
11+
JsonArray messages = JsonNode.Parse("""
12+
[
13+
{ "role": "system", "content": "outer system" },
14+
{ "role": "developer", "content": "outer developer" },
15+
{ "role": "user", "content": "first user" },
16+
{ "role": "system", "content": "inner system" },
17+
{ "role": "assistant", "content": "answer" },
18+
{ "role": "developer", "content": "inner developer" }
19+
]
20+
""")!.AsArray();
21+
22+
string? systemPrompt = NeutralConversions.ExtractSystemPrompt(messages);
23+
IList<NeutralMessage> parsedMessages = NeutralConversions.ParseOpenAIMessages(messages);
24+
25+
Assert.Equal("outer system\r\nouter developer", systemPrompt);
26+
Assert.Equal(
27+
[NeutralChatRole.User, NeutralChatRole.System, NeutralChatRole.Assistant, NeutralChatRole.System],
28+
parsedMessages.Select(x => x.Role).ToArray());
29+
Assert.Equal("first user", parsedMessages[0].GetTextContent());
30+
Assert.Equal("inner system", parsedMessages[1].GetTextContent());
31+
Assert.Equal("answer", parsedMessages[2].GetTextContent());
32+
Assert.Equal("inner developer", parsedMessages[3].GetTextContent());
33+
}
34+
}

src/BE/tests/Chats.BE.UnitTest/ChatServices/Response/AzureResponseApiServiceTests.cs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,52 @@ public async Task ResponseApiService_ShouldSendThinkingSignatureAsReasoningEncry
228228
Assert.True(found, "Request input should contain a reasoning item with encrypted_content from NeutralThinkContent.Signature.");
229229
}
230230

231+
[Fact]
232+
public async Task ResponseApiService_ShouldSendOuterAndInnerSystemMessagesInOrder()
233+
{
234+
// Arrange
235+
string sse = "event: response.completed\n" +
236+
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0}}}\n\n";
237+
238+
string? capturedBody = null;
239+
CapturingHttpClientFactory httpClientFactory = new(HttpStatusCode.OK, sse, req =>
240+
{
241+
capturedBody = req.Content == null ? null : req.Content.ReadAsStringAsync().GetAwaiter().GetResult();
242+
});
243+
244+
AzureResponseApiService service = new(httpClientFactory, NullLogger<AzureResponseApiService>.Instance);
245+
ChatRequest request = CreateBaseChatRequest() with
246+
{
247+
Messages =
248+
[
249+
NeutralMessage.FromUserText("first user"),
250+
NeutralMessage.FromSystemText("inner system"),
251+
NeutralMessage.FromUserText("second user")
252+
]
253+
};
254+
255+
// Act
256+
await foreach (ChatSegment _ in service.ChatStreamed(request, CancellationToken.None))
257+
{
258+
// drain
259+
}
260+
261+
// Assert
262+
Assert.False(string.IsNullOrWhiteSpace(capturedBody));
263+
264+
using JsonDocument doc = JsonDocument.Parse(capturedBody!);
265+
JsonElement input = doc.RootElement.GetProperty("input");
266+
JsonElement[] messages = input.EnumerateArray()
267+
.Where(item => item.GetProperty("type").GetString() == "message")
268+
.ToArray();
269+
270+
Assert.Equal(["system", "user", "system", "user"], messages.Select(x => x.GetProperty("role").GetString()!).ToArray());
271+
Assert.Equal("你是AI助手Sdcb Chats\n当前日期: 2026/01/07,当前模型:gpt-5.2", messages[0].GetProperty("content")[0].GetProperty("text").GetString());
272+
Assert.Equal("first user", messages[1].GetProperty("content")[0].GetProperty("text").GetString());
273+
Assert.Equal("inner system", messages[2].GetProperty("content")[0].GetProperty("text").GetString());
274+
Assert.Equal("second user", messages[3].GetProperty("content")[0].GetProperty("text").GetString());
275+
}
276+
231277
[Fact]
232278
public async Task ResponseApiService_ShouldPutEncryptedContentIntoThinkChatSegmentSignature_OnOutputItemDone()
233279
{

src/BE/tests/Chats.BE.UnitTest/Chats.BE.UnitTest.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
<None Update="ChatServices\Response\FiddlerDump\*.dump">
4343
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
4444
</None>
45+
<None Update="ChatServices\Anthropic\claude-code.req.json">
46+
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
47+
</None>
4548
</ItemGroup>
4649

4750
</Project>

src/BE/web/Services/Models/ChatServices/Anthropic/AnthropicChatService.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -727,6 +727,7 @@ static JsonObject ToAnthropicMessage(NeutralMessage message, bool allowThinkingB
727727
{
728728
NeutralChatRole.User => "user",
729729
NeutralChatRole.Assistant => "assistant",
730+
NeutralChatRole.System => "system",
730731
NeutralChatRole.Tool => throw new CustomChatServiceException(DBFinishReason.InternalConfigIssue, "Tool messages should be merged into user messages before conversion."),
731732
_ => throw new CustomChatServiceException(DBFinishReason.InternalConfigIssue, $"Unknown message role: {message.Role}"),
732733
};

src/BE/web/Services/Models/ChatServices/GoogleAI/GoogleAI2ChatService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -712,6 +712,7 @@ private static JsonArray ConvertMessages(IList<NeutralMessage> messages)
712712
NeutralChatRole.User => "user",
713713
NeutralChatRole.Assistant => "model",
714714
NeutralChatRole.Tool => "function",
715+
NeutralChatRole.System => "user",
715716
_ => throw new CustomChatServiceException(DBFinishReason.InternalConfigIssue, $"Unsupported message role: {message.Role} in {nameof(GoogleAI2ChatService)}"),
716717
}
717718
};
@@ -721,6 +722,7 @@ private static JsonArray ConvertMessages(IList<NeutralMessage> messages)
721722
NeutralChatRole.User => BuildUserParts(message),
722723
NeutralChatRole.Assistant => BuildAssistantParts(message),
723724
NeutralChatRole.Tool => BuildToolParts(message),
725+
NeutralChatRole.System => BuildUserParts(message),
724726
_ => throw new NotSupportedException($"Unsupported message role: {message.Role} in {nameof(GoogleAI2ChatService)}"),
725727
};
726728

0 commit comments

Comments
 (0)