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
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Abstraction.Users.Models;

namespace BotSharp.Abstraction.Conversations;

Expand Down Expand Up @@ -41,7 +40,7 @@ Task<bool> SendMessage(string agentId,
PostbackMessageModel? replyMessage,
Func<RoleDialogModel, Task> onResponseReceived);

Task<List<RoleDialogModel>> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable<string>? includeMessageTypes = null);
Task<List<RoleDialogModel>> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable<string>? includeMessageTypes = null, ConversationDialogFilter? filter = null);
Task CleanHistory(string agentId);

/// <summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
using BotSharp.Abstraction.Repositories.Filters;

namespace BotSharp.Abstraction.Conversations;

public interface IConversationStorage
{
Task Append(string conversationId, RoleDialogModel dialog);
Task Append(string conversationId, IEnumerable<RoleDialogModel> dialogs);
Task<List<RoleDialogModel>> GetDialogs(string conversationId);
Task<List<RoleDialogModel>> GetDialogs(string conversationId, ConversationDialogFilter? filter = null);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
namespace BotSharp.Abstraction.Conversations.Models;

public class ConversationFile
{
public string ConversationId { get; set; }
public string? Thumbnail { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Repositories.Filters;

public class ConversationDialogFilter
{
public string Order { get; set; } = "asc";
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace BotSharp.Abstraction.Repositories.Filters;

public class ConversationFileFilter
{
public IEnumerable<string> ConversationIds { get; set; } = [];
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class ConversationFilter
public List<string>? Tags { get; set; }

public bool IsLoadLatestStates { get; set; }
public bool IsLoadThumbnail { get; set; }

public static ConversationFilter Empty()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@
Task<List<User>> GetUsersByAffiliateId(string affiliateId) => throw new NotImplementedException();
Task<User?> GetUserByUserName(string userName) => throw new NotImplementedException();
Task UpdateUserName(string userId, string userName) => throw new NotImplementedException();
Task<Dashboard?> GetDashboard(string id = null) => throw new NotImplementedException();

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Cannot convert null literal to non-nullable reference type.

Check warning on line 51 in src/Infrastructure/BotSharp.Abstraction/Repositories/IBotSharpRepository.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

Cannot convert null literal to non-nullable reference type.
Task CreateUser(User user) => throw new NotImplementedException();
Task UpdateExistUser(string userId, User user) => throw new NotImplementedException();
Task UpdateUserVerified(string userId) => throw new NotImplementedException();
Expand Down Expand Up @@ -129,7 +129,7 @@
=> throw new NotImplementedException();
Task<bool> DeleteConversations(IEnumerable<string> conversationIds)
=> throw new NotImplementedException();
Task<List<DialogElement>> GetConversationDialogs(string conversationId)
Task<List<DialogElement>> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null)
=> throw new NotImplementedException();
Task AppendConversationDialogs(string conversationId, List<DialogElement> dialogs)
=> throw new NotImplementedException();
Expand Down Expand Up @@ -169,6 +169,12 @@
=> throw new NotImplementedException();
Task<bool> MigrateConvsersationLatestStates(string conversationId)
=> throw new NotImplementedException();
Task<List<ConversationFile>> GetConversationFiles(ConversationFileFilter filter)
=> throw new NotImplementedException();
Task<bool> SaveConversationFiles(List<ConversationFile> files)
=> throw new NotImplementedException();
Task<bool> DeleteConversationFiles(List<string> conversationIds)
=> throw new NotImplementedException();
#endregion

#region LLM Completion Log
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,13 @@ private static MethodInfo GetMethod(string name)
try
{
var sidecar = serviceProvider.GetService<IConversationSideCar>();
var argTypes = args.Select(x => x.GetType()).ToArray();
var argTypes = args.Select(x => x != null ? x.GetType() : null).ToArray();
var sidecarMethod = sidecar?.GetType()?.GetMethods(BindingFlags.Public | BindingFlags.Instance)
.FirstOrDefault(x => x.Name == methodName
&& x.ReturnType == retType
&& x.GetParameters().Length == argTypes.Length
&& x.GetParameters().Select(p => p.ParameterType)
.Zip(argTypes, (paramType, argType) => paramType.IsAssignableFrom(argType)).All(y => y));
.Zip(argTypes, (paramType, argType) => IsParameterTypeMatch(paramType, argType)).All(y => y));

return (sidecar, sidecarMethod);
}
Expand All @@ -78,6 +78,28 @@ private static MethodInfo GetMethod(string name)
}
}

private static bool IsParameterTypeMatch(Type paramType, Type? argType)
{
// If argument is null, check if parameter type is nullable
if (argType == null)
{
// Check if it's a nullable value type (e.g., int?)
if (paramType.IsGenericType && paramType.GetGenericTypeDefinition() == typeof(Nullable<>))
{
return true;
}
// Check if it's a reference type (which are inherently nullable)
if (!paramType.IsValueType)
{
return true;
}
return false;
}

// Normal type matching
return paramType.IsAssignableFrom(argType);
}

private async Task<(bool, object?)> CallAsyncMethod(IConversationSideCar instance, MethodInfo method, Type retType, object[] args)
{
object? value = null;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Abstraction.SideCar.Options;

namespace BotSharp.Abstraction.SideCar;
Expand All @@ -8,7 +9,7 @@ public interface IConversationSideCar
bool IsEnabled { get; }

Task AppendConversationDialogs(string conversationId, List<DialogElement> messages);
Task<List<DialogElement>> GetConversationDialogs(string conversationId);
Task<List<DialogElement>> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null);
Task UpdateConversationBreakpoint(string conversationId, ConversationBreakpoint breakpoint);
Task<ConversationBreakpoint?> GetConversationBreakpoint(string conversationId);
Task UpdateConversationStates(string conversationId, List<StateKeyValue> states);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ You may obtain a copy of the License at
limitations under the License.
******************************************************************************/

using BotSharp.Abstraction.SideCar.Options;
using BotSharp.Abstraction.Repositories.Filters;
using BotSharp.Core.Infrastructures;

namespace BotSharp.Core.SideCar.Services;
Expand Down Expand Up @@ -54,16 +54,20 @@ public async Task AppendConversationDialogs(string conversationId, List<DialogEl
await Task.CompletedTask;
}

public async Task<List<DialogElement>> GetConversationDialogs(string conversationId)
public async Task<List<DialogElement>> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null)
{
if (!IsValid(conversationId))
{
Comment on lines +57 to 60

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Sidecar ignores order filter 🐞 Bug ✓ Correctness

ConversationService slices dialogs differently for order="desc" vs "asc" and assumes the underlying
dialog list is already ordered accordingly. BotSharpConversationSideCar.GetConversationDialogs
ignores the filter and returns dialogs as-is, so order="desc" can return the wrong subset/order when
sidecar is enabled.
Agent Prompt
### Issue description
When `ConversationDialogFilter.Order == "desc"`, `ConversationService.GetDialogHistory` uses `Take(count)` which assumes dialogs are already sorted newest-first. The sidecar implementation (`BotSharpConversationSideCar.GetConversationDialogs`) ignores the filter and returns dialogs in insertion order, so descending requests can return the wrong subset/order when sidecar is enabled.

### Issue Context
Other repository implementations (FileRepository, MongoRepository) explicitly sort dialogs when `Order == "desc"`, so the sidecar path is now inconsistent.

### Fix Focus Areas
- src/Infrastructure/BotSharp.Core.SideCar/Services/BotSharpConversationSideCar.cs[57-67]
- src/Infrastructure/BotSharp.Core/Conversations/Services/ConversationService.cs[157-194]

### Suggested fix
- In `BotSharpConversationSideCar.GetConversationDialogs`, if `filter?.Order == "desc"`, return `Dialogs.OrderByDescending(d => d.MetaData?.CreatedTime).ToList()`.
- Optionally harden `ConversationService.GetDialogHistory` by sorting based on `CreatedAt` when a filter is provided, so all storage backends (including sidecar) behave correctly.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

return new List<DialogElement>();
return [];
}

await Task.CompletedTask;
var dialogs = _contextStack.Peek().Dialogs ?? [];
if (filter?.Order == "desc")
{
dialogs = dialogs.OrderByDescending(x => x.MetaData?.CreatedTime).ToList();
}

return _contextStack.Peek().Dialogs;
return await Task.FromResult(dialogs);
}

public async Task UpdateConversationBreakpoint(string conversationId, ConversationBreakpoint breakpoint)
Expand All @@ -87,9 +91,7 @@ public async Task UpdateConversationBreakpoint(string conversationId, Conversati
}

var top = _contextStack.Peek().Breakpoints;

await Task.CompletedTask;
return top.LastOrDefault();
return await Task.FromResult(top.LastOrDefault());
}

public async Task UpdateConversationStates(string conversationId, List<StateKeyValue> states)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,14 +154,14 @@ public Task CleanHistory(string agentId)
throw new NotImplementedException();
}

public async Task<List<RoleDialogModel>> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable<string>? includeMessageTypes = null)
public async Task<List<RoleDialogModel>> GetDialogHistory(int lastCount = 100, bool fromBreakpoint = true, IEnumerable<string>? includeMessageTypes = null, ConversationDialogFilter? filter = null)
{
if (string.IsNullOrEmpty(_conversationId))
{
throw new ArgumentNullException("ConversationId is null.");
}

var dialogs = await _storage.GetDialogs(_conversationId);
var dialogs = await _storage.GetDialogs(_conversationId, filter);

if (!includeMessageTypes.IsNullOrEmpty())
{
Expand Down Expand Up @@ -190,7 +190,7 @@ public async Task<List<RoleDialogModel>> GetDialogHistory(int lastCount = 100, b
var agentMsgCount = await GetAgentMessageCount();
var count = agentMsgCount.HasValue && agentMsgCount.Value > 0 ? agentMsgCount.Value : lastCount;

return dialogs.TakeLast(count).ToList();
return filter?.Order == "desc" ? dialogs.Take(count).ToList() : dialogs.TakeLast(count).ToList();
}

public async Task SetConversationId(string conversationId, List<MessageState> states, bool isReadOnly = false)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,10 @@ public async Task Append(string conversationId, IEnumerable<RoleDialogModel> dia
await db.AppendConversationDialogs(conversationId, dialogElements);
}

public async Task<List<RoleDialogModel>> GetDialogs(string conversationId)
public async Task<List<RoleDialogModel>> GetDialogs(string conversationId, ConversationDialogFilter? filter = null)
{
var db = _services.GetRequiredService<IBotSharpRepository>();
var dialogs = await db.GetConversationDialogs(conversationId);
var dialogs = await db.GetConversationDialogs(conversationId, filter);
var hooks = _services.GetServices<IConversationHook>();

var results = new List<RoleDialogModel>();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using BotSharp.Abstraction.Loggers.Models;
using System.IO;
using System.Threading;

namespace BotSharp.Core.Repository;

Expand Down Expand Up @@ -75,7 +74,7 @@ public Task<bool> DeleteConversations(IEnumerable<string> conversationIds)
}

[SideCar]
public async Task<List<DialogElement>> GetConversationDialogs(string conversationId)
public async Task<List<DialogElement>> GetConversationDialogs(string conversationId, ConversationDialogFilter? filter = null)
{
var dialogs = new List<DialogElement>();
var convDir = FindConversationDirectory(conversationId);
Expand All @@ -93,11 +92,16 @@ public async Task<List<DialogElement>> GetConversationDialogs(string conversatio
var texts = await File.ReadAllTextAsync(dialogDir);
try
{
dialogs = JsonSerializer.Deserialize<List<DialogElement>>(texts, _options) ?? new List<DialogElement>();
dialogs = JsonSerializer.Deserialize<List<DialogElement>>(texts, _options) ?? [];
}
catch
{
dialogs = new List<DialogElement>();
dialogs = [];
}
Comment on lines 94 to +100

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. getconversationdialogs swallows deserialize errors 📘 Rule violation ✧ Quality

JSON deserialization failures are caught without logging, which hides corrupt/invalid dialog data
and makes production debugging difficult. This violates the requirement to avoid swallowed
exceptions without logging or actionable context.
Agent Prompt
## Issue description
`GetConversationDialogs` catches JSON deserialization exceptions without logging, resulting in silent data corruption/format issues being undetectable.

## Issue Context
The code reads and deserializes a dialogs JSON file; failures currently degrade to `[]` with no telemetry.

## Fix Focus Areas
- src/Infrastructure/BotSharp.Core/Repository/FileRepository/FileRepository.Conversation.cs[93-105]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


if (filter?.Order == "desc")
{
dialogs = dialogs.OrderByDescending(x => x.MetaData?.CreatedTime).ToList();
}
}
finally
Expand Down Expand Up @@ -884,6 +888,139 @@ public async Task<bool> MigrateConvsersationLatestStates(string conversationId)
}


#region Files
public async Task<List<ConversationFile>> GetConversationFiles(ConversationFileFilter filter)
{
if (filter == null || filter.ConversationIds.IsNullOrEmpty())
{
return [];
}

var files = new List<ConversationFile>();
var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir);

if (!Directory.Exists(baseDir))
{
return files;
}

foreach (var conversationId in filter.ConversationIds)
{
if (string.IsNullOrEmpty(conversationId))
{
continue;
}

var convDir = Path.Combine(baseDir, conversationId);
if (!Directory.Exists(convDir))
{
continue;
}

var filesFile = Path.Combine(convDir, CONV_FILES_FILE);
if (!File.Exists(filesFile))
{
continue;
}

try
{
var json = await File.ReadAllTextAsync(filesFile);
var conversationFile = JsonSerializer.Deserialize<ConversationFile>(json, _options);
if (conversationFile != null)
{
files.Add(conversationFile);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, $"Error when reading conversation files for conversation {conversationId}.");
}
}

return files;
}

public async Task<bool> SaveConversationFiles(List<ConversationFile> files)
{
if (files.IsNullOrEmpty())
{
return false;
}

try
{
var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir);

foreach (var file in files)
{
if (string.IsNullOrEmpty(file.ConversationId))
{
continue;
}

var convDir = Path.Combine(baseDir, file.ConversationId);
if (!Directory.Exists(convDir))
{
Directory.CreateDirectory(convDir);
}

var convFile = Path.Combine(convDir, CONV_FILES_FILE);
var json = JsonSerializer.Serialize(file, _options);
await File.WriteAllTextAsync(convFile, json);
}

return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error when saving conversation files.");
return false;
}
}

public async Task<bool> DeleteConversationFiles(List<string> conversationIds)
{
if (conversationIds.IsNullOrEmpty())
{
return false;
}

try
{
var baseDir = Path.Combine(_dbSettings.FileRepository, _conversationSettings.DataDir);

foreach (var conversationId in conversationIds)
{
if (string.IsNullOrEmpty(conversationId))
{
continue;
}

var convDir = Path.Combine(baseDir, conversationId);
if (!Directory.Exists(convDir))
{
continue;
}

var filesFile = Path.Combine(convDir, CONV_FILES_FILE);
if (File.Exists(filesFile))
{
File.Delete(filesFile);
}
}

return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error when deleting conversation files.");
return false;
}
}
#endregion


#region Private methods
private string? FindConversationDirectory(string conversationId)
{
Expand Down
Loading
Loading