Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,17 @@ namespace AiDevNet.Tests.Integration;
public class ConsistencyUiAndDetectionIntegrationTests : IDisposable
{
private readonly string _rootPath;
private readonly ActiveWorkspaceHolder _holder;
private readonly WorkspacePaths _paths;
private readonly AtomicFileWriter _fileWriter = new();
private readonly ProjectMutationCoordinator _coordinator = new();
private readonly string _registryPath;

public ConsistencyUiAndDetectionIntegrationTests()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"consistency-ui-{Guid.NewGuid():N}");
Directory.CreateDirectory(_rootPath);
_holder = new ActiveWorkspaceHolder();
_holder.Activate(_rootPath);
_paths = _holder.Paths;
_paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev")));
_registryPath = Path.Combine(_rootPath, "test-managed-projects.json");
}

public void Dispose()
Expand All @@ -35,8 +34,8 @@ public void Dispose()
[Fact]
public async Task CheckProjectAsync_WhenRawBoardHasDuplicateTaskReference_ReportsRawFinding()
{
var workspaceService = new WorkspaceService(_holder, _fileWriter);
workspaceService.CreateProject(_rootPath, "demo-project", "Demo Project", null).ShouldBeOfType<Ok<Unit>>();
var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath);
workspaceService.CreateProject(slug: "demo-project", name: "Demo Project").ShouldBeOfType<Ok<Unit>>();
var projectSlug = new ProjectSlug("demo-project");
var boardPath = _paths.BoardPath(projectSlug);
_fileWriter.WriteAllText(boardPath, "{\"columns\":[{\"id\":\"backlog\",\"title\":\"Backlog\",\"taskIds\":[\"task-1\"]},{\"id\":\"review\",\"title\":\"Review\",\"taskIds\":[\"task-1\"]}],\"tasks\":{\"task-1\":{\"id\":\"task-1\",\"title\":\"Investigate failure\",\"priority\":\"normal\"}}}");
Expand Down
23 changes: 11 additions & 12 deletions ai-dev-net.tests.integration/ProductionReadinessIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,19 @@ namespace AiDevNet.Tests.Integration;
public class ProductionReadinessIntegrationTests : IDisposable
{
private readonly string _rootPath;
private readonly ActiveWorkspaceHolder _holder;
private readonly WorkspacePaths _paths;
private readonly AtomicFileWriter _fileWriter = new();
private readonly ProjectMutationCoordinator _coordinator = new();
private readonly string _templatesDir;
private readonly string _registryPath;

public ProductionReadinessIntegrationTests()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"prod-ready-{Guid.NewGuid():N}");
Directory.CreateDirectory(_rootPath);
_holder = new ActiveWorkspaceHolder();
_holder.Activate(_rootPath);
_paths = _holder.Paths;
_paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev")));
_templatesDir = Path.Combine(Path.GetTempPath(), $"templates-{Guid.NewGuid():N}");
_registryPath = Path.Combine(_rootPath, "test-managed-projects.json");
}

public void Dispose()
Expand Down Expand Up @@ -142,14 +141,14 @@ public void AtomicFileWriter_WhenOverwritingFile_ReplacesContent()
[Fact]
public void WorkspaceService_CreateProject_UsesAtomicWritesForArtifacts()
{
var service = new WorkspaceService(_holder, _fileWriter);
var service = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath);

var result = service.CreateProject(_rootPath, "demo-project", "Demo Project", "Main app");
var result = service.CreateProject(slug: "demo-project", name: "Demo Project", description: "Main app");

result.ShouldBeOfType<Ok<Unit>>();
File.Exists(_paths.ProjectJsonPath(new ProjectSlug("demo-project"))).ShouldBeTrue();
File.Exists(_paths.BoardPath(new ProjectSlug("demo-project"))).ShouldBeTrue();
File.Exists(GlobalPaths.RegistryFile).ShouldBeTrue();
File.Exists(_registryPath).ShouldBeTrue();
}

[Fact]
Expand All @@ -166,8 +165,8 @@ public void AgentService_CreateAgent_WritesFilesAtomically()
modelRegistry,
NullLogger<AgentService>.Instance);
var projectSlug = new ProjectSlug("demo-project");
var workspaceService = new WorkspaceService(_holder, _fileWriter);
workspaceService.CreateProject(_rootPath, projectSlug.Value, "Demo Project", null).ShouldBeOfType<Ok<Unit>>();
var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath);
workspaceService.CreateProject(slug: projectSlug.Value, name: "Demo Project").ShouldBeOfType<Ok<Unit>>();

Directory.CreateDirectory(_templatesDir);
File.WriteAllText(Path.Combine(_templatesDir, "generic-standard.json"),
Expand All @@ -185,10 +184,10 @@ public void AgentService_CreateAgent_WritesFilesAtomically()
public void KbAndPlaybookServices_SaveAtomically()
{
var projectSlug = new ProjectSlug("demo-project");
var workspaceService = new WorkspaceService(_holder, _fileWriter);
workspaceService.CreateProject(_rootPath, projectSlug.Value, "Demo Project", null).ShouldBeOfType<Ok<Unit>>();
var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath);
workspaceService.CreateProject(slug: projectSlug.Value, name: "Demo Project").ShouldBeOfType<Ok<Unit>>();

var kbService = new KbService(_paths, _fileWriter, _coordinator);
var kbService= new KbService(_paths, _fileWriter, _coordinator);
var playbookService = new PlaybookService(_paths, _fileWriter, _coordinator);

kbService.Save(projectSlug, "deployment", "# Deployment\n\nSteps").ShouldBeOfType<Ok<Unit>>();
Expand Down
8 changes: 4 additions & 4 deletions ai-dev-net.tests.unit/ConsistencyCheckServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,11 @@ private static (WorkspacePaths paths, WorkspaceService workspace) CreateServices
{
var codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(codebasePath);
var holder = new ActiveWorkspaceHolder();
holder.Activate(codebasePath);
var workspace = new WorkspaceService(holder, new AtomicFileWriter());
var registryPath = Path.Combine(codebasePath, "test-managed-projects.json");
var paths = new WorkspacePaths(new RootDir(Path.Combine(codebasePath, ".ai-dev")));
var workspace = new WorkspaceService(paths, new AtomicFileWriter(), registryFilePath: registryPath);
workspace.CreateProject(codebasePath, "demo-project", "Demo Project", null).ShouldBeOfType<Ok<AiDev.Models.Unit>>();
return (holder.Paths, workspace);
return (paths, workspace);
}

private sealed class PassingDispatcher : IDomainEventDispatcher
Expand Down
51 changes: 33 additions & 18 deletions ai-dev-net.tests.unit/WorkspaceServiceTests.cs
Original file line number Diff line number Diff line change
@@ -1,45 +1,60 @@
namespace AiDevNet.Tests.Unit;

public class WorkspaceServiceTests
public class WorkspaceServiceTests : IDisposable
{
private readonly string _codebasePath;
private readonly string _registryPath;
private readonly WorkspaceService _service;

public WorkspaceServiceTests()
{
_codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_codebasePath);
_registryPath = Path.Combine(_codebasePath, "test-managed-projects.json");
var paths = new WorkspacePaths(new RootDir(Path.Combine(_codebasePath, ".ai-dev")));
_service = new WorkspaceService(paths, new AtomicFileWriter(), registryFilePath: _registryPath);
}

public void Dispose()
{
if (Directory.Exists(_codebasePath))
Directory.Delete(_codebasePath, recursive: true);
}

[Fact]
public void CreateProject_WhenSlugInvalid_ReturnsError()
{
var (service, codebasePath) = CreateService();

var result = service.CreateProject(codebasePath, "Invalid Slug", "Demo", null);
var result = _service.CreateProject(_codebasePath, "Invalid Slug", "Demo", null);

result.ShouldBeOfType<Err<AiDev.Models.Unit>>();
}

[Fact]
public void CreateProject_WhenValid_ReturnsOk()
{
var (service, codebasePath) = CreateService();
var paths = new WorkspacePaths(new RootDir(Path.Combine(codebasePath, ".ai-dev")));
var paths = new WorkspacePaths(new RootDir(Path.Combine(_codebasePath, ".ai-dev")));

var result = service.CreateProject(codebasePath, "demo-project", "Demo Project", "Main app");
var result = _service.CreateProject(_codebasePath, "demo-project", "Demo Project", "Main app");

result.ShouldBeOfType<Ok<AiDev.Models.Unit>>();
paths.ProjectJsonPath(new ProjectSlug("demo-project")).Exists().ShouldBeTrue();
}

[Fact]
public void UpdateProject_WhenMissing_ReturnsError()
public void CreateProject_WhenValid_StoresSlugInRegistry()
{
var (service, codebasePath) = CreateService();

var result = service.UpdateProject(new ProjectSlug("demo-project"), "Demo Project", null);
_service.CreateProject(_codebasePath, "demo-project", "Demo Project", null);

result.ShouldBeOfType<Err<AiDev.Models.Unit>>();
var json = File.ReadAllText(_registryPath);
json.ShouldContain("\"slug\"");
json.ShouldContain("demo-project");
}

private static (WorkspaceService service, string codebasePath) CreateService()
[Fact]
public void UpdateProject_WhenMissing_ReturnsError()
{
var codebasePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(codebasePath);
var holder = new ActiveWorkspaceHolder();
holder.Activate(codebasePath);
return (new WorkspaceService(holder, new AtomicFileWriter()), codebasePath);
var result = _service.UpdateProject(new ProjectSlug("demo-project"), "Demo Project", null);

result.ShouldBeOfType<Err<AiDev.Models.Unit>>();
}
}
10 changes: 4 additions & 6 deletions ai-dev-net.tests.winui/DecisionsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ public async Task SelectDecisionAsync_WhenChatHistoryExists_LoadsChatMessages()
await viewModel.SelectDecisionAsync(decision);

viewModel.SelectedDecision.ShouldNotBeNull();
viewModel.SelectedDecision.Id.ShouldBe("decision-1");
viewModel.SelectedDecision.Id.Value.ShouldBe("decision-1");
viewModel.ChatMessages.Count.ShouldBe(1);
viewModel.ChatMessages[0].Content.ShouldBe("Use Option A");
}
Expand Down Expand Up @@ -146,7 +146,7 @@ public async Task PollSelectedDecisionAsync_WhenDecisionWasResolved_UpdatesSelec
};

var runner = Substitute.For<IAgentRunnerService>();
var chatService = new DecisionChatService(_paths, runner, _projectStateNotifier, NullLogger<DecisionChatService>.Instance);
var chatService = new DecisionChatService(_paths, runner, Substitute.For<IAgentInboxService>(), _projectStateNotifier, NullLogger<DecisionChatService>.Instance);
var uiDispatcher = new ImmediateDispatcher();

var viewModel = new DecisionsViewModel(decisionsService, chatService, mainViewModel, uiDispatcher);
Expand All @@ -157,10 +157,8 @@ private static async Task InvokePollSelectedDecisionAsync(DecisionsViewModel vie
{
var method = typeof(DecisionsViewModel).GetMethod("PollSelectedDecisionAsync", BindingFlags.Instance | BindingFlags.NonPublic);
method.ShouldNotBeNull();
var task = method!.Invoke(viewModel, null) as Task;
task.ShouldNotBeNull();
var nonNullTask = task;
await nonNullTask!;
var nonNullTask = (method!.Invoke(viewModel, null) as Task).ShouldNotBeNull();
await nonNullTask;
}

private sealed class ImmediateDispatcher : IUiDispatcher
Expand Down
38 changes: 21 additions & 17 deletions ai-dev-net.tests.winui/ProjectSettingsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ public sealed class ProjectSettingsViewModelTests : IDisposable
private readonly WorkspacePaths _paths;
private readonly AtomicFileWriter _fileWriter = new();
private readonly ProjectMutationCoordinator _coordinator = new();
private readonly string _registryPath;
private readonly string _templatesDir;

public ProjectSettingsViewModelTests()
{
_rootPath = Path.Combine(Path.GetTempPath(), $"project-settings-vm-tests-{Guid.NewGuid():N}");
Directory.CreateDirectory(_rootPath);
_paths = new WorkspacePaths(new RootDir(_rootPath));
_paths = new WorkspacePaths(new RootDir(Path.Combine(_rootPath, ".ai-dev")));
_registryPath = Path.Combine(_rootPath, "test-managed-projects.json");
_templatesDir = Path.Combine(_rootPath, "agent-templates");
}

public void Dispose()
Expand Down Expand Up @@ -77,8 +81,8 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenTargetExecutorHasNoModels_R
public async Task ApplyBulkSwitchToExecutorAsync_WhenValid_UpdatesExecutorModelAndThinkingLevel()
{
var modelRegistry = Substitute.For<IModelRegistry>();
modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue)
modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic)
]);

var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry);
Expand All @@ -103,9 +107,9 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenValid_UpdatesExecutorModelA
public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideProvided_UsesOverrideForAllAgents()
{
var modelRegistry = Substitute.For<IModelRegistry>();
modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue),
new ModelDescriptor("anthropic-sonnet", "Anthropic Sonnet", AgentExecutorName.AnthropicValue, ModelCapabilities.Streaming | ModelCapabilities.ToolCalling | ModelCapabilities.Reasoning)
modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic),
new ModelDescriptor("anthropic-sonnet", "Anthropic Sonnet", AgentExecutorName.Anthropic, ModelCapabilities.Streaming | ModelCapabilities.ToolCalling | ModelCapabilities.Reasoning)
]);

var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry);
Expand All @@ -126,8 +130,8 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideProvided_UsesO
public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideIsUnsupported_ReturnsError()
{
var modelRegistry = Substitute.For<IModelRegistry>();
modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.AnthropicValue)
modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([
new ModelDescriptor("anthropic-haiku", "Anthropic Haiku", AgentExecutorName.Anthropic)
]);

var (viewModel, _, agentService, _) = CreateViewModel(modelRegistry: modelRegistry);
Expand All @@ -145,10 +149,10 @@ public async Task ApplyBulkSwitchToExecutorAsync_WhenModelOverrideIsUnsupported_
public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctSortedIds()
{
var modelRegistry = Substitute.For<IModelRegistry>();
modelRegistry.GetModelsForExecutor(AgentExecutorName.AnthropicValue).Returns([
new ModelDescriptor("z-model", "Z Model", AgentExecutorName.AnthropicValue),
new ModelDescriptor("a-model", "A Model", AgentExecutorName.AnthropicValue),
new ModelDescriptor("A-model", "A Model Duplicate", AgentExecutorName.AnthropicValue)
modelRegistry.GetModelsForExecutor(AgentExecutorName.Anthropic).Returns([
new ModelDescriptor("z-model", "Z Model", AgentExecutorName.Anthropic),
new ModelDescriptor("a-model", "A Model", AgentExecutorName.Anthropic),
new ModelDescriptor("A-model", "A Model Duplicate", AgentExecutorName.Anthropic)
]);

var (viewModel, _, _, _) = CreateViewModel(modelRegistry: modelRegistry);
Expand All @@ -163,11 +167,11 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS
IModelRegistry? modelRegistry = null,
IReadOnlyList<IAgentExecutor>? executors = null)
{
var workspaceService = new WorkspaceService(_paths, _fileWriter);
var templatesService = new AgentTemplatesService(_paths);
var workspaceService = new WorkspaceService(_paths, _fileWriter, registryFilePath: _registryPath);
var templatesService = new AgentTemplatesService(_templatesDir);
var effectiveModelRegistry = modelRegistry ?? Substitute.For<IModelRegistry>();
if (modelRegistry is null)
effectiveModelRegistry.GetModelsForExecutor(Arg.Any<string>()).Returns([]);
effectiveModelRegistry.GetModelsForExecutor(Arg.Any<AgentExecutorName>()).Returns([]);

var agentService = new AgentService(
_paths,
Expand All @@ -188,7 +192,7 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS

if (withActiveProject)
{
workspaceService.CreateProject("demo-project", "Demo Project", "Test project").ShouldBeOfType<Ok<Unit>>();
workspaceService.CreateProject(codebasePath: _rootPath, slug: "demo-project", name: "Demo Project", description: "Test project").ShouldBeOfType<Ok<Unit>>();
mainViewModel.ActiveProject = workspaceService.GetProject(new ProjectSlug("demo-project"));
}

Expand All @@ -205,7 +209,7 @@ public void GetAvailableModelsForExecutor_WhenExecutorHasModels_ReturnsDistinctS

private void SeedProjectWithAgent(AgentService agentService)
{
var templatesService = new AgentTemplatesService(_paths);
var templatesService = new AgentTemplatesService(_templatesDir);
templatesService.CreateTemplate(new AgentTemplate
{
Slug = new AgentSlug("generic-standard"),
Expand Down
13 changes: 7 additions & 6 deletions ai-dev.core/Extensions/CoreServiceExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@ public static class CoreServiceExtensions
/// </summary>
public static IServiceCollection AddAiDevCore(this IServiceCollection services)
{
// WorkspacePaths is resolved lazily from the active workspace holder so that the holder
// can be populated (by activating a project) before any service first uses it.
services.AddSingleton<ActiveWorkspaceHolder>();
services.AddSingleton<WorkspacePaths>(sp => sp.GetRequiredService<ActiveWorkspaceHolder>().Paths);

// WorkspacePaths and (optionally) ActiveWorkspaceHolder are registered by the host
// (Program.cs for the API, App.xaml.cs for WinUI). AddAiDevCore consumes them.
services.AddSingleton<IDomainEventDispatcher, InProcessDomainEventDispatcher>();
services.AddSingleton<IDomainEventHandler<TaskAssigned>, TaskAssignedHandler>();
services.AddSingleton<ProjectStateChangedNotifier>();
Expand All @@ -37,7 +34,11 @@ public static IServiceCollection AddAiDevCore(this IServiceCollection services)
services.AddSingleton<ProjectMutationCoordinator>();
services.AddSingleton<ConsistencyCheckService>();
services.AddSingleton<SecretsService>();
services.AddSingleton<WorkspaceService>();
services.AddSingleton<WorkspaceService>(sp => new WorkspaceService(
sp.GetRequiredService<WorkspacePaths>(),
sp.GetRequiredService<AtomicFileWriter>(),
sp.GetService<ActiveWorkspaceHolder>(),
logger: sp.GetService<ILogger<WorkspaceService>>()));
services.AddSingleton<StudioSettingsService>();
services.AddSingleton<FeatureFlagsService>();
services.AddSingleton<AgentTemplatesService>();
Expand Down
Loading
Loading