forked from admin/VolcanicAgenticCore
feat(core): models and sessions slices
This commit is contained in:
@@ -1 +0,0 @@
|
||||
namespace Volcanic.AgenticCore; internal static class AssemblyMarker { }
|
||||
94
src/Volcanic.AgenticCore/Features/Models/ApiKeyResolver.cs
Normal file
94
src/Volcanic.AgenticCore/Features/Models/ApiKeyResolver.cs
Normal file
@@ -0,0 +1,94 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves API keys in this order: the provider registration's spec (literal, <c>$ENV</c>, <c>${ENV}</c>, <c>!command</c>),
|
||||
/// then <c>auth.json</c> (<c>{ "provider": { "apiKey": "..." } }</c>), then the <c><PROVIDER>_API_KEY</c> environment variable.
|
||||
/// </summary>
|
||||
public sealed partial class ApiKeyResolver
|
||||
{
|
||||
private readonly string? _authFilePath;
|
||||
private readonly Func<string, string?> _getEnv;
|
||||
private readonly Dictionary<string, string> _commandCache = new();
|
||||
|
||||
public ApiKeyResolver(string? authFilePath, Func<string, string?>? getEnv = null)
|
||||
{
|
||||
_authFilePath = authFilePath;
|
||||
_getEnv = getEnv ?? Environment.GetEnvironmentVariable;
|
||||
}
|
||||
|
||||
public async Task<string?> ResolveAsync(string providerId, string? spec, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(spec))
|
||||
{
|
||||
var fromSpec = await ResolveSpecAsync(spec, cancellationToken).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(fromSpec)) return fromSpec;
|
||||
}
|
||||
|
||||
var fromFile = ReadAuthFile(providerId);
|
||||
if (!string.IsNullOrEmpty(fromFile)) return fromFile;
|
||||
|
||||
var envName = EnvVarName(providerId);
|
||||
var fromEnv = _getEnv(envName);
|
||||
return string.IsNullOrEmpty(fromEnv) ? null : fromEnv;
|
||||
}
|
||||
|
||||
public static string EnvVarName(string providerId) =>
|
||||
NonAlnum().Replace(providerId, "_").ToUpperInvariant() + "_API_KEY";
|
||||
|
||||
private async Task<string?> ResolveSpecAsync(string spec, CancellationToken cancellationToken)
|
||||
{
|
||||
if (spec.StartsWith("$$", StringComparison.Ordinal)) return spec[1..];
|
||||
if (spec.StartsWith("$!", StringComparison.Ordinal)) return spec[1..];
|
||||
if (spec.StartsWith("${", StringComparison.Ordinal) && spec.EndsWith('}')) return _getEnv(spec[2..^1]);
|
||||
if (spec.StartsWith('$')) return _getEnv(spec[1..]);
|
||||
if (spec.StartsWith('!'))
|
||||
{
|
||||
var command = spec[1..].Trim();
|
||||
lock (_commandCache)
|
||||
{
|
||||
if (_commandCache.TryGetValue(command, out var cached)) return cached;
|
||||
}
|
||||
var output = await RunCommandAsync(command, cancellationToken).ConfigureAwait(false);
|
||||
lock (_commandCache) _commandCache[command] = output;
|
||||
return output;
|
||||
}
|
||||
return spec;
|
||||
}
|
||||
|
||||
private string? ReadAuthFile(string providerId)
|
||||
{
|
||||
if (_authFilePath is null || !File.Exists(_authFilePath)) return null;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(File.ReadAllText(_authFilePath));
|
||||
if (!doc.RootElement.TryGetProperty(providerId, out var entry)) return null;
|
||||
if (entry.ValueKind == JsonValueKind.String) return entry.GetString();
|
||||
if (entry.TryGetProperty("apiKey", out var key) && key.ValueKind == JsonValueKind.String) return key.GetString();
|
||||
if (entry.TryGetProperty("env", out var env) && env.ValueKind == JsonValueKind.String) return _getEnv(env.GetString()!);
|
||||
return null;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<string> RunCommandAsync(string command, CancellationToken cancellationToken)
|
||||
{
|
||||
var (file, args) = OperatingSystem.IsWindows() ? ("cmd.exe", $"/c {command}") : ("/bin/sh", $"-c \"{command.Replace("\"", "\\\"")}\"");
|
||||
var psi = new ProcessStartInfo(file, args) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
|
||||
using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Could not start '{command}'.");
|
||||
var stdout = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
|
||||
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (process.ExitCode != 0) throw new InvalidOperationException($"API key command '{command}' exited with {process.ExitCode}.");
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
[GeneratedRegex("[^A-Za-z0-9]+")]
|
||||
private static partial Regex NonAlnum();
|
||||
}
|
||||
197
src/Volcanic.AgenticCore/Features/Models/ModelRegistry.cs
Normal file
197
src/Volcanic.AgenticCore/Features/Models/ModelRegistry.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
/// <summary>Holds provider registrations, resolves models by <c>provider/id</c> and dispatches streaming with auth injected.</summary>
|
||||
public sealed class ModelRegistry : IModelRegistry
|
||||
{
|
||||
private readonly Dictionary<string, ProviderRegistration> _providers = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Lock _lock = new();
|
||||
private readonly ApiKeyResolver _apiKeys;
|
||||
|
||||
public ModelRegistry(ApiKeyResolver apiKeys)
|
||||
{
|
||||
_apiKeys = apiKeys;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProviderRegistration> Providers
|
||||
{
|
||||
get { lock (_lock) return _providers.Values.OrderBy(p => p.Id, StringComparer.Ordinal).ToList(); }
|
||||
}
|
||||
|
||||
public void Register(ProviderRegistration provider)
|
||||
{
|
||||
var models = provider.Models
|
||||
.Select(m => m with { Provider = provider.Id, Api = string.IsNullOrEmpty(m.Api) ? provider.Api ?? "" : m.Api, BaseUrl = m.BaseUrl ?? provider.BaseUrl })
|
||||
.ToList();
|
||||
lock (_lock) _providers[provider.Id] = provider with { Models = models };
|
||||
}
|
||||
|
||||
public bool Unregister(string providerId)
|
||||
{
|
||||
lock (_lock) return _providers.Remove(providerId);
|
||||
}
|
||||
|
||||
public IReadOnlyList<Model> ListModels()
|
||||
{
|
||||
lock (_lock)
|
||||
return _providers.Values
|
||||
.OrderBy(p => p.Id, StringComparer.Ordinal)
|
||||
.SelectMany(p => p.Models.OrderBy(m => m.Id, StringComparer.Ordinal))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public Model? Find(string key)
|
||||
{
|
||||
var slash = key.IndexOf('/');
|
||||
if (slash <= 0) return null;
|
||||
return Find(key[..slash], key[(slash + 1)..]);
|
||||
}
|
||||
|
||||
public Model? Find(string provider, string modelId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_providers.TryGetValue(provider, out var registration)) return null;
|
||||
return registration.Models.FirstOrDefault(m => string.Equals(m.Id, modelId, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
|
||||
public ProviderRegistration? GetProvider(string providerId)
|
||||
{
|
||||
lock (_lock) return _providers.GetValueOrDefault(providerId);
|
||||
}
|
||||
|
||||
public bool IsAvailable(Model model)
|
||||
{
|
||||
var provider = GetProvider(model.Provider);
|
||||
if (provider is null) return false;
|
||||
if (!provider.RequiresApiKey) return true;
|
||||
return ResolveApiKeyAsync(model.Provider).GetAwaiter().GetResult() is not null;
|
||||
}
|
||||
|
||||
public Task<string?> ResolveApiKeyAsync(string providerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var provider = GetProvider(providerId);
|
||||
return _apiKeys.ResolveAsync(providerId, provider?.ApiKey, cancellationToken);
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<AssistantMessageEvent> StreamAsync(Model model, LlmContext context, StreamOptions options, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
var provider = GetProvider(model.Provider);
|
||||
if (provider is null)
|
||||
{
|
||||
yield return new AssistantMessageBuilder(model).Fail($"Provider '{model.Provider}' is not registered.");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var effectiveOptions = options;
|
||||
if (options.ApiKey is null && provider.RequiresApiKey)
|
||||
{
|
||||
string? key = null;
|
||||
string? failure = null;
|
||||
try
|
||||
{
|
||||
key = await _apiKeys.ResolveAsync(provider.Id, provider.ApiKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failure = $"Could not resolve API key for '{provider.Id}': {ex.Message}";
|
||||
}
|
||||
failure ??= key is null
|
||||
? $"No API key for provider '{provider.Id}'. Set {ApiKeyResolver.EnvVarName(provider.Id)} or add it to auth.json."
|
||||
: null;
|
||||
if (failure is not null)
|
||||
{
|
||||
yield return new AssistantMessageBuilder(model).Fail(failure);
|
||||
yield break;
|
||||
}
|
||||
effectiveOptions = options with { ApiKey = key };
|
||||
}
|
||||
|
||||
var headers = MergeHeaders(provider.Headers, model.Headers, options.Headers);
|
||||
if (headers is not null) effectiveOptions = effectiveOptions with { Headers = headers };
|
||||
|
||||
var normalized = context with { Messages = TranscriptNormalizer.Normalize(context.Messages, model) };
|
||||
|
||||
IAsyncEnumerator<AssistantMessageEvent>? enumerator = null;
|
||||
string? startFailure = null;
|
||||
var startAborted = false;
|
||||
try
|
||||
{
|
||||
enumerator = provider.Stream(model, normalized, effectiveOptions, cancellationToken).GetAsyncEnumerator(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
startFailure = $"Provider '{provider.Id}' failed to start: {ex.Message}";
|
||||
startAborted = ex is OperationCanceledException;
|
||||
}
|
||||
if (enumerator is null)
|
||||
{
|
||||
yield return new AssistantMessageBuilder(model).Fail(startFailure ?? "Provider returned no stream.", aborted: startAborted);
|
||||
yield break;
|
||||
}
|
||||
|
||||
await using (enumerator.ConfigureAwait(false))
|
||||
{
|
||||
var terminal = false;
|
||||
while (true)
|
||||
{
|
||||
AssistantMessageEvent? next = null;
|
||||
string? streamFailure = null;
|
||||
var streamAborted = false;
|
||||
var ended = false;
|
||||
try
|
||||
{
|
||||
if (await enumerator.MoveNextAsync().ConfigureAwait(false)) next = enumerator.Current;
|
||||
else ended = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
streamFailure = $"Provider '{provider.Id}' stream failed: {ex.Message}";
|
||||
streamAborted = ex is OperationCanceledException || cancellationToken.IsCancellationRequested;
|
||||
}
|
||||
if (streamFailure is not null)
|
||||
{
|
||||
yield return new AssistantMessageBuilder(model).Fail(streamFailure, aborted: streamAborted);
|
||||
yield break;
|
||||
}
|
||||
if (ended || next is null) break;
|
||||
yield return next;
|
||||
if (next.IsTerminal) { terminal = true; break; }
|
||||
}
|
||||
if (!terminal)
|
||||
yield return new AssistantMessageBuilder(model).Fail($"Provider '{provider.Id}' stream ended before a terminal event.", aborted: cancellationToken.IsCancellationRequested);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<AssistantMessage> CompleteAsync(Model model, LlmContext context, StreamOptions options, CancellationToken cancellationToken = default)
|
||||
{
|
||||
AssistantMessage? final = null;
|
||||
await foreach (var e in StreamAsync(model, context, options, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
final = e switch
|
||||
{
|
||||
AssistantMessageEvent.Done d => d.Message,
|
||||
AssistantMessageEvent.Error err => err.Message,
|
||||
_ => final,
|
||||
};
|
||||
}
|
||||
return final ?? new AssistantMessageBuilder(model).Fail("No terminal event.").Partial;
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string?>? MergeHeaders(
|
||||
IReadOnlyDictionary<string, string>? providerHeaders,
|
||||
IReadOnlyDictionary<string, string>? modelHeaders,
|
||||
IReadOnlyDictionary<string, string?>? requestHeaders)
|
||||
{
|
||||
if (providerHeaders is null && modelHeaders is null && requestHeaders is null) return null;
|
||||
var merged = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
if (providerHeaders is not null) foreach (var (k, v) in providerHeaders) merged[k] = v;
|
||||
if (modelHeaders is not null) foreach (var (k, v) in modelHeaders) merged[k] = v;
|
||||
if (requestHeaders is not null) foreach (var (k, v) in requestHeaders) merged[k] = v;
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
33
src/Volcanic.AgenticCore/Features/Models/ThinkingLevels.cs
Normal file
33
src/Volcanic.AgenticCore/Features/Models/ThinkingLevels.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
public static class ThinkingLevels
|
||||
{
|
||||
private static readonly ThinkingLevel[] Order =
|
||||
[ThinkingLevel.Off, ThinkingLevel.Minimal, ThinkingLevel.Low, ThinkingLevel.Medium, ThinkingLevel.High, ThinkingLevel.XHigh, ThinkingLevel.Max];
|
||||
|
||||
public static IReadOnlyList<ThinkingLevel> Supported(Model model)
|
||||
{
|
||||
if (!model.Reasoning) return [ThinkingLevel.Off];
|
||||
if (model.ThinkingLevelMap is null)
|
||||
return [ThinkingLevel.Off, ThinkingLevel.Minimal, ThinkingLevel.Low, ThinkingLevel.Medium, ThinkingLevel.High];
|
||||
var list = new List<ThinkingLevel> { ThinkingLevel.Off };
|
||||
foreach (var level in Order.Skip(1))
|
||||
{
|
||||
if (model.ThinkingLevelMap.TryGetValue(level, out var mapped) && mapped is not null) list.Add(level);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>Nearest supported level, preferring higher.</summary>
|
||||
public static ThinkingLevel Clamp(Model model, ThinkingLevel requested)
|
||||
{
|
||||
var supported = Supported(model);
|
||||
if (supported.Contains(requested)) return requested;
|
||||
var index = Array.IndexOf(Order, requested);
|
||||
for (var i = index + 1; i < Order.Length; i++) if (supported.Contains(Order[i])) return Order[i];
|
||||
for (var i = index - 1; i >= 0; i--) if (supported.Contains(Order[i])) return Order[i];
|
||||
return ThinkingLevel.Off;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites the transcript before a provider call so it is valid for the target model:
|
||||
/// drops failed assistant messages, fills in missing tool results, converts foreign thinking to text,
|
||||
/// replaces images for text-only models and turns custom messages into user messages.
|
||||
/// </summary>
|
||||
public static class TranscriptNormalizer
|
||||
{
|
||||
public static List<Message> Normalize(IEnumerable<Message> messages, Model model)
|
||||
{
|
||||
var result = new List<Message>();
|
||||
foreach (var message in messages)
|
||||
{
|
||||
switch (message)
|
||||
{
|
||||
case AssistantMessage { IsFailure: true }:
|
||||
continue;
|
||||
case AssistantMessage assistant:
|
||||
result.Add(NormalizeAssistant(assistant, model));
|
||||
break;
|
||||
case CustomMessage custom:
|
||||
result.Add(new UserMessage { Content = NormalizeImages(custom.Content, model), Timestamp = custom.Timestamp });
|
||||
break;
|
||||
case UserMessage user:
|
||||
result.Add(user with { Content = NormalizeImages(user.Content, model) });
|
||||
break;
|
||||
case ToolResultMessage toolResult:
|
||||
result.Add(toolResult with { Content = NormalizeImages(toolResult.Content, model) });
|
||||
break;
|
||||
default:
|
||||
result.Add(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return InsertMissingToolResults(result);
|
||||
}
|
||||
|
||||
private static AssistantMessage NormalizeAssistant(AssistantMessage assistant, Model model)
|
||||
{
|
||||
var sameModel = assistant.Provider == model.Provider && assistant.Api == model.Api && assistant.Model == model.Id;
|
||||
if (sameModel) return assistant;
|
||||
|
||||
var content = new List<ContentBlock>();
|
||||
foreach (var block in assistant.Content)
|
||||
{
|
||||
if (block is ThinkingContent thinking)
|
||||
{
|
||||
if (thinking.Redacted || string.IsNullOrWhiteSpace(thinking.Thinking)) continue;
|
||||
content.Add(new TextContent(thinking.Thinking));
|
||||
}
|
||||
else content.Add(block);
|
||||
}
|
||||
return assistant with { Content = content };
|
||||
}
|
||||
|
||||
private static IReadOnlyList<ContentBlock> NormalizeImages(IReadOnlyList<ContentBlock> content, Model model)
|
||||
{
|
||||
if (model.SupportsImages || !content.OfType<ImageContent>().Any()) return content;
|
||||
return content.Select(b => b is ImageContent ? new TextContent("[image omitted: model does not accept images]") : b).ToList();
|
||||
}
|
||||
|
||||
private static List<Message> InsertMissingToolResults(List<Message> messages)
|
||||
{
|
||||
var result = new List<Message>(messages.Count);
|
||||
for (var i = 0; i < messages.Count; i++)
|
||||
{
|
||||
var message = messages[i];
|
||||
result.Add(message);
|
||||
if (message is not AssistantMessage { ToolCalls.Count: > 0 } assistant) continue;
|
||||
|
||||
var answered = new HashSet<string>();
|
||||
var j = i + 1;
|
||||
while (j < messages.Count && messages[j] is ToolResultMessage r)
|
||||
{
|
||||
answered.Add(r.ToolCallId);
|
||||
result.Add(r);
|
||||
j++;
|
||||
}
|
||||
foreach (var call in assistant.ToolCalls.Where(c => !answered.Contains(c.Id)))
|
||||
{
|
||||
result.Add(new ToolResultMessage
|
||||
{
|
||||
ToolCallId = call.Id,
|
||||
ToolName = call.Name,
|
||||
Content = [new TextContent("No result provided")],
|
||||
IsError = true,
|
||||
Timestamp = assistant.Timestamp,
|
||||
});
|
||||
}
|
||||
i = j - 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
164
src/Volcanic.AgenticCore/Features/Sessions/FileSessionStore.cs
Normal file
164
src/Volcanic.AgenticCore/Features/Sessions/FileSessionStore.cs
Normal file
@@ -0,0 +1,164 @@
|
||||
using System.Text;
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// JSONL files: <c><root>/<scope>/<sessionId>.jsonl</c>. The first line is the <see cref="SessionHeader"/>,
|
||||
/// every following line one <see cref="SessionEntry"/>. Writes are serialised per session id.
|
||||
/// </summary>
|
||||
public sealed class FileSessionStore : ISessionStore
|
||||
{
|
||||
private readonly string _root;
|
||||
private readonly Dictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
|
||||
private readonly Lock _locksLock = new();
|
||||
|
||||
public FileSessionStore(string root)
|
||||
{
|
||||
_root = root;
|
||||
}
|
||||
|
||||
public string Root => _root;
|
||||
|
||||
/// <summary>pi-style scope from a working directory: separators and colons become dashes.</summary>
|
||||
public static string ScopeFor(string cwd) =>
|
||||
"--" + cwd.Replace('/', '-').Replace('\\', '-').Replace(':', '-').Trim('-') + "--";
|
||||
|
||||
public string PathFor(string scope, string sessionId) => Path.Combine(_root, SafeName(scope), SafeName(sessionId) + ".jsonl");
|
||||
|
||||
public async Task CreateAsync(string scope, SessionHeader header, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = PathFor(scope, header.SessionId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
var gate = GetLock(path);
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await File.WriteAllTextAsync(path, VolcanicJson.Serialize(header) + "\n", new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task AppendAsync(string scope, string sessionId, SessionEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = PathFor(scope, sessionId);
|
||||
var gate = GetLock(path);
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await File.AppendAllTextAsync(path, VolcanicJson.Serialize(entry) + "\n", new UTF8Encoding(false), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally { gate.Release(); }
|
||||
}
|
||||
|
||||
public async Task<SessionData?> LoadAsync(string scope, string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var path = PathFor(scope, sessionId);
|
||||
if (!File.Exists(path)) return null;
|
||||
var lines = await File.ReadAllLinesAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
return Parse(lines);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionSummary>> ListAsync(string scope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var dir = Path.Combine(_root, SafeName(scope));
|
||||
if (!Directory.Exists(dir)) return [];
|
||||
var summaries = new List<SessionSummary>();
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.jsonl"))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
SessionData? data;
|
||||
try { data = Parse(await File.ReadAllLinesAsync(file, cancellationToken).ConfigureAwait(false)); }
|
||||
catch (Exception) { continue; }
|
||||
if (data is null) continue;
|
||||
var messages = data.Entries.OfType<MessageEntry>().Select(e => e.Message).ToList();
|
||||
summaries.Add(new SessionSummary(
|
||||
data.Header.SessionId,
|
||||
data.Entries.OfType<SessionInfoEntry>().LastOrDefault()?.Name,
|
||||
data.Header.Timestamp,
|
||||
data.Entries.Count > 0 ? data.Entries[^1].Timestamp : data.Header.Timestamp,
|
||||
messages.Count,
|
||||
messages.OfType<UserMessage>().FirstOrDefault()?.Text));
|
||||
}
|
||||
return summaries.OrderByDescending(s => s.Modified).ToList();
|
||||
}
|
||||
|
||||
private static SessionData? Parse(string[] lines)
|
||||
{
|
||||
if (lines.Length == 0 || string.IsNullOrWhiteSpace(lines[0])) return null;
|
||||
var header = VolcanicJson.Deserialize<SessionHeader>(lines[0]);
|
||||
if (header is null) return null;
|
||||
var entries = new List<SessionEntry>();
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line)) continue;
|
||||
var entry = VolcanicJson.Deserialize<SessionEntry>(line);
|
||||
if (entry is not null) entries.Add(entry);
|
||||
}
|
||||
return new SessionData(header, entries);
|
||||
}
|
||||
|
||||
private SemaphoreSlim GetLock(string path)
|
||||
{
|
||||
lock (_locksLock)
|
||||
{
|
||||
if (!_locks.TryGetValue(path, out var gate)) _locks[path] = gate = new SemaphoreSlim(1, 1);
|
||||
return gate;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeName(string value)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
var sb = new StringBuilder(value.Length);
|
||||
foreach (var c in value) sb.Append(invalid.Contains(c) ? '-' : c);
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Non-persistent store for tests and throwaway sessions.</summary>
|
||||
public sealed class InMemorySessionStore : ISessionStore
|
||||
{
|
||||
private readonly Dictionary<(string, string), (SessionHeader Header, List<SessionEntry> Entries)> _sessions = new();
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
public Task CreateAsync(string scope, SessionHeader header, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock) _sessions[(scope, header.SessionId)] = (header, []);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task AppendAsync(string scope, string sessionId, SessionEntry entry, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_sessions.TryGetValue((scope, sessionId), out var s)) throw new KeyNotFoundException($"Session '{sessionId}' not found.");
|
||||
s.Entries.Add(entry);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<SessionData?> LoadAsync(string scope, string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
return Task.FromResult(_sessions.TryGetValue((scope, sessionId), out var s) ? new SessionData(s.Header, s.Entries.ToList()) : null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<SessionSummary>> ListAsync(string scope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
IReadOnlyList<SessionSummary> list = _sessions.Where(kv => kv.Key.Item1 == scope)
|
||||
.Select(kv => new SessionSummary(kv.Value.Header.SessionId,
|
||||
kv.Value.Entries.OfType<SessionInfoEntry>().LastOrDefault()?.Name,
|
||||
kv.Value.Header.Timestamp,
|
||||
kv.Value.Entries.Count > 0 ? kv.Value.Entries[^1].Timestamp : kv.Value.Header.Timestamp,
|
||||
kv.Value.Entries.OfType<MessageEntry>().Count(),
|
||||
kv.Value.Entries.OfType<MessageEntry>().Select(e => e.Message).OfType<UserMessage>().FirstOrDefault()?.Text))
|
||||
.OrderByDescending(s => s.Modified).ToList();
|
||||
return Task.FromResult(list);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Sessions;
|
||||
|
||||
/// <summary>Turns a branch of session entries into the message list a model should see.</summary>
|
||||
public static class SessionContextBuilder
|
||||
{
|
||||
public const string SummaryPrefix = "The conversation history before this point was compacted into the following summary:";
|
||||
|
||||
public static IReadOnlyList<Message> Build(IReadOnlyList<SessionEntry> branch)
|
||||
{
|
||||
var compactionIndex = -1;
|
||||
for (var i = branch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (branch[i] is CompactionEntry) { compactionIndex = i; break; }
|
||||
}
|
||||
|
||||
var messages = new List<Message>();
|
||||
if (compactionIndex < 0)
|
||||
{
|
||||
messages.AddRange(branch.OfType<MessageEntry>().Select(e => e.Message));
|
||||
return messages;
|
||||
}
|
||||
|
||||
var compaction = (CompactionEntry)branch[compactionIndex];
|
||||
messages.Add(new UserMessage
|
||||
{
|
||||
Content = [new TextContent($"{SummaryPrefix}\n\n<summary>\n{compaction.Summary}\n</summary>")],
|
||||
Timestamp = compaction.Timestamp,
|
||||
});
|
||||
|
||||
// Entries from FirstKeptEntryId up to (excluding) the compaction stay verbatim.
|
||||
if (compaction.FirstKeptEntryId is not null)
|
||||
{
|
||||
var keeping = false;
|
||||
for (var i = 0; i < compactionIndex; i++)
|
||||
{
|
||||
if (branch[i].Id == compaction.FirstKeptEntryId) keeping = true;
|
||||
if (keeping && branch[i] is MessageEntry m) messages.Add(m.Message);
|
||||
}
|
||||
}
|
||||
|
||||
for (var i = compactionIndex + 1; i < branch.Count; i++)
|
||||
{
|
||||
if (branch[i] is MessageEntry m) messages.Add(m.Message);
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
148
src/Volcanic.AgenticCore/Features/Sessions/SessionManager.cs
Normal file
148
src/Volcanic.AgenticCore/Features/Sessions/SessionManager.cs
Normal file
@@ -0,0 +1,148 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Features.Sessions;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory view of one session's entry tree plus write-through to an <see cref="ISessionStore"/>.
|
||||
/// Appends always chain to the current leaf; <see cref="Branch"/> moves the leaf so the next append starts a branch.
|
||||
/// </summary>
|
||||
public sealed class SessionManager : IReadOnlySessionManager
|
||||
{
|
||||
private readonly ISessionStore _store;
|
||||
private readonly List<SessionEntry> _entries = [];
|
||||
private readonly Dictionary<string, SessionEntry> _byId = new(StringComparer.Ordinal);
|
||||
private readonly Lock _lock = new();
|
||||
|
||||
private SessionManager(ISessionStore store, SessionHeader header, string scope)
|
||||
{
|
||||
_store = store;
|
||||
Header = header;
|
||||
Scope = scope;
|
||||
}
|
||||
|
||||
public SessionHeader Header { get; }
|
||||
public string SessionId => Header.SessionId;
|
||||
public string Scope { get; }
|
||||
public string Cwd => Header.Cwd;
|
||||
public string? Name { get; private set; }
|
||||
public string? LeafId { get; private set; }
|
||||
|
||||
public static async Task<SessionManager> CreateAsync(ISessionStore store, string scope, string cwd, string? parentSessionId = null, string? sessionId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var header = new SessionHeader { SessionId = sessionId ?? Guid.NewGuid().ToString("N"), Cwd = cwd, ParentSessionId = parentSessionId };
|
||||
await store.CreateAsync(scope, header, cancellationToken).ConfigureAwait(false);
|
||||
return new SessionManager(store, header, scope);
|
||||
}
|
||||
|
||||
public static async Task<SessionManager?> OpenAsync(ISessionStore store, string scope, string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var data = await store.LoadAsync(scope, sessionId, cancellationToken).ConfigureAwait(false);
|
||||
if (data is null) return null;
|
||||
var manager = new SessionManager(store, data.Header, scope);
|
||||
foreach (var entry in data.Entries) manager.Track(entry);
|
||||
return manager;
|
||||
}
|
||||
|
||||
public static string NewEntryId() => Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
public IReadOnlyList<SessionEntry> GetEntries()
|
||||
{
|
||||
lock (_lock) return _entries.ToList();
|
||||
}
|
||||
|
||||
public SessionEntry? GetEntry(string id)
|
||||
{
|
||||
lock (_lock) return _byId.GetValueOrDefault(id);
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionEntry> GetBranch()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var branch = new List<SessionEntry>();
|
||||
var id = LeafId;
|
||||
while (id is not null && _byId.TryGetValue(id, out var entry))
|
||||
{
|
||||
branch.Add(entry);
|
||||
id = entry.ParentId;
|
||||
}
|
||||
branch.Reverse();
|
||||
return branch;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Move the leaf to an earlier entry; subsequent appends form a new branch.</summary>
|
||||
public void Branch(string? entryId)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (entryId is not null && !_byId.ContainsKey(entryId)) throw new KeyNotFoundException($"Entry '{entryId}' not found.");
|
||||
LeafId = entryId;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<MessageEntry> AppendMessageAsync(Message message, CancellationToken ct = default) =>
|
||||
AppendAsync(new MessageEntry { Id = NewEntryId(), Message = message }, ct);
|
||||
|
||||
public Task<ModelChangeEntry> AppendModelChangeAsync(Model model, CancellationToken ct = default) =>
|
||||
AppendAsync(new ModelChangeEntry { Id = NewEntryId(), Provider = model.Provider, ModelId = model.Id }, ct);
|
||||
|
||||
public Task<ThinkingLevelChangeEntry> AppendThinkingLevelChangeAsync(ThinkingLevel level, CancellationToken ct = default) =>
|
||||
AppendAsync(new ThinkingLevelChangeEntry { Id = NewEntryId(), ThinkingLevel = level }, ct);
|
||||
|
||||
public Task<CompactionEntry> AppendCompactionAsync(string summary, string? firstKeptEntryId, int tokensBefore, System.Text.Json.JsonElement? details, Usage? usage, CancellationToken ct = default) =>
|
||||
AppendAsync(new CompactionEntry { Id = NewEntryId(), Summary = summary, FirstKeptEntryId = firstKeptEntryId, TokensBefore = tokensBefore, Details = details, Usage = usage }, ct);
|
||||
|
||||
public Task<CustomEntry> AppendCustomAsync(string customType, System.Text.Json.JsonElement? data, CancellationToken ct = default) =>
|
||||
AppendAsync(new CustomEntry { Id = NewEntryId(), CustomType = customType, Data = data }, ct);
|
||||
|
||||
public Task<LabelEntry> AppendLabelAsync(string targetId, string? label, CancellationToken ct = default) =>
|
||||
AppendAsync(new LabelEntry { Id = NewEntryId(), TargetId = targetId, Label = label }, ct);
|
||||
|
||||
public Task<SessionInfoEntry> AppendSessionInfoAsync(string? name, CancellationToken ct = default) =>
|
||||
AppendAsync(new SessionInfoEntry { Id = NewEntryId(), Name = name }, ct);
|
||||
|
||||
public async Task<TEntry> AppendAsync<TEntry>(TEntry entry, CancellationToken cancellationToken = default) where TEntry : SessionEntry
|
||||
{
|
||||
TEntry linked;
|
||||
lock (_lock)
|
||||
{
|
||||
linked = entry with { ParentId = LeafId };
|
||||
Track(linked);
|
||||
}
|
||||
await _store.AppendAsync(Scope, SessionId, linked, cancellationToken).ConfigureAwait(false);
|
||||
return linked;
|
||||
}
|
||||
|
||||
private void Track(SessionEntry entry)
|
||||
{
|
||||
_entries.Add(entry);
|
||||
_byId[entry.Id] = entry;
|
||||
LeafId = entry.Id;
|
||||
if (entry is SessionInfoEntry info) Name = info.Name;
|
||||
}
|
||||
|
||||
/// <summary>Current model and thinking level as recorded on the branch (the latest change entries).</summary>
|
||||
public (string? Provider, string? ModelId, ThinkingLevel? ThinkingLevel) GetBranchSettings()
|
||||
{
|
||||
string? provider = null, modelId = null;
|
||||
ThinkingLevel? level = null;
|
||||
foreach (var entry in GetBranch())
|
||||
{
|
||||
if (entry is ModelChangeEntry m) { provider = m.Provider; modelId = m.ModelId; }
|
||||
else if (entry is ThinkingLevelChangeEntry t) level = t.ThinkingLevel;
|
||||
}
|
||||
return (provider, modelId, level);
|
||||
}
|
||||
|
||||
/// <summary>Messages on the current branch, honouring the latest compaction.</summary>
|
||||
public IReadOnlyList<Message> BuildContext() => SessionContextBuilder.Build(GetBranch());
|
||||
|
||||
/// <summary>All messages on the current branch, ignoring compaction (for display).</summary>
|
||||
public IReadOnlyList<Message> GetMessages() =>
|
||||
GetBranch().OfType<MessageEntry>().Select(e => e.Message).ToList();
|
||||
|
||||
public CompactionEntry? LatestCompaction() => GetBranch().OfType<CompactionEntry>().LastOrDefault();
|
||||
}
|
||||
196
tests/Volcanic.AgenticCore.Tests/Features/ModelsTests.cs
Normal file
196
tests/Volcanic.AgenticCore.Tests/Features/ModelsTests.cs
Normal file
@@ -0,0 +1,196 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Json;
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Features.Models;
|
||||
|
||||
namespace Volcanic.AgenticCore.Tests.Features;
|
||||
|
||||
public class ModelRegistryTests
|
||||
{
|
||||
private static ProviderRegistration Provider(string id, params string[] models) => new()
|
||||
{
|
||||
Id = id,
|
||||
Api = id + "-api",
|
||||
Models = models.Select(m => new Model { Id = m, Name = m, Provider = "", Api = "" }).ToList(),
|
||||
Stream = (model, ctx, opts, ct) => Empty(),
|
||||
RequiresApiKey = false,
|
||||
};
|
||||
|
||||
private static async IAsyncEnumerable<AssistantMessageEvent> Empty() { await Task.CompletedTask; yield break; }
|
||||
|
||||
[Fact]
|
||||
public void Register_find_and_unregister()
|
||||
{
|
||||
var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null));
|
||||
registry.Register(Provider("zeta", "m2", "m1"));
|
||||
registry.Register(Provider("alpha", "x"));
|
||||
|
||||
var model = registry.Find("zeta/m1");
|
||||
Assert.NotNull(model);
|
||||
Assert.Equal("zeta", model!.Provider);
|
||||
Assert.Equal("zeta-api", model.Api);
|
||||
Assert.Equal(["alpha/x", "zeta/m1", "zeta/m2"], registry.ListModels().Select(m => m.Key));
|
||||
Assert.Null(registry.Find("nope/x"));
|
||||
Assert.Null(registry.Find("no-slash"));
|
||||
|
||||
Assert.True(registry.Unregister("zeta"));
|
||||
Assert.Null(registry.Find("zeta/m1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stream_without_api_key_yields_error_event()
|
||||
{
|
||||
var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null));
|
||||
registry.Register(Provider("needs-key", "m") with { RequiresApiKey = true });
|
||||
var model = registry.Find("needs-key/m")!;
|
||||
Assert.False(registry.IsAvailable(model));
|
||||
|
||||
var message = await registry.CompleteAsync(model, LlmContext.Simple(null, "hi"), StreamOptions.Default);
|
||||
Assert.Equal(StopReason.Error, message.StopReason);
|
||||
Assert.Contains("NEEDS_KEY_API_KEY", message.ErrorMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Stream_that_ends_without_terminal_event_is_converted_to_error()
|
||||
{
|
||||
var registry = new ModelRegistry(new ApiKeyResolver(null, _ => null));
|
||||
registry.Register(Provider("p", "m"));
|
||||
var message = await registry.CompleteAsync(registry.Find("p/m")!, LlmContext.Simple(null, "hi"), StreamOptions.Default);
|
||||
Assert.Equal(StopReason.Error, message.StopReason);
|
||||
Assert.Contains("terminal", message.ErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
public class ApiKeyResolverTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("literal-key", "literal-key")]
|
||||
[InlineData("$MY_KEY", "from-env")]
|
||||
[InlineData("${MY_KEY}", "from-env")]
|
||||
[InlineData("$$literal", "$literal")]
|
||||
public async Task Spec_forms_resolve(string spec, string expected)
|
||||
{
|
||||
var resolver = new ApiKeyResolver(null, name => name == "MY_KEY" ? "from-env" : null);
|
||||
Assert.Equal(expected, await resolver.ResolveAsync("p", spec));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Command_spec_runs_once_and_caches()
|
||||
{
|
||||
if (OperatingSystem.IsWindows()) return;
|
||||
var resolver = new ApiKeyResolver(null, _ => null);
|
||||
Assert.Equal("secret", await resolver.ResolveAsync("p", "!echo secret"));
|
||||
Assert.Equal("secret", await resolver.ResolveAsync("p", "!echo secret"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Auth_file_then_convention_env_var()
|
||||
{
|
||||
var file = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N") + ".json");
|
||||
await File.WriteAllTextAsync(file, """{"anthropic":{"apiKey":"file-key"},"openai":{"env":"OPENAI_KEY_VAR"}}""");
|
||||
try
|
||||
{
|
||||
var resolver = new ApiKeyResolver(file, name => name switch
|
||||
{
|
||||
"OPENAI_KEY_VAR" => "env-indirect",
|
||||
"GROQ_API_KEY" => "groq-env",
|
||||
"MY_PROVIDER_API_KEY" => "dashed",
|
||||
_ => null,
|
||||
});
|
||||
Assert.Equal("file-key", await resolver.ResolveAsync("anthropic", null));
|
||||
Assert.Equal("env-indirect", await resolver.ResolveAsync("openai", null));
|
||||
Assert.Equal("groq-env", await resolver.ResolveAsync("groq", null));
|
||||
Assert.Equal("dashed", await resolver.ResolveAsync("my-provider", null));
|
||||
Assert.Null(await resolver.ResolveAsync("unknown", null));
|
||||
}
|
||||
finally { File.Delete(file); }
|
||||
}
|
||||
}
|
||||
|
||||
public class TranscriptNormalizerTests
|
||||
{
|
||||
private static readonly Model Target = new() { Id = "m", Name = "m", Provider = "p", Api = "a" };
|
||||
private static readonly Model VisionTarget = Target with { Input = [ModelInput.Text, ModelInput.Image] };
|
||||
|
||||
[Fact]
|
||||
public void Failed_assistant_messages_are_dropped()
|
||||
{
|
||||
var messages = new List<Message>
|
||||
{
|
||||
UserMessage.FromText("hi"),
|
||||
new AssistantMessage { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Error, ErrorMessage = "x" },
|
||||
new AssistantMessage { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Aborted },
|
||||
new AssistantMessage { Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Stop, Content = [new TextContent("ok")] },
|
||||
};
|
||||
var result = TranscriptNormalizer.Normalize(messages, Target);
|
||||
Assert.Equal(2, result.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Missing_tool_results_are_synthesised()
|
||||
{
|
||||
var messages = new List<Message>
|
||||
{
|
||||
UserMessage.FromText("hi"),
|
||||
new AssistantMessage
|
||||
{
|
||||
Provider = "p", Api = "a", Model = "m", StopReason = StopReason.ToolUse,
|
||||
Content = [new ToolCallContent("c1", "bash", VolcanicJson.EmptyObject), new ToolCallContent("c2", "bash", VolcanicJson.EmptyObject)],
|
||||
},
|
||||
new ToolResultMessage { ToolCallId = "c2", ToolName = "bash", Content = [new TextContent("done")] },
|
||||
UserMessage.FromText("next"),
|
||||
};
|
||||
var result = TranscriptNormalizer.Normalize(messages, Target);
|
||||
Assert.Equal(5, result.Count);
|
||||
var synthetic = Assert.IsType<ToolResultMessage>(result[3]);
|
||||
Assert.Equal("c1", synthetic.ToolCallId);
|
||||
Assert.True(synthetic.IsError);
|
||||
Assert.IsType<UserMessage>(result[4]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Foreign_thinking_becomes_text_and_redacted_is_dropped()
|
||||
{
|
||||
var foreign = new AssistantMessage
|
||||
{
|
||||
Provider = "other", Api = "a", Model = "m", StopReason = StopReason.Stop,
|
||||
Content = [new ThinkingContent("deep", "sig"), new ThinkingContent("", "blob", Redacted: true), new TextContent("answer")],
|
||||
};
|
||||
var same = foreign with { Provider = "p" };
|
||||
|
||||
var converted = Assert.IsType<AssistantMessage>(TranscriptNormalizer.Normalize([foreign], Target).Single());
|
||||
Assert.Equal(2, converted.Content.Count);
|
||||
Assert.Equal("deep", Assert.IsType<TextContent>(converted.Content[0]).Text);
|
||||
|
||||
var kept = Assert.IsType<AssistantMessage>(TranscriptNormalizer.Normalize([same], Target).Single());
|
||||
Assert.IsType<ThinkingContent>(kept.Content[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Images_are_replaced_for_text_only_models_and_custom_becomes_user()
|
||||
{
|
||||
var custom = new CustomMessage { CustomType = "x", Content = [new TextContent("t"), new ImageContent("AAAA", "image/png")] };
|
||||
var textOnly = Assert.IsType<UserMessage>(TranscriptNormalizer.Normalize([custom], Target).Single());
|
||||
Assert.IsType<TextContent>(textOnly.Content[1]);
|
||||
var vision = Assert.IsType<UserMessage>(TranscriptNormalizer.Normalize([custom], VisionTarget).Single());
|
||||
Assert.IsType<ImageContent>(vision.Content[1]);
|
||||
}
|
||||
}
|
||||
|
||||
public class ThinkingLevelsTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clamp_respects_model_map()
|
||||
{
|
||||
var model = new Model
|
||||
{
|
||||
Id = "m", Name = "m", Provider = "p", Api = "a", Reasoning = true,
|
||||
ThinkingLevelMap = new Dictionary<ThinkingLevel, string?> { [ThinkingLevel.Low] = "low", [ThinkingLevel.High] = "high", [ThinkingLevel.Max] = null },
|
||||
};
|
||||
Assert.Equal([ThinkingLevel.Off, ThinkingLevel.Low, ThinkingLevel.High], ThinkingLevels.Supported(model));
|
||||
Assert.Equal(ThinkingLevel.High, ThinkingLevels.Clamp(model, ThinkingLevel.Medium));
|
||||
Assert.Equal(ThinkingLevel.High, ThinkingLevels.Clamp(model, ThinkingLevel.Max));
|
||||
Assert.Equal(ThinkingLevel.Low, ThinkingLevels.Clamp(model, ThinkingLevel.Minimal));
|
||||
Assert.Equal(ThinkingLevel.Off, ThinkingLevels.Clamp(model with { Reasoning = false }, ThinkingLevel.High));
|
||||
}
|
||||
}
|
||||
123
tests/Volcanic.AgenticCore.Tests/Features/SessionsTests.cs
Normal file
123
tests/Volcanic.AgenticCore.Tests/Features/SessionsTests.cs
Normal file
@@ -0,0 +1,123 @@
|
||||
using Volcanic.AgenticCore.Abstractions.Messages;
|
||||
using Volcanic.AgenticCore.Abstractions.Providers;
|
||||
using Volcanic.AgenticCore.Abstractions.Sessions;
|
||||
using Volcanic.AgenticCore.Features.Sessions;
|
||||
|
||||
namespace Volcanic.AgenticCore.Tests.Features;
|
||||
|
||||
public class SessionManagerTests
|
||||
{
|
||||
private static AssistantMessage Assistant(string text) => new()
|
||||
{
|
||||
Provider = "p", Api = "a", Model = "m", StopReason = StopReason.Stop, Content = [new TextContent(text)],
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public async Task Appends_chain_to_leaf_and_branch_returns_root_to_leaf()
|
||||
{
|
||||
var store = new InMemorySessionStore();
|
||||
var session = await SessionManager.CreateAsync(store, "scope", "/tmp");
|
||||
var e1 = await session.AppendMessageAsync(UserMessage.FromText("one"));
|
||||
var e2 = await session.AppendMessageAsync(Assistant("two"));
|
||||
Assert.Null(e1.ParentId);
|
||||
Assert.Equal(e1.Id, e2.ParentId);
|
||||
Assert.Equal(e2.Id, session.LeafId);
|
||||
Assert.Equal([e1.Id, e2.Id], session.GetBranch().Select(e => e.Id));
|
||||
Assert.Equal(8, e1.Id.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Branching_starts_a_new_path_but_keeps_all_entries()
|
||||
{
|
||||
var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "scope", "/tmp");
|
||||
var e1 = await session.AppendMessageAsync(UserMessage.FromText("one"));
|
||||
await session.AppendMessageAsync(Assistant("old"));
|
||||
session.Branch(e1.Id);
|
||||
var e3 = await session.AppendMessageAsync(Assistant("new"));
|
||||
Assert.Equal(e1.Id, e3.ParentId);
|
||||
Assert.Equal(["one", "new"], session.GetMessages().Select(m => m.Text));
|
||||
Assert.Equal(3, session.GetEntries().Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Open_reloads_entries_and_continues_appending()
|
||||
{
|
||||
var store = new InMemorySessionStore();
|
||||
var session = await SessionManager.CreateAsync(store, "scope", "/tmp");
|
||||
await session.AppendMessageAsync(UserMessage.FromText("one"));
|
||||
await session.AppendModelChangeAsync(new Model { Id = "m", Name = "m", Provider = "p", Api = "a" });
|
||||
await session.AppendThinkingLevelChangeAsync(ThinkingLevel.High);
|
||||
await session.AppendSessionInfoAsync("named");
|
||||
|
||||
var reopened = await SessionManager.OpenAsync(store, "scope", session.SessionId);
|
||||
Assert.NotNull(reopened);
|
||||
Assert.Equal(session.LeafId, reopened!.LeafId);
|
||||
Assert.Equal("named", reopened.Name);
|
||||
Assert.Equal(("p", "m", (ThinkingLevel?)ThinkingLevel.High), reopened.GetBranchSettings());
|
||||
var e = await reopened.AppendMessageAsync(Assistant("more"));
|
||||
Assert.Equal(session.LeafId, e.ParentId);
|
||||
Assert.Null(await SessionManager.OpenAsync(store, "scope", "missing"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildContext_honours_compaction()
|
||||
{
|
||||
var session = await SessionManager.CreateAsync(new InMemorySessionStore(), "scope", "/tmp");
|
||||
await session.AppendMessageAsync(UserMessage.FromText("old-1"));
|
||||
await session.AppendMessageAsync(Assistant("old-2"));
|
||||
var kept = await session.AppendMessageAsync(UserMessage.FromText("kept-1"));
|
||||
await session.AppendMessageAsync(Assistant("kept-2"));
|
||||
await session.AppendCompactionAsync("SUMMARY", kept.Id, 1000, null, null);
|
||||
await session.AppendMessageAsync(UserMessage.FromText("after"));
|
||||
|
||||
var context = session.BuildContext();
|
||||
Assert.Equal(4, context.Count);
|
||||
Assert.Contains("SUMMARY", context[0].Text);
|
||||
Assert.StartsWith(SessionContextBuilder.SummaryPrefix, context[0].Text);
|
||||
Assert.Equal(["kept-1", "kept-2", "after"], context.Skip(1).Select(m => m.Text));
|
||||
Assert.Equal(5, session.GetMessages().Count);
|
||||
|
||||
await session.AppendCompactionAsync("ALL", null, 10, null, null);
|
||||
Assert.Single(session.BuildContext());
|
||||
}
|
||||
}
|
||||
|
||||
public class FileSessionStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Writes_jsonl_and_lists_sessions()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), "volcanic-tests", Guid.NewGuid().ToString("N"));
|
||||
try
|
||||
{
|
||||
var store = new FileSessionStore(root);
|
||||
var scope = FileSessionStore.ScopeFor("/home/user/project");
|
||||
Assert.Equal("--home-user-project--", scope);
|
||||
|
||||
var session = await SessionManager.CreateAsync(store, scope, "/home/user/project");
|
||||
await session.AppendMessageAsync(UserMessage.FromText("first question"));
|
||||
await session.AppendSessionInfoAsync("Named");
|
||||
|
||||
var path = store.PathFor(scope, session.SessionId);
|
||||
var lines = await File.ReadAllLinesAsync(path);
|
||||
Assert.Equal(3, lines.Length);
|
||||
Assert.Contains("\"sessionId\"", lines[0]);
|
||||
Assert.Contains("\"type\":\"message\"", lines[1]);
|
||||
|
||||
var loaded = await store.LoadAsync(scope, session.SessionId);
|
||||
Assert.Equal(2, loaded!.Entries.Count);
|
||||
|
||||
var other = await SessionManager.CreateAsync(store, scope, "/home/user/project");
|
||||
await Task.Delay(10);
|
||||
await other.AppendMessageAsync(UserMessage.FromText("second"));
|
||||
|
||||
var list = await store.ListAsync(scope);
|
||||
Assert.Equal(2, list.Count);
|
||||
Assert.Equal(other.SessionId, list[0].SessionId);
|
||||
Assert.Equal("Named", list[1].Name);
|
||||
Assert.Equal("first question", list[1].FirstUserText);
|
||||
Assert.Empty(await store.ListAsync("nothing"));
|
||||
}
|
||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user