feat(worker): session skills SignalR surface + per-level persistence

This commit is contained in:
Mika Kuns
2026-07-23 16:47:14 +02:00
committed by mika kuns
parent 4626481359
commit b4c58087d2
18 changed files with 397 additions and 19 deletions
@@ -64,6 +64,7 @@ public sealed class AppSettingsRepository
? null : updated.ReportExcludedPaths; ? null : updated.ReportExcludedPaths;
row.StandupWeekday = updated.StandupWeekday; row.StandupWeekday = updated.StandupWeekday;
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks; row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -77,6 +77,7 @@ public sealed class ListRepository
existing.SystemPrompt = config.SystemPrompt; existing.SystemPrompt = config.SystemPrompt;
existing.AgentPath = config.AgentPath; existing.AgentPath = config.AgentPath;
existing.MaxTurns = config.MaxTurns; existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
@@ -189,6 +189,7 @@ public sealed class TaskRepository
string? systemPrompt, string? systemPrompt,
string? agentPath, string? agentPath,
int? maxTurns = null, int? maxTurns = null,
string? sessionSkills = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
await _context.Tasks await _context.Tasks
@@ -197,7 +198,8 @@ public sealed class TaskRepository
.SetProperty(t => t.Model, model) .SetProperty(t => t.Model, model)
.SetProperty(t => t.SystemPrompt, systemPrompt) .SetProperty(t => t.SystemPrompt, systemPrompt)
.SetProperty(t => t.AgentPath, agentPath) .SetProperty(t => t.AgentPath, agentPath)
.SetProperty(t => t.MaxTurns, maxTurns), ct); .SetProperty(t => t.MaxTurns, maxTurns)
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
} }
#endregion #endregion
@@ -64,6 +64,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<SeedResultDto?> RestoreDefaultAgentsAsync(); Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
Task<List<string>> InstallSessionSkillAsync(string url);
Task UpdateSessionSkillAsync(string sourceUrl);
Task RemoveSessionSkillAsync(string sourceUrl);
Task SetTaskStatusAsync(string taskId, TaskStatus status); Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch); Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch); Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
+24 -4
View File
@@ -481,6 +481,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto); await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
} }
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
public Task<List<string>> InstallSessionSkillAsync(string url)
=> _hub.InvokeAsync<List<string>>("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) public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
{ {
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
@@ -627,7 +639,15 @@ public sealed record AppSettingsDto(
int WorktreeAutoCleanupDays, int WorktreeAutoCleanupDays,
string? ReportExcludedPaths, string? ReportExcludedPaths,
int StandupWeekday, int StandupWeekday,
int DailyPrepMaxTasks); int DailyPrepMaxTasks,
List<string>? 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 WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
@@ -638,9 +658,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); 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 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 UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record SeedResultDto(int Copied, int Skipped); public sealed record SeedResultDto(int Copied, int Skipped);
public sealed record WorktreeOverviewDto( public sealed record WorktreeOverviewDto(
+1 -1
View File
@@ -58,7 +58,7 @@ public sealed class ConfigMcpTools
_ = await _tasks.GetByIdAsync(taskId, cancellationToken) _ = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found."); ?? 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); await _broadcaster.TaskUpdated(taskId);
} }
+57 -8
View File
@@ -15,8 +15,10 @@ using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces; using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Skills;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Worktrees; using ClaudeDo.Worker.Worktrees;
using System.Text.Json;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -37,7 +39,15 @@ public record AppSettingsDto(
int WorktreeAutoCleanupDays, int WorktreeAutoCleanupDays,
string? ReportExcludedPaths, string? ReportExcludedPaths,
int StandupWeekday, int StandupWeekday,
int DailyPrepMaxTasks); int DailyPrepMaxTasks,
List<string>? SessionSkills = null);
public record SessionSkillDto(
string Name,
string Description,
string SourceUrl,
string PinnedRef,
DateTimeOffset AddedAt);
public record WorktreeCleanupDto(int Removed); public record WorktreeCleanupDto(int Removed);
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks); public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
@@ -65,9 +75,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); 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 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 UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
public record OnlineInboxStateDto( public record OnlineInboxStateDto(
@@ -119,6 +129,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly Runner.PendingQuestionRegistry _pendingQuestions; private readonly Runner.PendingQuestionRegistry _pendingQuestions;
private readonly InteractiveSessionService _interactive; private readonly InteractiveSessionService _interactive;
private readonly LogRingBuffer? _logBuffer; private readonly LogRingBuffer? _logBuffer;
private readonly ISessionSkillRegistry _skillRegistry;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -145,6 +156,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
OnlineTokenStore onlineTokenStore, OnlineTokenStore onlineTokenStore,
Runner.PendingQuestionRegistry pendingQuestions, Runner.PendingQuestionRegistry pendingQuestions,
InteractiveSessionService interactive, InteractiveSessionService interactive,
ISessionSkillRegistry skillRegistry,
LogRingBuffer? logBuffer = null) LogRingBuffer? logBuffer = null)
{ {
_queue = queue; _queue = queue;
@@ -171,9 +183,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_onlineTokenStore = onlineTokenStore; _onlineTokenStore = onlineTokenStore;
_pendingQuestions = pendingQuestions; _pendingQuestions = pendingQuestions;
_interactive = interactive; _interactive = interactive;
_skillRegistry = skillRegistry;
_logBuffer = logBuffer; _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<string>? names) =>
names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names);
private static List<string>? SkillsFromJson(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try { return JsonSerializer.Deserialize<List<string>>(json); }
catch (JsonException) { return null; }
}
/// <summary>Deliver the user's answer to a question a running task raised via AskUser. /// <summary>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).</summary> /// Returns false if no matching question is still pending (already answered or timed out).</summary>
public bool AnswerTaskQuestion(string taskId, string questionId, string answer) => public bool AnswerTaskQuestion(string taskId, string questionId, string answer) =>
@@ -288,7 +314,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.WorktreeAutoCleanupDays, row.WorktreeAutoCleanupDays,
row.ReportExcludedPaths, row.ReportExcludedPaths,
row.StandupWeekday, row.StandupWeekday,
row.DailyPrepMaxTasks); row.DailyPrepMaxTasks,
SkillsFromJson(row.SessionSkills));
} }
public async Task UpdateAppSettings(AppSettingsDto dto) public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -310,9 +337,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
ReportExcludedPaths = dto.ReportExcludedPaths, ReportExcludedPaths = dto.ReportExcludedPaths,
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
DailyPrepMaxTasks = dto.DailyPrepMaxTasks, DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
SessionSkills = SkillsToJson(dto.SessionSkills),
}); });
} }
public async Task<List<SessionSkillDto>> 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<List<string>> 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<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null) public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
{ {
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted); 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 model = dto.Model.NullIfBlank();
var systemPrompt = dto.SystemPrompt.NullIfBlank(); var systemPrompt = dto.SystemPrompt.NullIfBlank();
var agentPath = dto.AgentPath.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); await repo.DeleteConfigAsync(dto.ListId);
} }
@@ -470,6 +517,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
SystemPrompt = systemPrompt, SystemPrompt = systemPrompt,
AgentPath = agentPath, AgentPath = agentPath,
MaxTurns = dto.MaxTurns, MaxTurns = dto.MaxTurns,
SessionSkills = sessionSkills,
}); });
} }
@@ -482,7 +530,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var repo = new ListRepository(ctx); var repo = new ListRepository(ctx);
var config = await repo.GetConfigAsync(listId); var config = await repo.GetConfigAsync(listId);
if (config is null) return null; 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) public async Task SetTaskStatus(string taskId, string status)
@@ -543,7 +591,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
dto.Model.NullIfBlank(), dto.Model.NullIfBlank(),
dto.SystemPrompt.NullIfBlank(), dto.SystemPrompt.NullIfBlank(),
dto.AgentPath.NullIfBlank(), dto.AgentPath.NullIfBlank(),
dto.MaxTurns); dto.MaxTurns,
SkillsToJson(dto.SessionSkills));
await _broadcaster.TaskUpdated(dto.TaskId); await _broadcaster.TaskUpdated(dto.TaskId);
} }
@@ -86,6 +86,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null); public virtual Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null); public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public virtual Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
public virtual Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
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 SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null); public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null); public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
@@ -624,7 +624,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
{ {
var listId = await SeedListAsync(); var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId); 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 sut = BuildConfigSut();
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None); var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
@@ -21,7 +21,7 @@ public sealed class ClearMyDayHubTests : IDisposable
null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), 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.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext(); hub.Context = new FakeHubCallerContext();
return hub; return hub;
@@ -31,7 +31,7 @@ public sealed class OnlineInboxHubTests : IDisposable
var hub = new WorkerHub( var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, null!, null!, null!, null!, null!, broadcaster, null!,
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, 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.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext(); hub.Context = new FakeHubCallerContext();
return (hub, inboxCfg, store); return (hub, inboxCfg, store);
@@ -57,7 +57,7 @@ public sealed class PlanningHubTests : IDisposable
null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!, null!, null!, null!, null!, null!, _db.CreateFactory(), null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!, _planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), 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.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext(); hub.Context = new FakeHubCallerContext();
return hub; return hub;
@@ -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<SessionSkillEntity> 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<IReadOnlyList<string>> InstallAsync(string url, CancellationToken ct)
{
InstallUrl = url;
if (ThrowOnInstall is not null) throw ThrowOnInstall;
return Task.FromResult<IReadOnlyList<string>>(new List<string> { "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<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<SessionSkillEntity>>(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<string> { "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<Microsoft.AspNetCore.SignalR.HubException>(
() => 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<string> { "ponytail", "ponytail-help" } });
var reloaded = await hub.GetAppSettings();
Assert.Equal(new List<string> { "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<string> { "ponytail" } });
await hub.UpdateAppSettings(current with { SessionSkills = new List<string>() });
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<string> { "ponytail" }));
var config = await hub.GetListConfig(listId);
Assert.NotNull(config);
Assert.Equal(new List<string> { "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<string> { "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);
}
}
@@ -21,7 +21,7 @@ public sealed class WorktreeStateHubTests : IDisposable
null!, null!, null!, null!, broadcaster, _db.CreateFactory(), null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.Online.OnlineTokenStore(), 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.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext(); hub.Context = new FakeHubCallerContext();
return hub; return hub;
@@ -72,6 +72,43 @@ public class AppSettingsRepositoryTests : IDisposable
Assert.Null(row.CentralWorktreeRoot); 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] [Fact]
public async Task DailyPrepMaxTasks_defaults_to_5_and_persists() public async Task DailyPrepMaxTasks_defaults_to_5_and_persists()
{ {
@@ -60,6 +60,42 @@ public sealed class ListRepositoryConfigTests : IDisposable
Assert.Equal("haiku-4-5", fetched.Model); 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() public void Dispose()
{ {
_ctx.Dispose(); _ctx.Dispose();
@@ -72,4 +72,33 @@ public sealed class TaskRepositoryAgentSettingsTests : IDisposable
Assert.Null(entity.SystemPrompt); Assert.Null(entity.SystemPrompt);
Assert.Null(entity.AgentPath); 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);
}
} }
@@ -55,6 +55,10 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null); public Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null); public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask; public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public Task<List<SessionSkillDto>> GetSessionSkillsAsync() => Task.FromResult(new List<SessionSkillDto>());
public Task<List<string>> InstallSessionSkillAsync(string url) => Task.FromResult(new List<string>());
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 SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null); public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null); public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);