Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions LocalModelIntegrator.slnx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@

<Solution>
<Folder Name="/tests/">
<Project Path="tests/LocalModelIntegrator.Tests/LocalModelIntegrator.Tests.csproj" />
</Folder>
<Project Path="src\LocalModelIntegrator\LocalModelIntegrator.csproj">
<Deploy Solution="Debug|*" />
</Project>
Expand Down
45 changes: 45 additions & 0 deletions src/LocalModelIntegrator/Services/ChatMessageNormalizer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using System.Collections.Generic;
using System.Linq;
using LocalModelIntegrator.Models;

namespace LocalModelIntegrator.Services
{
/// <summary>
/// Send-time payload normalization. Strict chat templates (Qwen among them) reject any
/// request whose system message is not the single first message ("System message must be
/// at the beginning"); permissive ones (Llama) don't care. Coalescing every system
/// message into one leading system message satisfies both, so it is applied to every
/// outgoing request regardless of backend.
/// </summary>
public static class ChatMessageNormalizer
{
/// <summary>
/// Returns a payload with all system messages merged (in order of appearance, joined
/// by a blank line) into a single system message at index 0. Non-system messages keep
/// their relative order and identity. The input list is never mutated - callers
/// persist it as conversation history.
/// </summary>
public static List<ChatMessage> CoalesceSystemMessages(List<ChatMessage> messages)
{
if (messages == null)
return null;

List<string> systemParts = messages
.Where(m => m.Role == "system" && !string.IsNullOrWhiteSpace(m.Content))
.Select(m => m.Content)
.ToList();

// Already canonical (or nothing to do): single non-blank system at index 0, no others.
int systemCount = messages.Count(m => m.Role == "system");
if (systemCount == 0 ||
(systemCount == 1 && systemParts.Count == 1 && messages[0].Role == "system"))
return messages;

var result = new List<ChatMessage>(messages.Count);
if (systemParts.Count > 0)
result.Add(new ChatMessage("system", string.Join("\n\n", systemParts)));
result.AddRange(messages.Where(m => m.Role != "system"));
return result;
}
}
}
10 changes: 5 additions & 5 deletions src/LocalModelIntegrator/Services/LLMService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ public async Task<string> CallLLMAsync(
string jsonRequest = dialect.BuildRequestJson(new DialectRequest
{
Model = options.ModelName,
Messages = messages,
Messages = ChatMessageNormalizer.CoalesceSystemMessages(messages),
Temperature = options.Temperature,
MaxTokens = options.MaxTokens,
ContextWindowTokens = options.ContextWindowTokens
Expand Down Expand Up @@ -122,7 +122,7 @@ public async Task<string> CallLLMStreamingAsync(
string jsonRequest = dialect.BuildRequestJson(new DialectRequest
{
Model = options.ModelName,
Messages = messages,
Messages = ChatMessageNormalizer.CoalesceSystemMessages(messages),
Temperature = options.Temperature,
MaxTokens = options.MaxTokens,
Stream = true, // force streaming on regardless of the dialect's default
Expand Down Expand Up @@ -262,7 +262,7 @@ public async Task<LlmToolTurn> CallLLMWithToolsAsync(
string jsonRequest = dialect.BuildRequestJson(new DialectRequest
{
Model = options.ModelName,
Messages = messages,
Messages = ChatMessageNormalizer.CoalesceSystemMessages(messages),
Temperature = options.Temperature,
MaxTokens = options.MaxTokens,
Stream = false,
Expand Down Expand Up @@ -334,7 +334,7 @@ public async Task<LlmToolTurn> CallLLMWithToolsStreamingAsync(
string jsonRequest = dialect.BuildRequestJson(new DialectRequest
{
Model = options.ModelName,
Messages = messages,
Messages = ChatMessageNormalizer.CoalesceSystemMessages(messages),
Temperature = options.Temperature,
MaxTokens = options.MaxTokens,
Stream = true,
Expand Down Expand Up @@ -498,7 +498,7 @@ public async Task<string> CompleteCodeAsync(
string jsonRequest = dialect.BuildRequestJson(new DialectRequest
{
Model = options.ModelName,
Messages = messages,
Messages = ChatMessageNormalizer.CoalesceSystemMessages(messages),
Temperature = temperature,
MaxTokens = maxTokens,
Stream = false,
Expand Down
148 changes: 148 additions & 0 deletions tests/LocalModelIntegrator.Tests/ChatMessageNormalizerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System.Collections.Generic;
using System.Linq;
using LocalModelIntegrator.Models;
using LocalModelIntegrator.Services;
using Newtonsoft.Json.Linq;
using Xunit;

namespace LocalModelIntegrator.Tests
{
// Strict chat templates (Qwen among them) reject any request whose system message is
// not the single first message ("System message must be at the beginning"). The
// normalizer coalesces every system message into one leading system message.
public class ChatMessageNormalizerTests
{
[Fact]
public void MidConversationSystemMessageIsMergedIntoTheLeadingOne()
{
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Base prompt."),
new ChatMessage("user", "First question"),
new ChatMessage("assistant", "First answer"),
new ChatMessage("system", "Current Visual Studio context: Foo.cs"),
new ChatMessage("user", "Second question"),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Equal(4, result.Count);
Assert.Equal("system", result[0].Role);
Assert.Equal("Base prompt.\n\nCurrent Visual Studio context: Foo.cs", result[0].Content);
Assert.Equal(new[] { "user", "assistant", "user" }, result.Skip(1).Select(m => m.Role));
Assert.Equal(new[] { "First question", "First answer", "Second question" },
result.Skip(1).Select(m => m.Content));
}

[Fact]
public void TwoLeadingSystemMessagesBecomeOne()
{
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Agent prompt."),
new ChatMessage("system", "Workspace orientation: bar"),
new ChatMessage("user", "Go"),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Equal(2, result.Count);
Assert.Equal("system", result[0].Role);
Assert.Equal("Agent prompt.\n\nWorkspace orientation: bar", result[0].Content);
Assert.Equal("user", result[1].Role);
}

[Fact]
public void SingleLeadingSystemMessageIsLeftAlone()
{
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Prompt"),
new ChatMessage("user", "Hi"),
new ChatMessage("assistant", "Hello"),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Equal(3, result.Count);
Assert.Equal(new[] { "system", "user", "assistant" }, result.Select(m => m.Role));
Assert.Equal(new[] { "Prompt", "Hi", "Hello" }, result.Select(m => m.Content));
}

[Fact]
public void NoSystemMessagesMeansNoChange()
{
var messages = new List<ChatMessage>
{
new ChatMessage("user", "Hi"),
new ChatMessage("assistant", "Hello"),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Equal(new[] { "user", "assistant" }, result.Select(m => m.Role));
}

[Fact]
public void InputListAndItsMessagesAreNotMutated()
{
var trailingSystem = new ChatMessage("system", "Context note");
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Base"),
new ChatMessage("user", "Q"),
trailingSystem,
};

ChatMessageNormalizer.CoalesceSystemMessages(messages);

// Callers persist this list as conversation history; normalization is send-time only.
Assert.Equal(3, messages.Count);
Assert.Equal("Base", messages[0].Content);
Assert.Equal("Context note", trailingSystem.Content);
Assert.Same(trailingSystem, messages[2]);
}

[Fact]
public void ToolCallMetadataOnNonSystemMessagesIsPreserved()
{
var assistant = new ChatMessage("assistant", null)
{
ToolCalls = JArray.Parse("[{\"id\":\"call_1\"}]")
};
var toolResult = new ChatMessage("tool", "result") { ToolCallId = "call_1" };
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Base"),
new ChatMessage("user", "Q"),
assistant,
toolResult,
new ChatMessage("system", "Active file: Foo.cs"),
new ChatMessage("user", "Next"),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Same(assistant, result[2]);
Assert.Same(toolResult, result[3]);
Assert.Equal("call_1", result[3].ToolCallId);
Assert.NotNull(result[2].ToolCalls);
}

[Fact]
public void BlankSystemMessagesAreDroppedNotJoined()
{
var messages = new List<ChatMessage>
{
new ChatMessage("system", "Base"),
new ChatMessage("user", "Q"),
new ChatMessage("system", " "),
};

List<ChatMessage> result = ChatMessageNormalizer.CoalesceSystemMessages(messages);

Assert.Equal(2, result.Count);
Assert.Equal("Base", result[0].Content);
}
}
}
24 changes: 24 additions & 0 deletions tests/LocalModelIntegrator.Tests/LocalModelIntegrator.Tests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<LangVersion>9.0</LangVersion>
<IsPackable>false</IsPackable>
<RootNamespace>LocalModelIntegrator.Tests</RootNamespace>
</PropertyGroup>

<ItemGroup>
<!-- The VSIX project cannot be referenced from a plain test project (VS SDK build
targets), so the transport-format-free sources under test are compile-linked. -->
<Compile Include="..\..\src\LocalModelIntegrator\Models\ChatMessage.cs" Link="Models\ChatMessage.cs" />
<Compile Include="..\..\src\LocalModelIntegrator\Services\ChatMessageNormalizer.cs" Link="Services\ChatMessageNormalizer.cs" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
</Project>