From b4c58087d23e3a4bee9747e56c4dfaa2592fe290 Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Fri, 3 Jul 2026 09:58:50 +0200 Subject: [PATCH] feat(worker): session skills SignalR surface + per-level persistence --- .../Repositories/AppSettingsRepository.cs | 1 + .../Repositories/ListRepository.cs | 1 + .../Repositories/TaskRepository.cs | 4 +- .../Services/Interfaces/IWorkerClient.cs | 4 + src/ClaudeDo.Ui/Services/WorkerClient.cs | 28 ++- .../External/ConfigMcpTools.cs | 2 +- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 65 +++++- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 4 + .../External/ExternalMcpServiceTests.cs | 2 +- .../Hub/ClearMyDayHubTests.cs | 2 +- .../Hub/OnlineInboxHubTests.cs | 2 +- .../Hub/PlanningHubTests.cs | 2 +- .../Hub/SessionSkillsHubTests.cs | 191 ++++++++++++++++++ .../Hub/WorktreeStateHubTests.cs | 2 +- .../AppSettingsRepositoryTests.cs | 37 ++++ .../Repositories/ListRepositoryConfigTests.cs | 36 ++++ .../TaskRepositoryAgentSettingsTests.cs | 29 +++ .../UiVm/TasksIslandViewModelPlanningTests.cs | 4 + 18 files changed, 397 insertions(+), 19 deletions(-) create mode 100644 tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs diff --git a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs index 0a4f9a6e..dbc21a4c 100644 --- a/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs +++ b/src/ClaudeDo.Data/Repositories/AppSettingsRepository.cs @@ -64,6 +64,7 @@ public sealed class AppSettingsRepository ? null : updated.ReportExcludedPaths; row.StandupWeekday = updated.StandupWeekday; row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks; + row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills; await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Repositories/ListRepository.cs b/src/ClaudeDo.Data/Repositories/ListRepository.cs index e8e2d640..fd32701c 100644 --- a/src/ClaudeDo.Data/Repositories/ListRepository.cs +++ b/src/ClaudeDo.Data/Repositories/ListRepository.cs @@ -77,6 +77,7 @@ public sealed class ListRepository existing.SystemPrompt = config.SystemPrompt; existing.AgentPath = config.AgentPath; existing.MaxTurns = config.MaxTurns; + existing.SessionSkills = config.SessionSkills; } await _context.SaveChangesAsync(ct); } diff --git a/src/ClaudeDo.Data/Repositories/TaskRepository.cs b/src/ClaudeDo.Data/Repositories/TaskRepository.cs index f4598197..e68e57f4 100644 --- a/src/ClaudeDo.Data/Repositories/TaskRepository.cs +++ b/src/ClaudeDo.Data/Repositories/TaskRepository.cs @@ -189,6 +189,7 @@ public sealed class TaskRepository string? systemPrompt, string? agentPath, int? maxTurns = null, + string? sessionSkills = null, CancellationToken ct = default) { await _context.Tasks @@ -197,7 +198,8 @@ public sealed class TaskRepository .SetProperty(t => t.Model, model) .SetProperty(t => t.SystemPrompt, systemPrompt) .SetProperty(t => t.AgentPath, agentPath) - .SetProperty(t => t.MaxTurns, maxTurns), ct); + .SetProperty(t => t.MaxTurns, maxTurns) + .SetProperty(t => t.SessionSkills, sessionSkills), ct); } #endregion diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index f2681b5d..bab8d1df 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -64,6 +64,10 @@ public interface IWorkerClient : INotifyPropertyChanged Task RestoreDefaultAgentsAsync(); Task GetListConfigAsync(string listId); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); + Task> GetSessionSkillsAsync(); + Task> InstallSessionSkillAsync(string url); + Task UpdateSessionSkillAsync(string sourceUrl); + Task RemoveSessionSkillAsync(string sourceUrl); Task SetTaskStatusAsync(string taskId, TaskStatus status); Task ApproveReviewAsync(string taskId, string targetBranch); Task PreviewMergeAsync(string taskId, string targetBranch); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index c9606501..09677cb3 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -481,6 +481,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC await _hub.InvokeAsync("UpdateTaskAgentSettings", dto); } + public async Task> GetSessionSkillsAsync() + => await TryInvokeAsync>("GetSessionSkills") ?? []; + + public Task> InstallSessionSkillAsync(string url) + => _hub.InvokeAsync>("InstallSessionSkill", url); + + public Task UpdateSessionSkillAsync(string sourceUrl) + => _hub.InvokeAsync("UpdateSessionSkill", sourceUrl); + + public Task RemoveSessionSkillAsync(string sourceUrl) + => _hub.InvokeAsync("RemoveSessionSkill", sourceUrl); + public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status) { await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); @@ -627,7 +639,15 @@ public sealed record AppSettingsDto( int WorktreeAutoCleanupDays, string? ReportExcludedPaths, int StandupWeekday, - int DailyPrepMaxTasks); + int DailyPrepMaxTasks, + List? SessionSkills = null); + +public sealed record SessionSkillDto( + string Name, + string Description, + string SourceUrl, + string PinnedRef, + DateTimeOffset AddedAt); public sealed record WorktreeCleanupDto(int Removed); public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); @@ -638,9 +658,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); -public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); -public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); -public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public sealed record SeedResultDto(int Copied, int Skipped); public sealed record WorktreeOverviewDto( diff --git a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs index 2fb9cc8c..12af5267 100644 --- a/src/ClaudeDo.Worker/External/ConfigMcpTools.cs +++ b/src/ClaudeDo.Worker/External/ConfigMcpTools.cs @@ -58,7 +58,7 @@ public sealed class ConfigMcpTools _ = await _tasks.GetByIdAsync(taskId, cancellationToken) ?? throw new InvalidOperationException($"Task {taskId} not found."); - await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken); + await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, ct: cancellationToken); await _broadcaster.TaskUpdated(taskId); } diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 2cac81f8..3d9a5cf7 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -15,8 +15,10 @@ using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report.Interfaces; +using ClaudeDo.Worker.Skills; using ClaudeDo.Worker.State; using ClaudeDo.Worker.Worktrees; +using System.Text.Json; using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; @@ -37,7 +39,15 @@ public record AppSettingsDto( int WorktreeAutoCleanupDays, string? ReportExcludedPaths, int StandupWeekday, - int DailyPrepMaxTasks); + int DailyPrepMaxTasks, + List? SessionSkills = null); + +public record SessionSkillDto( + string Name, + string Description, + string SourceUrl, + string PinnedRef, + DateTimeOffset AddedAt); public record WorktreeCleanupDto(int Removed); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); @@ -65,9 +75,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList Segments); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); -public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); -public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); -public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); +public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); +public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List? SessionSkills = null); public record SeedResultDto(int Copied, int Skipped); public record OnlineInboxStateDto( @@ -119,6 +129,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub private readonly Runner.PendingQuestionRegistry _pendingQuestions; private readonly InteractiveSessionService _interactive; private readonly LogRingBuffer? _logBuffer; + private readonly ISessionSkillRegistry _skillRegistry; public WorkerHub( QueueService queue, @@ -145,6 +156,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub OnlineTokenStore onlineTokenStore, Runner.PendingQuestionRegistry pendingQuestions, InteractiveSessionService interactive, + ISessionSkillRegistry skillRegistry, LogRingBuffer? logBuffer = null) { _queue = queue; @@ -171,9 +183,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub _onlineTokenStore = onlineTokenStore; _pendingQuestions = pendingQuestions; _interactive = interactive; + _skillRegistry = skillRegistry; _logBuffer = logBuffer; } + // Persistence boundary for the session_skills JSON-array columns (task/list/global). + // A null/empty selection persists as null so "inherit / none" stays clean, and the + // shape matches TaskRunner.UnionSkillNames's expected JSON string array. + private static string? SkillsToJson(List? names) => + names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names); + + private static List? SkillsFromJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return null; + try { return JsonSerializer.Deserialize>(json); } + catch (JsonException) { return null; } + } + /// Deliver the user's answer to a question a running task raised via AskUser. /// Returns false if no matching question is still pending (already answered or timed out). public bool AnswerTaskQuestion(string taskId, string questionId, string answer) => @@ -288,7 +314,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub row.WorktreeAutoCleanupDays, row.ReportExcludedPaths, row.StandupWeekday, - row.DailyPrepMaxTasks); + row.DailyPrepMaxTasks, + SkillsFromJson(row.SessionSkills)); } public async Task UpdateAppSettings(AppSettingsDto dto) @@ -310,9 +337,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub ReportExcludedPaths = dto.ReportExcludedPaths, StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, DailyPrepMaxTasks = dto.DailyPrepMaxTasks, + SessionSkills = SkillsToJson(dto.SessionSkills), }); } + public async Task> GetSessionSkills() + { + var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted); + return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList(); + } + + public Task> InstallSessionSkill(string url) => HubGuard(async () => + { + var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted); + return installed.ToList(); + }); + + public Task UpdateSessionSkill(string sourceUrl) => HubGuard( + () => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted)); + + public Task RemoveSessionSkill(string sourceUrl) => HubGuard( + () => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted)); + public async Task CleanupFinishedWorktrees(string? listId = null) { var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted); @@ -456,8 +502,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var model = dto.Model.NullIfBlank(); var systemPrompt = dto.SystemPrompt.NullIfBlank(); var agentPath = dto.AgentPath.NullIfBlank(); + var sessionSkills = SkillsToJson(dto.SessionSkills); - if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null) + if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null) { await repo.DeleteConfigAsync(dto.ListId); } @@ -470,6 +517,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub SystemPrompt = systemPrompt, AgentPath = agentPath, MaxTurns = dto.MaxTurns, + SessionSkills = sessionSkills, }); } @@ -482,7 +530,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub var repo = new ListRepository(ctx); var config = await repo.GetConfigAsync(listId); if (config is null) return null; - return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns); + return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills)); } public async Task SetTaskStatus(string taskId, string status) @@ -543,7 +591,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub dto.Model.NullIfBlank(), dto.SystemPrompt.NullIfBlank(), dto.AgentPath.NullIfBlank(), - dto.MaxTurns); + dto.MaxTurns, + SkillsToJson(dto.SessionSkills)); await _broadcaster.TaskUpdated(dto.TaskId); } diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 4c65b5eb..801cf393 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -86,6 +86,10 @@ public abstract class StubWorkerClient : IWorkerClient public virtual Task RestoreDefaultAgentsAsync() => Task.FromResult(null); public virtual Task GetListConfigAsync(string listId) => Task.FromResult(null); public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public virtual Task> GetSessionSkillsAsync() => Task.FromResult(new List()); + public virtual Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); + public virtual Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask; + public virtual Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask; public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; public virtual Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null); public virtual Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null); diff --git a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs index adb8e964..4d785beb 100644 --- a/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs +++ b/tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs @@ -624,7 +624,7 @@ public sealed class ExternalMcpServiceTests : IDisposable { var listId = await SeedListAsync(); var task = await SeedTaskAsync(listId); - await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, CancellationToken.None); + await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, ct: CancellationToken.None); var sut = BuildConfigSut(); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs index 7e4ac855..e1796696 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/ClearMyDayHubTests.cs @@ -21,7 +21,7 @@ public sealed class ClearMyDayHubTests : IDisposable null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), - new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs index c654fd15..74ae5ee6 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/OnlineInboxHubTests.cs @@ -31,7 +31,7 @@ public sealed class OnlineInboxHubTests : IDisposable var hub = new WorkerHub( null!, null!, null!, null!, broadcaster, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, - cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + cfg, inboxCfg, store, new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return (hub, inboxCfg, store); diff --git a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs index f59b5beb..c43a55c6 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/PlanningHubTests.cs @@ -57,7 +57,7 @@ public sealed class PlanningHubTests : IDisposable null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!, _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!, null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), - new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, null!); hub.Clients = new FakeHubCallerClients(_proxy); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs new file mode 100644 index 00000000..5ccc4675 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/Hub/SessionSkillsHubTests.cs @@ -0,0 +1,191 @@ +using ClaudeDo.Data.Models; +using ClaudeDo.Data.Repositories; +using ClaudeDo.Worker.Hub; +using ClaudeDo.Worker.Skills; +using ClaudeDo.Worker.Tests.Infrastructure; +using Xunit; + +namespace ClaudeDo.Worker.Tests.Hub; + +public sealed class SessionSkillsHubTests : IDisposable +{ + private readonly DbFixture _db = new(); + + public void Dispose() => _db.Dispose(); + + private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry + { + public List Skills { get; } = new(); + public string? InstallUrl { get; private set; } + public string? UpdateSourceUrl { get; private set; } + public string? RemoveSourceUrl { get; private set; } + public Exception? ThrowOnInstall { get; set; } + + public Task> InstallAsync(string url, CancellationToken ct) + { + InstallUrl = url; + if (ThrowOnInstall is not null) throw ThrowOnInstall; + return Task.FromResult>(new List { "ponytail", "ponytail-help" }); + } + + public Task UpdateAsync(string sourceUrl, CancellationToken ct) + { + UpdateSourceUrl = sourceUrl; + return Task.CompletedTask; + } + + public Task RemoveAsync(string sourceUrl, CancellationToken ct) + { + RemoveSourceUrl = sourceUrl; + return Task.CompletedTask; + } + + public Task> ListAsync(CancellationToken ct) + => Task.FromResult>(Skills); + } + + private (WorkerHub hub, FakeSessionSkillRegistry registry) CreateHub() + { + var registry = new FakeSessionSkillRegistry(); + var broadcaster = new HubBroadcaster(new CapturingHubContext()); + var hub = new WorkerHub( + null!, null!, null!, null!, broadcaster, _db.CreateFactory(), + null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, + null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, registry); + hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); + hub.Context = new FakeHubCallerContext(); + return (hub, registry); + } + + [Fact] + public async Task GetSessionSkills_maps_registry_rows_to_dtos() + { + var (hub, registry) = CreateHub(); + registry.Skills.Add(new SessionSkillEntity + { + Name = "ponytail", + SourceUrl = "https://example.com/skills.git", + PinnedRef = "abc123", + Subpath = "skills/ponytail", + Description = "A skill", + AddedAt = DateTimeOffset.UtcNow, + }); + + var result = await hub.GetSessionSkills(); + + var dto = Assert.Single(result); + Assert.Equal("ponytail", dto.Name); + Assert.Equal("A skill", dto.Description); + Assert.Equal("https://example.com/skills.git", dto.SourceUrl); + Assert.Equal("abc123", dto.PinnedRef); + } + + [Fact] + public async Task InstallSessionSkill_returns_installed_names_and_forwards_url() + { + var (hub, registry) = CreateHub(); + + var installed = await hub.InstallSessionSkill("https://example.com/skills.git"); + + Assert.Equal(new List { "ponytail", "ponytail-help" }, installed); + Assert.Equal("https://example.com/skills.git", registry.InstallUrl); + } + + [Fact] + public async Task InstallSessionSkill_wraps_InvalidOperationException_as_HubException() + { + var (hub, registry) = CreateHub(); + registry.ThrowOnInstall = new InvalidOperationException("boom"); + + var ex = await Assert.ThrowsAsync( + () => hub.InstallSessionSkill("https://example.com/skills.git")); + Assert.Equal("boom", ex.Message); + } + + [Fact] + public async Task UpdateSessionSkill_forwards_source_url() + { + var (hub, registry) = CreateHub(); + + await hub.UpdateSessionSkill("https://example.com/skills.git"); + + Assert.Equal("https://example.com/skills.git", registry.UpdateSourceUrl); + } + + [Fact] + public async Task RemoveSessionSkill_forwards_source_url() + { + var (hub, registry) = CreateHub(); + + await hub.RemoveSessionSkill("https://example.com/skills.git"); + + Assert.Equal("https://example.com/skills.git", registry.RemoveSourceUrl); + } + + [Fact] + public async Task UpdateAppSettings_then_GetAppSettings_RoundTrips_SessionSkills() + { + var (hub, _) = CreateHub(); + var current = await hub.GetAppSettings(); + + await hub.UpdateAppSettings(current with { SessionSkills = new List { "ponytail", "ponytail-help" } }); + var reloaded = await hub.GetAppSettings(); + + Assert.Equal(new List { "ponytail", "ponytail-help" }, reloaded.SessionSkills); + } + + [Fact] + public async Task UpdateAppSettings_EmptySessionSkills_PersistsAsNull() + { + var (hub, _) = CreateHub(); + var current = await hub.GetAppSettings(); + + await hub.UpdateAppSettings(current with { SessionSkills = new List { "ponytail" } }); + await hub.UpdateAppSettings(current with { SessionSkills = new List() }); + var reloaded = await hub.GetAppSettings(); + + Assert.Null(reloaded.SessionSkills); + } + + [Fact] + public async Task UpdateListConfig_then_GetListConfig_RoundTrips_SessionSkills() + { + var (hub, _) = CreateHub(); + var listId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + } + + await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, null, new List { "ponytail" })); + var config = await hub.GetListConfig(listId); + + Assert.NotNull(config); + Assert.Equal(new List { "ponytail" }, config!.SessionSkills); + } + + [Fact] + public async Task UpdateTaskAgentSettings_Persists_SessionSkills() + { + var (hub, _) = CreateHub(); + var listId = Guid.NewGuid().ToString(); + var taskId = Guid.NewGuid().ToString(); + using (var ctx = _db.CreateContext()) + { + await new ListRepository(ctx).AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow }); + await new TaskRepository(ctx).AddAsync(new TaskEntity + { + Id = taskId, ListId = listId, Title = "T", CreatedAt = DateTime.UtcNow, + }); + } + + await hub.UpdateTaskAgentSettings(new UpdateTaskAgentSettingsDto( + taskId, null, null, null, null, new List { "ponytail", "ponytail-help" })); + + using var readCtx = _db.CreateContext(); + var entity = await new TaskRepository(readCtx).GetByIdAsync(taskId); + Assert.NotNull(entity); + Assert.Equal("[\"ponytail\",\"ponytail-help\"]", entity!.SessionSkills); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs index ee803906..5fde9a77 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/WorktreeStateHubTests.cs @@ -21,7 +21,7 @@ public sealed class WorktreeStateHubTests : IDisposable null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), - new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!); + new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!, null!); hub.Clients = new FakeHubCallerClients(new RecordingClientProxy()); hub.Context = new FakeHubCallerContext(); return hub; diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs index 4af108c7..7f80a8a2 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/AppSettingsRepositoryTests.cs @@ -72,6 +72,43 @@ public class AppSettingsRepositoryTests : IDisposable Assert.Null(row.CentralWorktreeRoot); } + [Fact] + public async Task UpdateAsync_Persists_SessionSkills_Json() + { + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(new AppSettingsEntity + { + SessionSkills = "[\"ponytail\",\"ponytail-help\"]", + }); + } + + using var readCtx = _db.CreateContext(); + var row = await new AppSettingsRepository(readCtx).GetAsync(); + Assert.Equal("[\"ponytail\",\"ponytail-help\"]", row.SessionSkills); + } + + [Fact] + public async Task UpdateAsync_Blank_SessionSkills_Stored_As_Null() + { + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(new AppSettingsEntity { SessionSkills = "[\"a\"]" }); + } + + using (var ctx = _db.CreateContext()) + { + var repo = new AppSettingsRepository(ctx); + await repo.UpdateAsync(new AppSettingsEntity { SessionSkills = null }); + } + + using var readCtx = _db.CreateContext(); + var row = await new AppSettingsRepository(readCtx).GetAsync(); + Assert.Null(row.SessionSkills); + } + [Fact] public async Task DailyPrepMaxTasks_defaults_to_5_and_persists() { diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs index 07e6b714..c4f4f921 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs @@ -60,6 +60,42 @@ public sealed class ListRepositoryConfigTests : IDisposable Assert.Equal("haiku-4-5", fetched.Model); } + [Fact] + public async Task SetConfig_Persists_SessionSkills_On_Insert() + { + await _repo.SetConfigAsync(new ListConfigEntity + { + ListId = _listId, + SessionSkills = "[\"ponytail\"]", + }); + + var fetched = await _repo.GetConfigAsync(_listId); + Assert.NotNull(fetched); + Assert.Equal("[\"ponytail\"]", fetched.SessionSkills); + } + + [Fact] + public async Task SetConfig_Persists_SessionSkills_On_Update() + { + await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, SessionSkills = "[\"a\"]" }); + await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, SessionSkills = "[\"b\",\"c\"]" }); + + var fetched = await _repo.GetConfigAsync(_listId); + Assert.NotNull(fetched); + Assert.Equal("[\"b\",\"c\"]", fetched.SessionSkills); + } + + [Fact] + public async Task SetConfig_Null_SessionSkills_Clears_On_Update() + { + await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, SessionSkills = "[\"a\"]" }); + await _repo.SetConfigAsync(new ListConfigEntity { ListId = _listId, SessionSkills = null }); + + var fetched = await _repo.GetConfigAsync(_listId); + Assert.NotNull(fetched); + Assert.Null(fetched.SessionSkills); + } + public void Dispose() { _ctx.Dispose(); diff --git a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs index 6ac3d784..680a35ad 100644 --- a/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryAgentSettingsTests.cs @@ -72,4 +72,33 @@ public sealed class TaskRepositoryAgentSettingsTests : IDisposable Assert.Null(entity.SystemPrompt); Assert.Null(entity.AgentPath); } + + [Fact] + public async Task UpdateAgentSettingsAsync_Persists_SessionSkills() + { + var taskId = await SeedTaskAsync(); + + await _repo.UpdateAgentSettingsAsync(taskId, null, null, null, sessionSkills: "[\"ponytail\",\"ponytail-help\"]"); + + var entity = await _repo.GetByIdAsync(taskId); + Assert.NotNull(entity); + Assert.Equal("[\"ponytail\",\"ponytail-help\"]", entity!.SessionSkills); + } + + [Fact] + public async Task UpdateAgentSettingsAsync_Null_SessionSkills_ClearsColumn() + { + var taskId = await SeedTaskAsync(); + + using (var ctx = _db.CreateContext()) + { + await new TaskRepository(ctx).UpdateAgentSettingsAsync(taskId, null, null, null, sessionSkills: "[\"a\"]"); + } + + await _repo.UpdateAgentSettingsAsync(taskId, null, null, null, sessionSkills: null); + + var entity = await _repo.GetByIdAsync(taskId); + Assert.NotNull(entity); + Assert.Null(entity!.SessionSkills); + } } diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index 34760d0e..a15e275f 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -55,6 +55,10 @@ sealed class FakeWorkerClient : IWorkerClient public Task RestoreDefaultAgentsAsync() => Task.FromResult(null); public Task GetListConfigAsync(string listId) => Task.FromResult(null); public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; + public Task> GetSessionSkillsAsync() => Task.FromResult(new List()); + public Task> InstallSessionSkillAsync(string url) => Task.FromResult(new List()); + public Task UpdateSessionSkillAsync(string sourceUrl) => Task.CompletedTask; + public Task RemoveSessionSkillAsync(string sourceUrl) => Task.CompletedTask; public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask; public Task ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult(null); public Task PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult(null);