From 760cb441a84342481c39940bc389878da086c9ab Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 01:39:31 +0000
Subject: [PATCH 01/11] Initial plan
From e7b4ed33cca6ea103ba700dfc5e4c3e36cff2a65 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 01:43:54 +0000
Subject: [PATCH 02/11] Add ApiKey config, fix chat persistence, add exchange
counter
Co-authored-by: rrusson <653188+rrusson@users.noreply.github.com>
---
ClippyWeb.Tests/ClippyWeb.Tests.csproj | 1 +
ClippyWeb.Tests/SemanticKernelClientTests.cs | 69 ++++++++++++++++++++
ClippyWeb/Program.cs | 4 +-
ClippyWeb/appsettings.json | 3 +-
SemanticKernelHelper/SemanticKernelClient.cs | 50 +++++++++-----
5 files changed, 109 insertions(+), 18 deletions(-)
create mode 100644 ClippyWeb.Tests/SemanticKernelClientTests.cs
diff --git a/ClippyWeb.Tests/ClippyWeb.Tests.csproj b/ClippyWeb.Tests/ClippyWeb.Tests.csproj
index 641b79d..7adca2f 100644
--- a/ClippyWeb.Tests/ClippyWeb.Tests.csproj
+++ b/ClippyWeb.Tests/ClippyWeb.Tests.csproj
@@ -21,6 +21,7 @@
+
diff --git a/ClippyWeb.Tests/SemanticKernelClientTests.cs b/ClippyWeb.Tests/SemanticKernelClientTests.cs
new file mode 100644
index 0000000..4b325b3
--- /dev/null
+++ b/ClippyWeb.Tests/SemanticKernelClientTests.cs
@@ -0,0 +1,69 @@
+using Microsoft.SemanticKernel;
+using Microsoft.SemanticKernel.ChatCompletion;
+
+using SemanticKernelHelper;
+
+namespace ClippyWeb.Tests
+{
+ [TestClass]
+ public class SemanticKernelClientTests
+ {
+ private const string TestApiUrl = "http://localhost:11434/v1";
+ private const string TestModel = "test-model";
+ private const string TestApiKey = "test-api-key";
+
+ [TestMethod]
+ public void IfApiKeyIsProvidedThenClientIsCreated()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel, TestApiKey);
+
+ Assert.IsNotNull(client);
+ }
+
+ [TestMethod]
+ public void IfApiKeyIsNullThenClientIsCreatedWithEmptyKey()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel, null);
+
+ Assert.IsNotNull(client);
+ }
+
+ [TestMethod]
+ public void IfApiKeyIsNotProvidedThenClientIsCreatedWithEmptyKey()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel);
+
+ Assert.IsNotNull(client);
+ }
+
+ [TestMethod]
+ public async Task IfEmptyMessageThenReturnsDefaultResponse()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel, TestApiKey);
+
+ var response = await client.GetChatResponseAsync("");
+
+ Assert.AreEqual("You say something?", response);
+ }
+
+ [TestMethod]
+ public async Task IfWhitespaceMessageThenReturnsDefaultResponse()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel, TestApiKey);
+
+ var response = await client.GetChatResponseAsync(" ");
+
+ Assert.AreEqual("You say something?", response);
+ }
+
+ [TestMethod]
+ public async Task IfNullMessageThenReturnsDefaultResponse()
+ {
+ var client = new SemanticKernelClient(TestApiUrl, TestModel, TestApiKey);
+
+ var response = await client.GetChatResponseAsync(null!);
+
+ Assert.AreEqual("You say something?", response);
+ }
+ }
+}
diff --git a/ClippyWeb/Program.cs b/ClippyWeb/Program.cs
index 71229ce..1083be3 100644
--- a/ClippyWeb/Program.cs
+++ b/ClippyWeb/Program.cs
@@ -96,9 +96,11 @@ private static async Task SetupLlmService(WebApplicationBuilder builder)
throw new InvalidOperationException("Please supply a config value for Model.");
}
+ string? apiKey = builder.Configuration["ApiKey"];
+
Log.Information("DarkClippy: Connecting to LLM service at: {ServiceUrl} with model: {Model}", serviceUrl, model);
- return new SemanticKernelHelper.SemanticKernelClient(serviceUrl, model);
+ return new SemanticKernelHelper.SemanticKernelClient(serviceUrl, model, apiKey);
});
}
diff --git a/ClippyWeb/appsettings.json b/ClippyWeb/appsettings.json
index 45ccf1c..a1c1f74 100644
--- a/ClippyWeb/appsettings.json
+++ b/ClippyWeb/appsettings.json
@@ -8,5 +8,6 @@
"AllowedHosts": "*",
"LogPath": "C:\\temp\\ClippyWeb\\Logs\\",
"Model": "HammerAI/neuraldaredevil-abliterated",
- "ServiceUrl": "http://localhost:11434/v1"
+ "ServiceUrl": "http://localhost:11434/v1",
+ "ApiKey": ""
}
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 5f00559..53cc5a6 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -7,11 +7,36 @@
namespace SemanticKernelHelper
{
- public class SemanticKernelClient(string apiUrl, string model) : IChatClient
+ public class SemanticKernelClient : IChatClient
{
- private readonly string _apiUrl = apiUrl;
- private readonly string _model = model;
+ private readonly string _apiUrl;
+ private readonly string _model;
+ private readonly string _apiKey;
private readonly ChatHistory _chatHistory = [];
+ private readonly Kernel _kernel;
+ private readonly IChatCompletionService _aiChatService;
+ private int _exchangeCount;
+ private const int MaxExchangesBeforeFatigue = 10;
+
+ public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
+ {
+ _apiUrl = apiUrl;
+ _model = model;
+ _apiKey = apiKey ?? string.Empty;
+ _exchangeCount = 0;
+
+ _kernel = Kernel.CreateBuilder()
+ .AddOpenAIChatCompletion(
+ modelId: _model,
+ endpoint: new Uri(_apiUrl),
+ apiKey: _apiKey)
+ .Build();
+
+ _aiChatService = _kernel.GetRequiredService();
+
+ string systemPrompt = "You are Dark Clippy, a profane talking paperclip. You give damn snarky, sarcastic, impatient, and insulting, short answers to questions. And you swear a lot.";
+ _chatHistory.Add(new ChatMessageContent(AuthorRole.System, systemPrompt));
+ }
///
/// Generates a chat response from the AI assistant based on the provided user message, maintaining conversational context.
@@ -26,25 +51,18 @@ public class SemanticKernelClient(string apiUrl, string model) : IChatClient
return "You say something?";
}
- // Create a kernel with OpenAI chat completion
- Kernel kernel = Kernel.CreateBuilder()
- .AddOpenAIChatCompletion(
- modelId: _model,
- endpoint: new Uri(_apiUrl),
- apiKey: "")
- .Build();
-
- IChatCompletionService aiChatService = kernel.GetRequiredService();
+ _exchangeCount++;
- string systemPrompt = "You are Dark Clippy, a profane talking paperclip. You give damn snarky, sarcastic, impatient, and insulting, short answers to questions. And you swear a lot.";
- _chatHistory.Add(new ChatMessageContent(AuthorRole.System, systemPrompt));
+ if (_exchangeCount > MaxExchangesBeforeFatigue)
+ {
+ return "Alright, I'm sick of talking about this shit. Go bother someone else.";
+ }
- // Consider persisting the chat history for more coherent conversations
_chatHistory.Add(new ChatMessageContent(AuthorRole.User, chatMessage));
var responseBuilder = new StringBuilder();
- await foreach (StreamingChatMessageContent item in aiChatService.GetStreamingChatMessageContentsAsync(_chatHistory))
+ await foreach (StreamingChatMessageContent item in _aiChatService.GetStreamingChatMessageContentsAsync(_chatHistory))
{
responseBuilder.Append(item.Content);
}
From f7e640eb71662ece56bc9cf9f82b9a849b0b8689 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 20 Jan 2026 01:47:03 +0000
Subject: [PATCH 03/11] Add constructor documentation with exception details
Co-authored-by: rrusson <653188+rrusson@users.noreply.github.com>
---
SemanticKernelHelper/SemanticKernelClient.cs | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 53cc5a6..116b932 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -18,6 +18,14 @@ public class SemanticKernelClient : IChatClient
private int _exchangeCount;
private const int MaxExchangesBeforeFatigue = 10;
+ ///
+ /// Initializes a new instance of the SemanticKernelClient with the specified configuration.
+ ///
+ /// The base URL of the OpenAI-compatible API endpoint. Must be a valid URL.
+ /// The identifier of the model to use for chat completion.
+ /// Optional API key for authentication. Defaults to empty string for local services like Ollama.
+ /// Thrown when apiUrl or model is null.
+ /// Thrown when apiUrl is not a valid URL format.
public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
{
_apiUrl = apiUrl;
From 3ee0b223bfdb4c954239daeba85e7f1f7b251211 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 24 Jan 2026 14:50:33 +0000
Subject: [PATCH 04/11] Remove unused private variables from constructor
Co-authored-by: rrusson <653188+rrusson@users.noreply.github.com>
---
SemanticKernelHelper/SemanticKernelClient.cs | 12 +++---------
1 file changed, 3 insertions(+), 9 deletions(-)
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 116b932..25e645b 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -9,9 +9,6 @@ namespace SemanticKernelHelper
{
public class SemanticKernelClient : IChatClient
{
- private readonly string _apiUrl;
- private readonly string _model;
- private readonly string _apiKey;
private readonly ChatHistory _chatHistory = [];
private readonly Kernel _kernel;
private readonly IChatCompletionService _aiChatService;
@@ -28,16 +25,13 @@ public class SemanticKernelClient : IChatClient
/// Thrown when apiUrl is not a valid URL format.
public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
{
- _apiUrl = apiUrl;
- _model = model;
- _apiKey = apiKey ?? string.Empty;
_exchangeCount = 0;
_kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion(
- modelId: _model,
- endpoint: new Uri(_apiUrl),
- apiKey: _apiKey)
+ modelId: model,
+ endpoint: new Uri(apiUrl),
+ apiKey: apiKey ?? string.Empty)
.Build();
_aiChatService = _kernel.GetRequiredService();
From a9f71a50a3c2fd398cca3d7a384499ecd8dd094d Mon Sep 17 00:00:00 2001
From: Ryan Russon
Date: Sat, 24 Jan 2026 10:19:36 -0500
Subject: [PATCH 05/11] Apply all unresolved suggestions from code review
@Copilot, please do your best to address all unresolved comments on this PR. See my replies and ask for guidance, if necessary.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
SemanticKernelHelper/SemanticKernelClient.cs | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 25e645b..296aef2 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -25,12 +25,28 @@ public class SemanticKernelClient : IChatClient
/// Thrown when apiUrl is not a valid URL format.
public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
{
+ if (apiUrl is null)
+ {
+ throw new ArgumentNullException(nameof(apiUrl));
+ }
+
+ if (model is null)
+ {
+ throw new ArgumentNullException(nameof(model));
+ }
+
+ if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri) ||
+ (apiUri.Scheme != Uri.UriSchemeHttp && apiUri.Scheme != Uri.UriSchemeHttps))
+ {
+ throw new UriFormatException($"The value of {nameof(apiUrl)} is not a valid HTTP or HTTPS URL.");
+ }
+
_exchangeCount = 0;
_kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion(
modelId: model,
- endpoint: new Uri(apiUrl),
+ endpoint: apiUri,
apiKey: apiKey ?? string.Empty)
.Build();
From 96b3609ef1c5c354645b965bf419069171f9e5a3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 24 Jan 2026 15:25:16 +0000
Subject: [PATCH 06/11] Isolate chat history by IP address using factory
pattern
Co-authored-by: rrusson <653188+rrusson@users.noreply.github.com>
---
ClippyWeb.Tests/ChatClientFactoryTests.cs | 80 +++++++++++++++++++
.../Controllers/ChatControllerTests.cs | 5 +-
ClippyWeb.Tests/SemanticKernelClientTests.cs | 28 +++++++
ClippyWeb/Controllers/ChatController.cs | 9 ++-
ClippyWeb/Program.cs | 6 +-
SemanticKernelHelper/ChatClientFactory.cs | 44 ++++++++++
SemanticKernelHelper/SemanticKernelClient.cs | 25 +++---
.../SemanticKernelHelper.csproj | 1 +
SharedInterfaces/IChatClientFactory.cs | 7 ++
9 files changed, 189 insertions(+), 16 deletions(-)
create mode 100644 ClippyWeb.Tests/ChatClientFactoryTests.cs
create mode 100644 SemanticKernelHelper/ChatClientFactory.cs
create mode 100644 SharedInterfaces/IChatClientFactory.cs
diff --git a/ClippyWeb.Tests/ChatClientFactoryTests.cs b/ClippyWeb.Tests/ChatClientFactoryTests.cs
new file mode 100644
index 0000000..7352df7
--- /dev/null
+++ b/ClippyWeb.Tests/ChatClientFactoryTests.cs
@@ -0,0 +1,80 @@
+using Microsoft.Extensions.Caching.Memory;
+
+using SemanticKernelHelper;
+
+using SharedInterfaces;
+
+namespace ClippyWeb.Tests
+{
+ [TestClass]
+ public class ChatClientFactoryTests
+ {
+ private const string TestApiUrl = "http://localhost:11434/v1";
+ private const string TestModel = "test-model";
+ private const string TestApiKey = "test-api-key";
+ private IMemoryCache _cache = null!;
+
+ [TestInitialize]
+ public void TestInitialize()
+ {
+ _cache = new MemoryCache(new MemoryCacheOptions());
+ }
+
+ [TestCleanup]
+ public void TestCleanup()
+ {
+ _cache?.Dispose();
+ }
+
+ [TestMethod]
+ public void IfSessionKeyIsProvidedThenClientIsReturned()
+ {
+ var factory = new ChatClientFactory(TestApiUrl, TestModel, TestApiKey, _cache);
+
+ var client = factory.GetOrCreateClient("test-session");
+
+ Assert.IsNotNull(client);
+ }
+
+ [TestMethod]
+ public void IfSameSessionKeyIsUsedThenSameClientIsReturned()
+ {
+ var factory = new ChatClientFactory(TestApiUrl, TestModel, TestApiKey, _cache);
+
+ var client1 = factory.GetOrCreateClient("test-session");
+ var client2 = factory.GetOrCreateClient("test-session");
+
+ Assert.AreSame(client1, client2);
+ }
+
+ [TestMethod]
+ public void IfDifferentSessionKeysAreUsedThenDifferentClientsAreReturned()
+ {
+ var factory = new ChatClientFactory(TestApiUrl, TestModel, TestApiKey, _cache);
+
+ var client1 = factory.GetOrCreateClient("session-1");
+ var client2 = factory.GetOrCreateClient("session-2");
+
+ Assert.AreNotSame(client1, client2);
+ }
+
+ [TestMethod]
+ public void IfMultipleSessionsAreConcurrentThenFactoryIsThreadSafe()
+ {
+ var factory = new ChatClientFactory(TestApiUrl, TestModel, TestApiKey, _cache);
+ var clients = new List();
+ var lockObj = new object();
+
+ Parallel.For(0, 10, i =>
+ {
+ var client = factory.GetOrCreateClient($"session-{i % 3}");
+ lock (lockObj)
+ {
+ clients.Add(client);
+ }
+ });
+
+ Assert.AreEqual(10, clients.Count);
+ }
+ }
+}
diff --git a/ClippyWeb.Tests/Controllers/ChatControllerTests.cs b/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
index 96e48c7..3569137 100644
--- a/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
+++ b/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
@@ -17,6 +17,7 @@ namespace ClippyWeb.Tests.Controllers
public class ChatControllerTests
{
private Mock _mockChatClient = null!;
+ private Mock _mockChatClientFactory = null!;
private IMemoryCache _memoryCache = null!;
private Mock _mockConfiguration = null!;
private ChatController _sut = null!;
@@ -25,10 +26,12 @@ public class ChatControllerTests
public void TestInitialize()
{
_mockChatClient = new Mock();
+ _mockChatClientFactory = new Mock();
+ _mockChatClientFactory.Setup(f => f.GetOrCreateClient(It.IsAny())).Returns(_mockChatClient.Object);
_memoryCache = new MemoryCache(new MemoryCacheOptions());
_mockConfiguration = new Mock();
- _sut = new ChatController(_mockChatClient.Object, _memoryCache, _mockConfiguration.Object);
+ _sut = new ChatController(_mockChatClientFactory.Object, _memoryCache, _mockConfiguration.Object);
SetupHttpContext();
}
diff --git a/ClippyWeb.Tests/SemanticKernelClientTests.cs b/ClippyWeb.Tests/SemanticKernelClientTests.cs
index 4b325b3..a9f2fac 100644
--- a/ClippyWeb.Tests/SemanticKernelClientTests.cs
+++ b/ClippyWeb.Tests/SemanticKernelClientTests.cs
@@ -65,5 +65,33 @@ public async Task IfNullMessageThenReturnsDefaultResponse()
Assert.AreEqual("You say something?", response);
}
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentNullException))]
+ public void IfApiUrlIsNullThenThrowsArgumentNullException()
+ {
+ _ = new SemanticKernelClient(null!, TestModel, TestApiKey);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(ArgumentNullException))]
+ public void IfModelIsNullThenThrowsArgumentNullException()
+ {
+ _ = new SemanticKernelClient(TestApiUrl, null!, TestApiKey);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(UriFormatException))]
+ public void IfApiUrlIsInvalidThenThrowsUriFormatException()
+ {
+ _ = new SemanticKernelClient("not-a-valid-url", TestModel, TestApiKey);
+ }
+
+ [TestMethod]
+ [ExpectedException(typeof(UriFormatException))]
+ public void IfApiUrlIsNotHttpOrHttpsThenThrowsUriFormatException()
+ {
+ _ = new SemanticKernelClient("ftp://localhost:11434", TestModel, TestApiKey);
+ }
}
}
diff --git a/ClippyWeb/Controllers/ChatController.cs b/ClippyWeb/Controllers/ChatController.cs
index 07a9d45..ab1d45b 100644
--- a/ClippyWeb/Controllers/ChatController.cs
+++ b/ClippyWeb/Controllers/ChatController.cs
@@ -16,14 +16,14 @@ namespace ClippyWeb.Controllers
public class ChatController : ControllerBase
{
private const string RequestInProgressKey = nameof(RequestInProgressKey);
- private readonly IChatClient _chatClient;
+ private readonly IChatClientFactory _chatClientFactory;
private readonly Markdown _markdownConverter = new();
private readonly IMemoryCache _cache;
private readonly IConfiguration _configuration;
- public ChatController(IChatClient chatClient, IMemoryCache cache, IConfiguration configuration)
+ public ChatController(IChatClientFactory chatClientFactory, IMemoryCache cache, IConfiguration configuration)
{
- _chatClient = chatClient;
+ _chatClientFactory = chatClientFactory;
_cache = cache;
_configuration = configuration;
Log.Information("ChatController initialized");
@@ -46,7 +46,8 @@ public async Task Post([FromBody] string question)
try
{
- var response = await _chatClient.GetChatResponseAsync(question);
+ IChatClient chatClient = _chatClientFactory.GetOrCreateClient(ipAddress);
+ var response = await chatClient.GetChatResponseAsync(question);
if (response == null)
{
diff --git a/ClippyWeb/Program.cs b/ClippyWeb/Program.cs
index 1083be3..77c527c 100644
--- a/ClippyWeb/Program.cs
+++ b/ClippyWeb/Program.cs
@@ -2,6 +2,7 @@
using ClippyWeb.Util;
+using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Serilog;
@@ -82,7 +83,7 @@ private static async Task SetupLlmService(WebApplicationBuilder builder)
var validator = scope.ServiceProvider.GetRequiredService();
await validator.ValidateConnectionAsync(builder.Configuration).ConfigureAwait(false);
- builder.Services.AddSingleton(provider =>
+ builder.Services.AddSingleton(provider =>
{
string? serviceUrl = builder.Configuration["ServiceUrl"];
if (string.IsNullOrEmpty(serviceUrl))
@@ -100,7 +101,8 @@ private static async Task SetupLlmService(WebApplicationBuilder builder)
Log.Information("DarkClippy: Connecting to LLM service at: {ServiceUrl} with model: {Model}", serviceUrl, model);
- return new SemanticKernelHelper.SemanticKernelClient(serviceUrl, model, apiKey);
+ var cache = provider.GetRequiredService();
+ return new SemanticKernelHelper.ChatClientFactory(serviceUrl, model, apiKey ?? string.Empty, cache);
});
}
diff --git a/SemanticKernelHelper/ChatClientFactory.cs b/SemanticKernelHelper/ChatClientFactory.cs
new file mode 100644
index 0000000..97814bc
--- /dev/null
+++ b/SemanticKernelHelper/ChatClientFactory.cs
@@ -0,0 +1,44 @@
+using Microsoft.Extensions.Caching.Memory;
+
+using SharedInterfaces;
+
+namespace SemanticKernelHelper
+{
+ public class ChatClientFactory : IChatClientFactory
+ {
+ private readonly string _serviceUrl;
+ private readonly string _model;
+ private readonly string _apiKey;
+ private readonly IMemoryCache _cache;
+ private readonly object _lock = new();
+
+ public ChatClientFactory(string serviceUrl, string model, string apiKey, IMemoryCache cache)
+ {
+ _serviceUrl = serviceUrl;
+ _model = model;
+ _apiKey = apiKey;
+ _cache = cache;
+ }
+
+ public IChatClient GetOrCreateClient(string sessionKey)
+ {
+ string cacheKey = $"ChatClient_{sessionKey}";
+
+ lock (_lock)
+ {
+ if (!_cache.TryGetValue(cacheKey, out IChatClient? client))
+ {
+ client = new SemanticKernelClient(_serviceUrl, _model, _apiKey);
+ var cacheOptions = new MemoryCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromMinutes(30),
+ Priority = CacheItemPriority.Normal
+ };
+ _cache.Set(cacheKey, client, cacheOptions);
+ }
+
+ return client!;
+ }
+ }
+ }
+}
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 296aef2..49ef768 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -13,6 +13,7 @@ public class SemanticKernelClient : IChatClient
private readonly Kernel _kernel;
private readonly IChatCompletionService _aiChatService;
private int _exchangeCount;
+ private readonly object _lock = new();
private const int MaxExchangesBeforeFatigue = 10;
///
@@ -61,7 +62,7 @@ public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
///
/// The user's message to send to Dark Clippy. Cannot be null or empty.
/// A string containing Dark Clippy's entire response to the user message
- /// The conversation history is preserved across calls to provide contextually relevant responses.
+ /// The conversation history is preserved across calls to provide contextually relevant responses. This method is thread-safe.
public async Task GetChatResponseAsync(string chatMessage)
{
if (string.IsNullOrWhiteSpace(chatMessage))
@@ -69,25 +70,31 @@ public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
return "You say something?";
}
- _exchangeCount++;
-
- if (_exchangeCount > MaxExchangesBeforeFatigue)
+ lock (_lock)
{
- return "Alright, I'm sick of talking about this shit. Go bother someone else.";
- }
+ _exchangeCount++;
+
+ if (_exchangeCount > MaxExchangesBeforeFatigue)
+ {
+ return "Alright, I'm sick of talking about this shit. Go bother someone else.";
+ }
- _chatHistory.Add(new ChatMessageContent(AuthorRole.User, chatMessage));
+ _chatHistory.Add(new ChatMessageContent(AuthorRole.User, chatMessage));
+ }
var responseBuilder = new StringBuilder();
- await foreach (StreamingChatMessageContent item in _aiChatService.GetStreamingChatMessageContentsAsync(_chatHistory))
+ await foreach (StreamingChatMessageContent item in _aiChatService.GetStreamingChatMessageContentsAsync(_chatHistory).ConfigureAwait(false))
{
responseBuilder.Append(item.Content);
}
string response = responseBuilder.ToString();
- _chatHistory.Add(new ChatMessageContent(AuthorRole.Assistant, response));
+ lock (_lock)
+ {
+ _chatHistory.Add(new ChatMessageContent(AuthorRole.Assistant, response));
+ }
return response;
}
diff --git a/SemanticKernelHelper/SemanticKernelHelper.csproj b/SemanticKernelHelper/SemanticKernelHelper.csproj
index 8ea0ffb..38f6014 100644
--- a/SemanticKernelHelper/SemanticKernelHelper.csproj
+++ b/SemanticKernelHelper/SemanticKernelHelper.csproj
@@ -8,6 +8,7 @@
+
diff --git a/SharedInterfaces/IChatClientFactory.cs b/SharedInterfaces/IChatClientFactory.cs
new file mode 100644
index 0000000..ed84d3a
--- /dev/null
+++ b/SharedInterfaces/IChatClientFactory.cs
@@ -0,0 +1,7 @@
+namespace SharedInterfaces
+{
+ public interface IChatClientFactory
+ {
+ IChatClient GetOrCreateClient(string sessionKey);
+ }
+}
From 4e3d57b142070c8c50db37698dbb4bfb582123ec Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 24 Jan 2026 15:27:42 +0000
Subject: [PATCH 07/11] Remove unnecessary null-forgiving operator with
justification
Co-authored-by: rrusson <653188+rrusson@users.noreply.github.com>
---
SemanticKernelHelper/ChatClientFactory.cs | 20 +++++++++++---------
1 file changed, 11 insertions(+), 9 deletions(-)
diff --git a/SemanticKernelHelper/ChatClientFactory.cs b/SemanticKernelHelper/ChatClientFactory.cs
index 97814bc..a85bdc0 100644
--- a/SemanticKernelHelper/ChatClientFactory.cs
+++ b/SemanticKernelHelper/ChatClientFactory.cs
@@ -26,18 +26,20 @@ public IChatClient GetOrCreateClient(string sessionKey)
lock (_lock)
{
- if (!_cache.TryGetValue(cacheKey, out IChatClient? client))
+ if (_cache.TryGetValue(cacheKey, out var client))
{
- client = new SemanticKernelClient(_serviceUrl, _model, _apiKey);
- var cacheOptions = new MemoryCacheEntryOptions
- {
- SlidingExpiration = TimeSpan.FromMinutes(30),
- Priority = CacheItemPriority.Normal
- };
- _cache.Set(cacheKey, client, cacheOptions);
+ // TryGetValue returns true only when client is not null
+ return client!;
}
- return client!;
+ var newClient = new SemanticKernelClient(_serviceUrl, _model, _apiKey);
+ var cacheOptions = new MemoryCacheEntryOptions
+ {
+ SlidingExpiration = TimeSpan.FromMinutes(30),
+ Priority = CacheItemPriority.Normal
+ };
+ _cache.Set(cacheKey, newClient, cacheOptions);
+ return newClient;
}
}
}
From aeaa00d08a66e7c40e4bd67f413074b30cb48f9e Mon Sep 17 00:00:00 2001
From: Ryan Russon
Date: Sat, 24 Jan 2026 12:23:37 -0500
Subject: [PATCH 08/11] Removed sonar-project.properties
---
sonar-project.properties | 7 -------
1 file changed, 7 deletions(-)
delete mode 100644 sonar-project.properties
diff --git a/sonar-project.properties b/sonar-project.properties
deleted file mode 100644
index 0786632..0000000
--- a/sonar-project.properties
+++ /dev/null
@@ -1,7 +0,0 @@
-# SonarQube project configuration for DarkClippy
-sonar.projectKey=DarkClippy
-sonar.projectName=DarkClippy
-sonar.projectVersion=1.0
-sonar.sources=.
-sonar.exclusions=ClippyWeb/wwwroot/js/clippy/src/**/*
-sonar.sourceEncoding=UTF-8
From b3d503146804f50bd0ec1144911392eea857ea6e Mon Sep 17 00:00:00 2001
From: Ryan Russon
Date: Sat, 24 Jan 2026 13:09:32 -0500
Subject: [PATCH 09/11] Added some unit tests
---
ClippyWeb.Tests/ClippyWeb.Tests.csproj | 5 -
ClippyWeb.Tests/Util/TcpClientFactoryTests.cs | 23 +
ClippyWeb.Tests/Util/TcpClientWrapperTests.cs | 439 ++++++++++++++++++
3 files changed, 462 insertions(+), 5 deletions(-)
create mode 100644 ClippyWeb.Tests/Util/TcpClientFactoryTests.cs
create mode 100644 ClippyWeb.Tests/Util/TcpClientWrapperTests.cs
diff --git a/ClippyWeb.Tests/ClippyWeb.Tests.csproj b/ClippyWeb.Tests/ClippyWeb.Tests.csproj
index 7adca2f..2b430a0 100644
--- a/ClippyWeb.Tests/ClippyWeb.Tests.csproj
+++ b/ClippyWeb.Tests/ClippyWeb.Tests.csproj
@@ -25,9 +25,4 @@
-
-
-
-
-
diff --git a/ClippyWeb.Tests/Util/TcpClientFactoryTests.cs b/ClippyWeb.Tests/Util/TcpClientFactoryTests.cs
new file mode 100644
index 0000000..326257c
--- /dev/null
+++ b/ClippyWeb.Tests/Util/TcpClientFactoryTests.cs
@@ -0,0 +1,23 @@
+namespace ClippyWeb.Util.UnitTests
+{
+ ///
+ /// Unit tests for the class.
+ ///
+ [TestClass]
+ public class TcpClientFactoryTests
+ {
+ ///
+ /// Tests that the Dispose method calls Dispose(bool) with true parameter.
+ ///
+ [TestMethod]
+ public void CreateWorks()
+ {
+ // Act
+ var result = new TcpClientFactory().Create();
+
+ // Assert
+ Assert.IsNotNull(result);
+ Assert.IsInstanceOfType(result, typeof(TcpClientWrapper));
+ }
+ }
+}
diff --git a/ClippyWeb.Tests/Util/TcpClientWrapperTests.cs b/ClippyWeb.Tests/Util/TcpClientWrapperTests.cs
new file mode 100644
index 0000000..e33fb2b
--- /dev/null
+++ b/ClippyWeb.Tests/Util/TcpClientWrapperTests.cs
@@ -0,0 +1,439 @@
+using System.Net.Sockets;
+
+namespace ClippyWeb.Util.UnitTests
+{
+ ///
+ /// Unit tests for the class.
+ ///
+ [TestClass]
+ public class TcpClientWrapperTests
+ {
+ ///
+ /// Tests that the Dispose method calls Dispose(bool) with true parameter.
+ ///
+ [TestMethod]
+ public void Dispose_WhenCalled_CallsDisposeWithTrue()
+ {
+ // Arrange
+ var disposeTracker = new DisposableTestWrapper();
+
+ // Act
+ disposeTracker.Dispose();
+
+ // Assert
+ Assert.IsTrue(disposeTracker.DisposeCalled, "Dispose(bool) should have been called");
+ Assert.IsTrue(disposeTracker.DisposingParameter, "Dispose(bool) should have been called with true");
+ }
+
+ ///
+ /// Tests that the Dispose method does not throw exceptions when called.
+ ///
+ [TestMethod]
+ public void Dispose_WhenCalled_DoesNotThrow()
+ {
+ // Arrange
+ var sut = new global::ClippyWeb.Util.TcpClientWrapper();
+
+ // Act & Assert
+ sut.Dispose();
+ }
+
+ ///
+ /// Tests that the Dispose method can be called multiple times without throwing exceptions (idempotency).
+ ///
+ [TestMethod]
+ public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange
+ var sut = new global::ClippyWeb.Util.TcpClientWrapper();
+
+ // Act & Assert
+ sut.Dispose();
+ sut.Dispose();
+ sut.Dispose();
+ }
+
+ ///
+ /// Helper class to track Dispose(bool) calls for testing purposes.
+ ///
+ private sealed class DisposableTestWrapper : global::ClippyWeb.Util.TcpClientWrapper
+ {
+ public bool DisposeCalled { get; private set; }
+ public bool DisposingParameter { get; private set; }
+
+ protected override void Dispose(bool disposing)
+ {
+ DisposeCalled = true;
+ DisposingParameter = disposing;
+ base.Dispose(disposing);
+ }
+ }
+
+ ///
+ /// Tests that the Connected property returns false when the TcpClient is not connected.
+ /// Input: A newly instantiated TcpClientWrapper with no active connection.
+ /// Expected: Connected property should return false.
+ ///
+ [TestMethod]
+ public void Connected_WhenNotConnected_ReturnsFalse()
+ {
+ // Arrange
+ using var wrapper = new TcpClientWrapper();
+
+ // Act
+ var result = wrapper.Connected;
+
+ // Assert
+ Assert.IsFalse(result);
+ }
+
+ ///
+ /// Tests that the Connected property can be accessed multiple times without throwing.
+ /// Input: A newly instantiated TcpClientWrapper accessed multiple times.
+ /// Expected: Connected property should consistently return false without exceptions.
+ ///
+ [TestMethod]
+ public void Connected_MultipleAccesses_ReturnsConsistentValue()
+ {
+ // Arrange
+ using var wrapper = new TcpClientWrapper();
+
+ // Act
+ var firstAccess = wrapper.Connected;
+ var secondAccess = wrapper.Connected;
+ var thirdAccess = wrapper.Connected;
+
+ // Assert
+ Assert.IsFalse(firstAccess);
+ Assert.IsFalse(secondAccess);
+ Assert.IsFalse(thirdAccess);
+ }
+
+ ///
+ /// Tests that the Connected property does not throw an exception when accessed.
+ /// Input: A newly instantiated TcpClientWrapper.
+ /// Expected: No exception should be thrown when accessing Connected property.
+ ///
+ [TestMethod]
+ public void Connected_WhenAccessed_DoesNotThrowException()
+ {
+ // Arrange
+ using var wrapper = new TcpClientWrapper();
+
+ // Act & Assert
+ try
+ {
+ var _ = wrapper.Connected;
+ Assert.IsTrue(true);
+ }
+ catch (Exception ex)
+ {
+ Assert.Fail($"Expected no exception, but got {ex.GetType().Name}: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Tests that the parameterless constructor creates an instance successfully without throwing exceptions.
+ /// Input: No parameters.
+ /// Expected: Instance is created and not null.
+ ///
+ [TestMethod]
+ public void TcpClientWrapper_Constructor_CreatesInstanceSuccessfully()
+ {
+ // Arrange & Act
+ var sut = new TcpClientWrapper();
+
+ // Assert
+ Assert.IsNotNull(sut);
+ }
+
+ ///
+ /// Tests that the constructor properly initializes the internal TcpClient instance.
+ /// This is verified indirectly through the Connected property.
+ /// Input: No parameters.
+ /// Expected: Connected property is accessible and returns false for a newly created, unconnected TcpClient.
+ ///
+ [TestMethod]
+ public void TcpClientWrapper_Constructor_InitializesInternalTcpClient()
+ {
+ // Arrange & Act
+ var sut = new TcpClientWrapper();
+
+ // Assert
+ Assert.IsFalse(sut.Connected);
+ }
+
+ ///
+ /// Tests that Dispose(bool) with disposing=true properly disposes the internal TcpClient.
+ /// Verifies that the internal TcpClient is disposed by checking the Connected property behavior.
+ ///
+ [TestMethod]
+ public void Dispose_DisposingTrue_DisposesInternalTcpClient()
+ {
+ // Arrange
+ var wrapper = new TestableTcpClientWrapper();
+
+ // Act
+ wrapper.PublicDispose(true);
+
+ // Assert
+ // After disposal, accessing Connected should either return false or throw ObjectDisposedException
+ // This verifies the internal TcpClient was actually disposed
+ try
+ {
+ var connected = wrapper.Connected;
+ // If no exception, verify it returns false after disposal
+ Assert.IsFalse(connected);
+ }
+ catch (ObjectDisposedException)
+ {
+ // This is also acceptable behavior after disposal
+ Assert.IsTrue(true);
+ }
+ }
+
+ ///
+ /// Tests that Dispose(bool) with disposing=false does not dispose the internal TcpClient.
+ /// Verifies that the method executes without throwing exceptions.
+ ///
+ [TestMethod]
+ public void Dispose_DisposingFalse_DoesNotDispose()
+ {
+ // Arrange
+ var wrapper = new TestableTcpClientWrapper();
+
+ // Act
+ wrapper.PublicDispose(false);
+
+ // Assert
+ // The method should complete without exceptions
+ // Connected property should still be accessible (though likely false since not connected)
+ var connected = wrapper.Connected;
+ Assert.IsFalse(connected);
+ }
+
+ ///
+ /// Tests that Dispose(bool) with disposing=true handles null TcpClient gracefully.
+ /// Verifies that calling Dispose multiple times does not throw exceptions.
+ ///
+ [TestMethod]
+ public void Dispose_CalledMultipleTimes_DoesNotThrow()
+ {
+ // Arrange
+ var wrapper = new TestableTcpClientWrapper();
+
+ // Act
+ wrapper.PublicDispose(true);
+ wrapper.PublicDispose(true);
+
+ // Assert
+ // Multiple dispose calls should not throw exceptions
+ Assert.IsTrue(true);
+ }
+
+ ///
+ /// Helper class to expose the protected Dispose(bool) method for testing
+ ///
+ private class TestableTcpClientWrapper : global::ClippyWeb.Util.TcpClientWrapper
+ {
+ ///
+ /// Exposes the protected Dispose(bool) method as public for testing
+ ///
+ /// True to dispose managed resources
+ public void PublicDispose(bool disposing)
+ {
+ Dispose(disposing);
+ }
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentNullException when host parameter is null.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_NullHost_ThrowsArgumentNullException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string? host = null;
+ int port = 80;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host!, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentOutOfRangeException when port is negative.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_NegativePort_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = -1;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentOutOfRangeException when port is zero.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_ZeroPort_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = 0;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentOutOfRangeException when port exceeds maximum valid value.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_PortAboveMaximum_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = 65536;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentOutOfRangeException when port is int.MaxValue.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_PortMaxValue_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = int.MaxValue;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentOutOfRangeException when port is int.MinValue.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_PortMinValue_ThrowsArgumentOutOfRangeException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = int.MinValue;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync respects cancellation when token is already cancelled.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_CancelledToken_ThrowsOperationCanceledException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = 80;
+ var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ // Act & Assert
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cts.Token);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync throws ArgumentNullException when host is empty string.
+ /// Note: The actual behavior depends on the underlying TcpClient implementation.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_EmptyHost_ThrowsArgumentException()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "";
+ int port = 80;
+ var cancellationToken = CancellationToken.None;
+
+ // Act & Assert
+ // Empty host should throw an exception from the underlying TcpClient
+ await Assert.ThrowsExceptionAsync(async () =>
+ {
+ await sut.ConnectAsync(host, port, cancellationToken);
+ });
+ }
+
+ ///
+ /// Tests that ConnectAsync with valid parameters initiates connection attempt.
+ /// Note: This test validates that the method can be called without immediate exceptions.
+ /// Actual connection success depends on network availability and is not tested here.
+ /// The method will likely throw SocketException if no service is listening.
+ ///
+ [TestMethod]
+ public async Task ConnectAsync_ValidParameters_InitiatesConnection()
+ {
+ // Arrange
+ var sut = new TcpClientWrapper();
+ string host = "localhost";
+ int port = 1; // Valid port number (though unlikely to have a service listening)
+ using var cts = new CancellationTokenSource(100); // Short timeout to prevent hanging
+
+ // Act & Assert
+ // This test verifies the method accepts valid parameters and attempts connection.
+ // It will likely throw SocketException due to no listener, which is expected behavior.
+ // We're primarily testing that parameter validation passes.
+ try
+ {
+ await sut.ConnectAsync(host, port, cts.Token);
+ }
+ catch (SocketException)
+ {
+ // Expected when no service is listening
+ Assert.IsTrue(true);
+ }
+ catch (OperationCanceledException)
+ {
+ // Expected if timeout occurs before connection fails
+ Assert.IsTrue(true);
+ }
+ }
+ }
+}
\ No newline at end of file
From 3c64c3bda2b27024cc4be9a569bfa934bd0137d2 Mon Sep 17 00:00:00 2001
From: Ryan Russon
Date: Sat, 24 Jan 2026 13:10:13 -0500
Subject: [PATCH 10/11] Clean-up
---
.../Controllers/ChatControllerTests.cs | 8 ++-
ClippyWeb.Tests/GlobalUsings.cs | 1 +
ClippyWeb.Tests/Pages/ErrorModelTests.cs | 9 ++-
ClippyWeb.Tests/SemanticKernelClientTests.cs | 3 -
.../Util/ConnectionValidatorTests.cs | 3 -
ClippyWeb/Controllers/ChatController.cs | 8 +--
ClippyWeb/Pages/About.cshtml.cs | 4 +-
ClippyWeb/Pages/Error.cshtml.cs | 5 +-
ClippyWeb/Pages/Index.cshtml.cs | 2 -
ClippyWeb/Pages/Privacy.cshtml.cs | 4 +-
ClippyWeb/Program.cs | 5 +-
ClippyWeb/Util/ConnectionValidator.cs | 5 +-
ClippyWeb/Util/TcpClientFactory.cs | 6 +-
ClippyWeb/Util/TcpClientWrapper.cs | 62 +++++++++----------
SemanticKernelHelper/SemanticKernelClient.cs | 22 +++----
SharedInterfaces/IConnectionValidator.cs | 22 +++----
SharedInterfaces/IPingService.cs | 14 ++---
SharedInterfaces/ITcpClient.cs | 38 ++++++------
SharedInterfaces/ITcpClientFactory.cs | 24 ++++---
TestConsole/Program.cs | 2 +-
20 files changed, 110 insertions(+), 137 deletions(-)
diff --git a/ClippyWeb.Tests/Controllers/ChatControllerTests.cs b/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
index 3569137..ab6c243 100644
--- a/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
+++ b/ClippyWeb.Tests/Controllers/ChatControllerTests.cs
@@ -1,11 +1,13 @@
+using System.Net;
+using System.Net.Sockets;
+
+using ClippyWeb.Controllers;
+
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
-using System.Net;
-using System.Net.Sockets;
-using ClippyWeb.Controllers;
using SharedInterfaces;
namespace ClippyWeb.Tests.Controllers
diff --git a/ClippyWeb.Tests/GlobalUsings.cs b/ClippyWeb.Tests/GlobalUsings.cs
index 0ab807c..5fc4745 100644
--- a/ClippyWeb.Tests/GlobalUsings.cs
+++ b/ClippyWeb.Tests/GlobalUsings.cs
@@ -1,2 +1,3 @@
global using Microsoft.VisualStudio.TestTools.UnitTesting;
+
global using Moq;
diff --git a/ClippyWeb.Tests/Pages/ErrorModelTests.cs b/ClippyWeb.Tests/Pages/ErrorModelTests.cs
index 8585d92..0ba4a12 100644
--- a/ClippyWeb.Tests/Pages/ErrorModelTests.cs
+++ b/ClippyWeb.Tests/Pages/ErrorModelTests.cs
@@ -1,12 +1,11 @@
-using Microsoft.AspNetCore.Http;
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
-using Microsoft.Extensions.Configuration;
-
using System.Diagnostics;
using ClippyWeb.Pages;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.Extensions.Configuration;
+
namespace ClippyWeb.Tests.Pages
{
///
diff --git a/ClippyWeb.Tests/SemanticKernelClientTests.cs b/ClippyWeb.Tests/SemanticKernelClientTests.cs
index a9f2fac..966b554 100644
--- a/ClippyWeb.Tests/SemanticKernelClientTests.cs
+++ b/ClippyWeb.Tests/SemanticKernelClientTests.cs
@@ -1,6 +1,3 @@
-using Microsoft.SemanticKernel;
-using Microsoft.SemanticKernel.ChatCompletion;
-
using SemanticKernelHelper;
namespace ClippyWeb.Tests
diff --git a/ClippyWeb.Tests/Util/ConnectionValidatorTests.cs b/ClippyWeb.Tests/Util/ConnectionValidatorTests.cs
index 71a654e..d55c38a 100644
--- a/ClippyWeb.Tests/Util/ConnectionValidatorTests.cs
+++ b/ClippyWeb.Tests/Util/ConnectionValidatorTests.cs
@@ -1,6 +1,3 @@
-using System.Net;
-using System.Net.Sockets;
-
using ClippyWeb.Util;
using Microsoft.Extensions.Configuration;
diff --git a/ClippyWeb/Controllers/ChatController.cs b/ClippyWeb/Controllers/ChatController.cs
index ab1d45b..811cffd 100644
--- a/ClippyWeb/Controllers/ChatController.cs
+++ b/ClippyWeb/Controllers/ChatController.cs
@@ -1,14 +1,14 @@
+using System.Net.Sockets;
+
using MarkdownSharp;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
-using SharedInterfaces;
-
-using System.Net.Sockets;
-
using Serilog;
+using SharedInterfaces;
+
namespace ClippyWeb.Controllers
{
[ApiController]
diff --git a/ClippyWeb/Pages/About.cshtml.cs b/ClippyWeb/Pages/About.cshtml.cs
index a90b9a0..72a8b8e 100644
--- a/ClippyWeb/Pages/About.cshtml.cs
+++ b/ClippyWeb/Pages/About.cshtml.cs
@@ -1,10 +1,8 @@
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.RazorPages;
namespace ClippyWeb.Pages
{
public class AboutModel : PageModel
{
- public AboutModel() { }
}
}
diff --git a/ClippyWeb/Pages/Error.cshtml.cs b/ClippyWeb/Pages/Error.cshtml.cs
index f906b55..0d7e629 100644
--- a/ClippyWeb/Pages/Error.cshtml.cs
+++ b/ClippyWeb/Pages/Error.cshtml.cs
@@ -11,10 +11,11 @@ namespace ClippyWeb.Pages
[IgnoreAntiforgeryToken]
public class ErrorModel : PageModel
{
+ private readonly IConfiguration _configuration;
+
public string? RequestId { get; set; }
- public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
- private readonly IConfiguration _configuration;
+ public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
public ErrorModel(IConfiguration configuration)
{
diff --git a/ClippyWeb/Pages/Index.cshtml.cs b/ClippyWeb/Pages/Index.cshtml.cs
index dcd9166..3f033e7 100644
--- a/ClippyWeb/Pages/Index.cshtml.cs
+++ b/ClippyWeb/Pages/Index.cshtml.cs
@@ -1,10 +1,8 @@
-using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace ClippyWeb.Pages
{
public class IndexModel : PageModel
{
- public IndexModel() { }
}
}
diff --git a/ClippyWeb/Pages/Privacy.cshtml.cs b/ClippyWeb/Pages/Privacy.cshtml.cs
index c661614..2967a00 100644
--- a/ClippyWeb/Pages/Privacy.cshtml.cs
+++ b/ClippyWeb/Pages/Privacy.cshtml.cs
@@ -1,10 +1,8 @@
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.AspNetCore.Mvc.RazorPages;
namespace ClippyWeb.Pages
{
public class PrivacyModel : PageModel
{
- public PrivacyModel() { }
}
}
diff --git a/ClippyWeb/Program.cs b/ClippyWeb/Program.cs
index 77c527c..8811c42 100644
--- a/ClippyWeb/Program.cs
+++ b/ClippyWeb/Program.cs
@@ -1,15 +1,15 @@
-using System.Net.NetworkInformation;
+using System.Diagnostics.CodeAnalysis;
using ClippyWeb.Util;
using Microsoft.Extensions.Caching.Memory;
-using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Events;
using SharedInterfaces;
+
namespace ClippyWeb
{
public static class Program
@@ -113,6 +113,7 @@ private static async Task SetupLlmService(WebApplicationBuilder builder)
/// Thrown if the logging directory path setting is missing from the configuration.
/// This method sets up Serilog to log to both the console and a rolling file in the specified directory.
/// The log file is rotated daily and limited in size and retention. Logging levels for Microsoft and ASP.NET Core components are set to warning or higher.
+ [ExcludeFromCodeCoverage]
private static void SetupLogging(ConfigurationManager configuration)
{
string logPath = configuration["LogPath"] ?? throw new System.Configuration.ConfigurationErrorsException("Logging directory path setting missing from appsettings.");
diff --git a/ClippyWeb/Util/ConnectionValidator.cs b/ClippyWeb/Util/ConnectionValidator.cs
index 58d1962..40996ff 100644
--- a/ClippyWeb/Util/ConnectionValidator.cs
+++ b/ClippyWeb/Util/ConnectionValidator.cs
@@ -1,8 +1,5 @@
-using System.Net.Sockets;
-
-using Microsoft.Extensions.Configuration;
-
using Serilog;
+
using SharedInterfaces; // Add this using directive to resolve ITcpClient and ITcpClientFactory
namespace ClippyWeb.Util
diff --git a/ClippyWeb/Util/TcpClientFactory.cs b/ClippyWeb/Util/TcpClientFactory.cs
index a977033..8bdc36c 100644
--- a/ClippyWeb/Util/TcpClientFactory.cs
+++ b/ClippyWeb/Util/TcpClientFactory.cs
@@ -1,5 +1,4 @@
using SharedInterfaces;
-using System.Net.Sockets;
namespace ClippyWeb.Util
{
@@ -7,9 +6,6 @@ namespace ClippyWeb.Util
public class TcpClientFactory : ITcpClientFactory
{
///
- public ITcpClient Create()
- {
- return new TcpClientWrapper();
- }
+ public ITcpClient Create() => new TcpClientWrapper();
}
}
diff --git a/ClippyWeb/Util/TcpClientWrapper.cs b/ClippyWeb/Util/TcpClientWrapper.cs
index d5bf7fb..ea9f4cf 100644
--- a/ClippyWeb/Util/TcpClientWrapper.cs
+++ b/ClippyWeb/Util/TcpClientWrapper.cs
@@ -4,43 +4,43 @@
namespace ClippyWeb.Util
{
- ///
- /// Wrapper for TcpClient to enable testability
- ///
- public class TcpClientWrapper : ITcpClient
- {
- private readonly TcpClient _tcpClient;
+ ///
+ /// Wrapper for TcpClient to enable testability
+ ///
+ public class TcpClientWrapper : ITcpClient
+ {
+ private readonly TcpClient _tcpClient;
- ///
- /// Initializes a new instance of the class
- ///
- public TcpClientWrapper()
- {
- _tcpClient = new TcpClient();
- }
+ ///
+ /// Initializes a new instance of the class
+ ///
+ public TcpClientWrapper()
+ {
+ _tcpClient = new TcpClient();
+ }
- ///
- public bool Connected => _tcpClient.Connected;
+ ///
+ public bool Connected => _tcpClient.Connected;
- ///
- public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken)
- {
- return _tcpClient.ConnectAsync(host, port, cancellationToken);
- }
+ ///
+ public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken)
+ {
+ return _tcpClient.ConnectAsync(host, port, cancellationToken);
+ }
- ///
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
}
- protected virtual void Dispose(bool disposing)
- {
- if (disposing)
- {
- _tcpClient?.Dispose();
- }
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _tcpClient?.Dispose();
+ }
}
}
}
diff --git a/SemanticKernelHelper/SemanticKernelClient.cs b/SemanticKernelHelper/SemanticKernelClient.cs
index 49ef768..2b1ebbf 100644
--- a/SemanticKernelHelper/SemanticKernelClient.cs
+++ b/SemanticKernelHelper/SemanticKernelClient.cs
@@ -1,16 +1,15 @@
-using Microsoft.SemanticKernel;
+using System.Text;
+
+using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using SharedInterfaces;
-using System.Text;
-
namespace SemanticKernelHelper
{
public class SemanticKernelClient : IChatClient
{
private readonly ChatHistory _chatHistory = [];
- private readonly Kernel _kernel;
private readonly IChatCompletionService _aiChatService;
private int _exchangeCount;
private readonly object _lock = new();
@@ -26,15 +25,8 @@ public class SemanticKernelClient : IChatClient
/// Thrown when apiUrl is not a valid URL format.
public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
{
- if (apiUrl is null)
- {
- throw new ArgumentNullException(nameof(apiUrl));
- }
-
- if (model is null)
- {
- throw new ArgumentNullException(nameof(model));
- }
+ ArgumentNullException.ThrowIfNull(apiUrl);
+ ArgumentNullException.ThrowIfNull(model);
if (!Uri.TryCreate(apiUrl, UriKind.Absolute, out var apiUri) ||
(apiUri.Scheme != Uri.UriSchemeHttp && apiUri.Scheme != Uri.UriSchemeHttps))
@@ -44,14 +36,14 @@ public SemanticKernelClient(string apiUrl, string model, string? apiKey = null)
_exchangeCount = 0;
- _kernel = Kernel.CreateBuilder()
+ Kernel kernel = Kernel.CreateBuilder()
.AddOpenAIChatCompletion(
modelId: model,
endpoint: apiUri,
apiKey: apiKey ?? string.Empty)
.Build();
- _aiChatService = _kernel.GetRequiredService();
+ _aiChatService = kernel.GetRequiredService();
string systemPrompt = "You are Dark Clippy, a profane talking paperclip. You give damn snarky, sarcastic, impatient, and insulting, short answers to questions. And you swear a lot.";
_chatHistory.Add(new ChatMessageContent(AuthorRole.System, systemPrompt));
diff --git a/SharedInterfaces/IConnectionValidator.cs b/SharedInterfaces/IConnectionValidator.cs
index da08538..f4aba30 100644
--- a/SharedInterfaces/IConnectionValidator.cs
+++ b/SharedInterfaces/IConnectionValidator.cs
@@ -2,15 +2,15 @@
namespace SharedInterfaces
{
- ///
- /// Validates application connection settings.
- ///
- public interface IConnectionValidator
- {
- ///
- /// Validates the application's connection settings using configuration.
- ///
- /// The configuration source containing connection settings.
- Task ValidateConnectionAsync(IConfiguration configuration);
- }
+ ///
+ /// Validates application connection settings.
+ ///
+ public interface IConnectionValidator
+ {
+ ///
+ /// Validates the application's connection settings using configuration.
+ ///
+ /// The configuration source containing connection settings.
+ Task ValidateConnectionAsync(IConfiguration configuration);
+ }
}
\ No newline at end of file
diff --git a/SharedInterfaces/IPingService.cs b/SharedInterfaces/IPingService.cs
index bf0a17a..792e816 100644
--- a/SharedInterfaces/IPingService.cs
+++ b/SharedInterfaces/IPingService.cs
@@ -1,10 +1,10 @@
namespace ClippyWeb.Util
{
- ///
- /// Abstraction for sending network pings.
- ///
- public interface IPingService
- {
- Task PingAsync(string host, int timeoutMs);
- }
+ ///
+ /// Abstraction for sending network pings.
+ ///
+ public interface IPingService
+ {
+ Task PingAsync(string host, int timeoutMs);
+ }
}
\ No newline at end of file
diff --git a/SharedInterfaces/ITcpClient.cs b/SharedInterfaces/ITcpClient.cs
index 57259a0..0005606 100644
--- a/SharedInterfaces/ITcpClient.cs
+++ b/SharedInterfaces/ITcpClient.cs
@@ -1,24 +1,22 @@
-using System.Net.Sockets;
-
namespace SharedInterfaces
{
- ///
- /// Abstraction for TcpClient to enable testability.
- ///
- public interface ITcpClient : IDisposable
- {
- ///
- /// Gets a value indicating whether the underlying Socket is connected to a remote host.
- ///
- bool Connected { get; }
+ ///
+ /// Abstraction for TcpClient to enable testability.
+ ///
+ public interface ITcpClient : IDisposable
+ {
+ ///
+ /// Gets a value indicating whether the underlying Socket is connected to a remote host.
+ ///
+ bool Connected { get; }
- ///
- /// Connects the client to a remote TCP host using the specified host name, port number, and cancellation token as an asynchronous operation.
- ///
- /// The DNS name of the remote host to which you intend to connect.
- /// The port number of the remote host to which you intend to connect.
- /// A cancellation token used to propagate notification that this operation should be canceled.
- /// A task that represents the asynchronous connection operation.
- ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken);
- }
+ ///
+ /// Connects the client to a remote TCP host using the specified host name, port number, and cancellation token as an asynchronous operation.
+ ///
+ /// The DNS name of the remote host to which you intend to connect.
+ /// The port number of the remote host to which you intend to connect.
+ /// A cancellation token used to propagate notification that this operation should be canceled.
+ /// A task that represents the asynchronous connection operation.
+ ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken);
+ }
}
diff --git a/SharedInterfaces/ITcpClientFactory.cs b/SharedInterfaces/ITcpClientFactory.cs
index 69b5dd4..2570136 100644
--- a/SharedInterfaces/ITcpClientFactory.cs
+++ b/SharedInterfaces/ITcpClientFactory.cs
@@ -1,16 +1,14 @@
-using System.Net.Sockets;
-
namespace SharedInterfaces
{
- ///
- /// Factory for creating TcpClient instances.
- ///
- public interface ITcpClientFactory
- {
- ///
- /// Creates a new instance of .
- ///
- /// A new instance of .
- ITcpClient Create();
- }
+ ///
+ /// Factory for creating TcpClient instances.
+ ///
+ public interface ITcpClientFactory
+ {
+ ///
+ /// Creates a new instance of .
+ ///
+ /// A new instance of .
+ ITcpClient Create();
+ }
}
\ No newline at end of file
diff --git a/TestConsole/Program.cs b/TestConsole/Program.cs
index b1dac64..bcae1ff 100644
--- a/TestConsole/Program.cs
+++ b/TestConsole/Program.cs
@@ -24,7 +24,7 @@ static void Main(string[] args)
throw new ConfigurationErrorsException("Please supply a config value for Model.");
}
- IChatClient semanticClient = new SemanticKernelHelper.SemanticKernelClient(serviceUrl, model);
+ IChatClient semanticClient = new SemanticKernelHelper.SemanticKernelClient(serviceUrl, model);
string? responseX = Task.Run(async () => await semanticClient.GetChatResponseAsync(question)).GetAwaiter().GetResult();
Console.WriteLine("Semantic Kernel sez:" + responseX + Environment.NewLine);
}
From 833e65d17fe6a1b1383379e99be25d1c652c2916 Mon Sep 17 00:00:00 2001
From: Ryan Russon
Date: Sat, 24 Jan 2026 13:24:32 -0500
Subject: [PATCH 11/11] Fix syntax for SONAR_TOKEN in sonarcloud.yml
YAML is dog shit that should be XML or JSON.
---
.github/workflows/sonarcloud.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index a010ee2..960a941 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -50,7 +50,7 @@ jobs:
/k:"rrusson_DarkClippy" \
/o:"rrusson" \
/d:sonar.host.url="https://sonarcloud.io" \
- /d:sonar.token="${{ SONAR_TOKEN }}" \
+ /d:sonar.token="${SONAR_TOKEN}" \
/d:sonar.cs.opencover.reportsPaths="**/TestResults/**/coverage.opencover.xml"
- name: Restore