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
@@ -86,6 +86,10 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
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<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(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 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);
@@ -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;
@@ -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);
@@ -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;
@@ -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!, 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;
@@ -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()
{
@@ -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();
@@ -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);
}
}
@@ -55,6 +55,10 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<SeedResultDto?> RestoreDefaultAgentsAsync() => Task.FromResult<SeedResultDto?>(null);
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
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<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);