From 8fe599d85488ba65998af49026996688ce04efbf Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 12:58:58 +0100 Subject: [PATCH 01/30] Rewrite app around agent sessions --- .gitignore | 4 + AGENTS.md | 11 +- Directory.Packages.props | 7 + DotPilot.Core/AGENTS.md | 12 +- .../AgentSessions/AgentSessionCommands.cs | 24 + .../AgentSessions/AgentSessionContracts.cs | 63 ++ .../AgentSessionHostContracts.cs | 17 + .../AgentSessions/AgentSessionStates.cs | 29 + .../AgentSessions/IAgentSessionService.cs | 26 + .../CommunicationPrimitives.cs | 93 --- .../RuntimeCommunicationProblemCode.cs | 16 - .../RuntimeCommunicationProblems.cs | 130 ---- .../EmbeddedRuntimeHostContracts.cs | 75 --- .../EmbeddedRuntimeTrafficPolicyContracts.cs | 32 - .../RuntimeFoundation/IAgentRuntimeClient.cs | 13 - .../IRuntimeFoundationCatalog.cs | 6 - .../RuntimeFoundationContracts.cs | 31 - .../RuntimeFoundationIssues.cs | 16 - .../RuntimeFoundationStates.cs | 15 - .../RuntimeSessionArchiveContracts.cs | 25 - .../IToolchainCenterCatalog.cs | 6 - .../ToolchainCenterContracts.cs | 64 -- .../ToolchainCenter/ToolchainCenterIssues.cs | 20 - .../ToolchainCenter/ToolchainCenterStates.cs | 71 --- .../Features/Workbench/IWorkbenchCatalog.cs | 6 - .../Workbench/WorkbenchDocumentContracts.cs | 15 - .../Workbench/WorkbenchInspectorContracts.cs | 14 - .../Features/Workbench/WorkbenchIssues.cs | 15 - .../Features/Workbench/WorkbenchModes.cs | 27 - .../Workbench/WorkbenchRepositoryContracts.cs | 9 - .../Workbench/WorkbenchSessionContracts.cs | 7 - .../Workbench/WorkbenchSettingsContracts.cs | 22 - .../Features/Workbench/WorkbenchSnapshot.cs | 15 - DotPilot.Runtime.Host/AGENTS.md | 5 +- .../AgentSessions/AgentProfileGrain.cs | 30 + .../AgentSessionHostBuilderExtensions.cs | 39 ++ .../AgentSessions/AgentSessionHostNames.cs | 15 + .../AgentSessions/AgentSessionHostOptions.cs | 13 + .../Features/AgentSessions/SessionGrain.cs | 31 + .../RuntimeFoundation/ArtifactGrain.cs | 22 - .../EmbeddedRuntimeGrainGuards.cs | 16 - .../EmbeddedRuntimeHostBuilderExtensions.cs | 60 -- .../EmbeddedRuntimeHostCatalog.cs | 39 -- .../EmbeddedRuntimeHostLifecycleService.cs | 14 - .../EmbeddedRuntimeHostNames.cs | 28 - .../EmbeddedRuntimeHostOptions.cs | 12 - .../EmbeddedRuntimeTrafficPolicy.cs | 124 ---- .../EmbeddedRuntimeTrafficPolicyCatalog.cs | 25 - .../Features/RuntimeFoundation/FleetGrain.cs | 22 - .../Features/RuntimeFoundation/PolicyGrain.cs | 22 - .../RuntimeFoundation/SessionGrain.cs | 22 - .../RuntimeFoundation/WorkspaceGrain.cs | 22 - DotPilot.Runtime/AGENTS.md | 12 +- DotPilot.Runtime/DotPilot.Runtime.csproj | 7 + .../AgentSessionCommandProbe.cs} | 75 ++- .../AgentSessionDeterministicIdentity.cs} | 15 +- .../AgentSessionJsonSerializerContext.cs | 8 + .../AgentSessionProviderCatalog.cs | 54 ++ .../AgentSessions/AgentSessionService.cs | 575 ++++++++++++++++++ ...AgentSessionServiceCollectionExtensions.cs | 51 ++ .../AgentSessionStorageOptions.cs | 10 + .../Features/AgentSessions/DebugChatClient.cs | 108 ++++ .../LocalAgentSessionDbContext.cs | 108 ++++ .../AgentFrameworkRuntimeClient.cs | 447 -------------- .../DeterministicAgentRuntimeClient.cs | 60 -- .../DeterministicAgentTurnEngine.cs | 171 ------ .../ProviderToolchainNames.cs | 13 - .../ProviderToolchainProbe.cs | 77 --- .../RuntimeFoundationCatalog.cs | 109 ---- ...meFoundationServiceCollectionExtensions.cs | 29 - .../RuntimePersistenceOptions.cs | 19 - .../RuntimeSessionArchiveStore.cs | 123 ---- .../ToolchainCenter/ToolchainCenterCatalog.cs | 144 ----- .../ToolchainConfigurationSignal.cs | 10 - .../ToolchainDeterministicIdentity.cs | 22 - .../ToolchainProviderProfile.cs | 14 - .../ToolchainProviderProfiles.cs | 79 --- .../ToolchainProviderSnapshotFactory.cs | 395 ------------ .../Features/Workbench/GitIgnoreRuleSet.cs | 141 ----- .../Features/Workbench/WorkbenchCatalog.cs | 31 - .../Features/Workbench/WorkbenchSeedData.cs | 241 -------- .../Workbench/WorkbenchWorkspaceResolver.cs | 71 --- .../WorkbenchWorkspaceSnapshotBuilder.cs | 352 ----------- .../AgentSessions/AgentSessionServiceTests.cs | 146 +++++ .../ControlPlaneDomainContractsTests.cs | 4 +- ...ministicAgentRuntimeClientContractTests.cs | 82 --- .../RuntimeCommunicationProblemsTests.cs | 82 --- .../AgentFrameworkRuntimeClientTests.cs | 371 ----------- .../EmbeddedRuntimeHostTests.cs | 288 --------- ...mbeddedRuntimeTrafficPolicyCatalogTests.cs | 69 --- .../ProviderToolchainProbeTests.cs | 63 -- .../RuntimeFoundationCatalogTests.cs | 279 --------- .../ToolchainCenterCatalogTests.cs | 131 ---- .../ToolchainCommandProbeTests.cs | 146 ----- .../ToolchainProviderSnapshotFactoryTests.cs | 193 ------ .../Workbench/PresentationViewModelTests.cs | 84 --- .../Workbench/TemporaryWorkbenchDirectory.cs | 52 -- .../Workbench/WorkbenchCatalogTests.cs | 45 -- DotPilot.Tests/GlobalUsings.cs | 7 - DotPilot.UITests/AGENTS.md | 2 +- .../AgentSessions/GivenChatSessionsShell.cs | 176 ++++++ .../Features/Workbench/GivenWorkbenchShell.cs | 449 -------------- DotPilot.UITests/Harness/TestBase.cs | 240 +++++++- DotPilot.slnx | 1 - DotPilot/AGENTS.md | 13 +- DotPilot/App.xaml.cs | 20 +- DotPilot/DotPilot.csproj | 9 + DotPilot/GlobalUsings.cs | 2 - DotPilot/Presentation/AgentBuilderModels.cs | 47 +- DotPilot/Presentation/AsyncCommand.cs | 48 ++ DotPilot/Presentation/ChatDesignModels.cs | 33 +- .../Controls/AgentBasicInfoSection.xaml | 170 +++--- .../Controls/AgentPromptSection.xaml | 94 +-- .../Presentation/Controls/AgentSidebar.xaml | 108 ++-- .../Controls/AgentSkillsSection.xaml | 20 +- .../Presentation/Controls/ChatComposer.xaml | 136 ++--- .../Controls/ChatConversationView.xaml | 68 ++- .../Controls/ChatConversationView.xaml.cs | 2 +- .../Presentation/Controls/ChatInfoPanel.xaml | 124 ++-- .../Presentation/Controls/ChatSidebar.xaml | 101 +-- .../Controls/RuntimeFoundationPanel.xaml | 162 ----- .../Controls/RuntimeFoundationPanel.xaml.cs | 9 - .../Presentation/Controls/SettingsShell.xaml | 179 ++++-- .../Controls/SettingsSidebar.xaml | 39 +- .../Controls/ToolchainCenterPanel.xaml | 431 ------------- .../Controls/ToolchainCenterPanel.xaml.cs | 9 - .../Controls/WorkbenchActivityPanel.xaml | 84 --- .../Controls/WorkbenchActivityPanel.xaml.cs | 9 - .../Controls/WorkbenchDocumentSurface.xaml | 129 ---- .../Controls/WorkbenchDocumentSurface.xaml.cs | 9 - .../Controls/WorkbenchInspectorPanel.xaml | 130 ---- .../Controls/WorkbenchInspectorPanel.xaml.cs | 9 - .../Controls/WorkbenchSidebar.xaml | 217 ------- .../Controls/WorkbenchSidebar.xaml.cs | 9 - DotPilot/Presentation/MainPage.xaml | 17 +- DotPilot/Presentation/MainViewModel.cs | 441 ++++++++------ DotPilot/Presentation/SecondPage.xaml | 61 +- DotPilot/Presentation/SecondViewModel.cs | 261 ++++++-- DotPilot/Presentation/SettingsViewModel.cs | 206 ++++--- .../WorkbenchPresentationModels.cs | 38 -- ...R-0001-agent-control-plane-architecture.md | 18 +- ...003-vertical-slices-and-ui-only-uno-app.md | 16 +- docs/Architecture.md | 354 +++-------- .../agent-control-plane-experience.md | 273 ++++----- docs/Features/embedded-orleans-host.md | 33 +- .../embedded-runtime-orchestration.md | 99 --- .../runtime-communication-contracts.md | 59 -- docs/Features/toolchain-center.md | 65 -- docs/Features/workbench-foundation.md | 59 -- 149 files changed, 3363 insertions(+), 9035 deletions(-) create mode 100644 DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs create mode 100644 DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs create mode 100644 DotPilot.Core/Features/AgentSessions/AgentSessionHostContracts.cs create mode 100644 DotPilot.Core/Features/AgentSessions/AgentSessionStates.cs create mode 100644 DotPilot.Core/Features/AgentSessions/IAgentSessionService.cs delete mode 100644 DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs delete mode 100644 DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs delete mode 100644 DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs delete mode 100644 DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs delete mode 100644 DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs delete mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs delete mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs delete mode 100644 DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs delete mode 100644 DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchIssues.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchModes.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs delete mode 100644 DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/AgentProfileGrain.cs create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs delete mode 100644 DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs rename DotPilot.Runtime/Features/{ToolchainCenter/ToolchainCommandProbe.cs => AgentSessions/AgentSessionCommandProbe.cs} (75%) rename DotPilot.Runtime/Features/{RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs => AgentSessions/AgentSessionDeterministicIdentity.cs} (54%) create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionJsonSerializerContext.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/DebugChatClient.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionDbContext.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs delete mode 100644 DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs delete mode 100644 DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs delete mode 100644 DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs delete mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs delete mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs delete mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs delete mode 100644 DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentSessionServiceTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeCommunication/DeterministicAgentRuntimeClientContractTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeCommunication/RuntimeCommunicationProblemsTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs delete mode 100644 DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs delete mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs delete mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs delete mode 100644 DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs delete mode 100644 DotPilot.Tests/Features/Workbench/PresentationViewModelTests.cs delete mode 100644 DotPilot.Tests/Features/Workbench/TemporaryWorkbenchDirectory.cs delete mode 100644 DotPilot.Tests/Features/Workbench/WorkbenchCatalogTests.cs create mode 100644 DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs delete mode 100644 DotPilot.UITests/Features/Workbench/GivenWorkbenchShell.cs create mode 100644 DotPilot/Presentation/AsyncCommand.cs delete mode 100644 DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml delete mode 100644 DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml.cs delete mode 100644 DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml delete mode 100644 DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml.cs delete mode 100644 DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml delete mode 100644 DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs delete mode 100644 DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml delete mode 100644 DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs delete mode 100644 DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml delete mode 100644 DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs delete mode 100644 DotPilot/Presentation/Controls/WorkbenchSidebar.xaml delete mode 100644 DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs delete mode 100644 DotPilot/Presentation/WorkbenchPresentationModels.cs delete mode 100644 docs/Features/embedded-runtime-orchestration.md delete mode 100644 docs/Features/runtime-communication-contracts.md delete mode 100644 docs/Features/toolchain-center.md delete mode 100644 docs/Features/workbench-foundation.md diff --git a/.gitignore b/.gitignore index b50ac44..e559031 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,10 @@ artifacts/ *.svclog *.scc +# Local planning and metrics scratch files +CodeMetricsConfig.txt +*.plan.md + # Chutzpah Test files _Chutzpah* diff --git a/AGENTS.md b/AGENTS.md index cb08f64..f72ee74 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -145,6 +145,7 @@ For this app: - local and CI build commands must pass `-warnaserror`; warnings are not an acceptable "green" build state in this repository - do not run parallel `dotnet` or `MSBuild` work that shares the same checkout, target outputs, or NuGet package cache; the multi-target Uno app must build serially in CI to avoid `Uno.Resizetizer` file-lock failures - do not commit user-specific local paths, usernames, or machine-specific identifiers in tests, docs, snapshots, or fixtures; use neutral synthetic values so the repo stays portable and does not leak personal machine details +- keep local planning artifacts and analyzer scratch files out of git history: ignore `*.plan.md` and `CodeMetricsConfig.txt`, and do not commit or re-stage them because they are operator-local working files - quality gates should prefer analyzer-backed build failures over separate one-off CI tools; for overloaded methods and maintainability drift, enable build-time analyzers such as `CA1502` instead of adding a formatting-only gate - `Directory.Build.props` owns the shared analyzer and warning policy for future projects - `Directory.Packages.props` owns centrally managed package versions @@ -161,6 +162,12 @@ For this app: - never claim an epic is complete until its current GitHub scope is verified against the live issue graph; check which issues are real children versus issues that merely depend on the epic or belong to a different parent epic - Desktop responsiveness is a product requirement: avoid synchronous probe, filesystem, network, or process work on UI-facing construction and navigation paths so the app stays fast and immediately reactive - Do not invent a repo-specific product framing such as "workbench" unless the active issue or feature spec explicitly uses it; implement the app features described in the backlog instead of turning internal implementation language into the product narrative +- The primary product IA is a desktop chat client for local agents: session list, active session transcript, terminal-like streaming activity, agent management, and provider settings must be the default mental model instead of workbench, issue-tracking, domain-browser, or toolchain-center concepts +- User-facing UI must not expose backlog numbers, issue labels, workstream labels, "workbench", "domain", or similar internal planning and architecture language unless a feature explicitly exists to show source-control metadata +- Provider integrations should stay SDK-first: when Codex, Claude Code, GitHub Copilot, or debug/test providers already expose an `IChatClient`-style abstraction, build agent orchestration on top of that instead of inventing parallel request/result wrappers without a clear gap +- Persist app models and durable session state through `SQLite` plus `EF Core` when the data must survive restarts; do not keep the core chat/session experience trapped in seed data or transient in-memory catalogs +- Model agents and sessions as Orleans grains, with each session acting as the workflow container that coordinates participant agents and streams messages, tool activity, and status updates into the UI +- Do not keep legacy product slices alive during a rewrite: when `Workbench`, `ToolchainCenter`, legacy runtime demos, or similar prototype surfaces are being replaced, remove them instead of leaving a parallel legacy path in the codebase - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication - meaningful GitHub review comments must be evaluated and fixed when they still apply even if the original PR was closed; closed review threads are not a reason to ignore valid engineering feedback @@ -363,7 +370,7 @@ Ask first: - Keep validation and release GitHub Actions separate, with descriptive names and filenames instead of a generic `ci.yml`. - Keep the validation workflow focused on build and automated test feedback, and keep release responsibilities in a dedicated workflow that bumps versioning, publishes desktop artifacts, and creates the GitHub Release with feature notes. - Keep `dotPilot` positioned as a general agent control plane, not a coding-only shell. -- Reuse the current Uno desktop shell direction instead of replacing it with a wholly different layout when evolving the product. +- Keep the visible product direction aligned with desktop chat apps such as Codex and Claude: sessions first, chat first, streaming first, with repo and git actions as optional utilities inside a session instead of the primary navigation model. - Keep provider integrations SDK-first where good typed SDKs already exist. - Keep evaluation and observability aligned with official Microsoft `.NET` AI guidance when building agent-quality and trust features. @@ -378,3 +385,5 @@ Ask first: - Switching desktop Uno pages into stacked or mobile-style responsive layouts during resize work unless the user explicitly asks for a different composition; desktop pages must stay desktop-first and protect geometry through sizing constraints instead. - Adding extra UI-test orchestration complexity when the actual goal is simply to run the tests and get an honest pass or fail result. - Planning `MLXSharp` into the first product wave before it is ready for real use. +- Letting internal implementation labels such as `Workbench`, issue numbers, or architecture slice names leak into the visible product wording or navigation when the app should behave like a clean desktop chat client. +- Leaving deprecated product slices, pages, view models, or contracts in place "for later cleanup" after the replacement direction is already chosen. diff --git a/Directory.Packages.props b/Directory.Packages.props index 3a8467b..750dd4d 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -20,7 +20,14 @@ + + + + + + + diff --git a/DotPilot.Core/AGENTS.md b/DotPilot.Core/AGENTS.md index 668f8e6..9719df0 100644 --- a/DotPilot.Core/AGENTS.md +++ b/DotPilot.Core/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md Project: `DotPilot.Core` -Stack: `.NET 10`, class library, feature-aligned contracts and provider-independent runtime foundations +Stack: `.NET 10`, class library, feature-aligned contracts for control-plane domain types and agent-session workflows ## Purpose @@ -13,20 +13,20 @@ Stack: `.NET 10`, class library, feature-aligned contracts and provider-independ - `DotPilot.Core.csproj` - `Features/ApplicationShell/AppConfig.cs` - `Features/ControlPlaneDomain/*` -- `Features/RuntimeCommunication/*` -- `Features/RuntimeFoundation/*` +- `Features/AgentSessions/*` ## Boundaries - Keep this project free of `Uno Platform`, XAML, brushes, and page/view-model concerns. - Organize code by vertical feature slice, not by shared horizontal folders such as generic `Services` or `Helpers`. - Prefer stable contracts, typed identifiers, and public interfaces here; concrete runtime integrations can live in separate libraries. +- Keep the active public surface centered on providers, agent profiles, sessions, transcript events, and Orleans grain contracts. - Keep provider-independent testing seams real and deterministic so CI can validate core flows without external CLIs. ## Local Commands - `build-core`: `dotnet build DotPilot.Core/DotPilot.Core.csproj` -- `test-core`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeFoundation` +- `test-core`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~AgentSessions` ## Applicable Skills @@ -38,5 +38,5 @@ Stack: `.NET 10`, class library, feature-aligned contracts and provider-independ ## Local Risks Or Protected Areas -- These contracts will become shared dependencies across future slices, so naming drift or unclear boundaries will amplify quickly. -- Avoid baking provider-specific assumptions into the core runtime contracts unless an ADR or feature spec explicitly requires them. +- These contracts will become shared dependencies across the app, runtime, host, and tests, so naming drift or unclear boundaries will amplify quickly. +- Avoid baking CLI-process assumptions into the core contracts; keep them expressed in provider/session terms that can work with SDK-backed chat clients. diff --git a/DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs b/DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs new file mode 100644 index 0000000..1181be3 --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs @@ -0,0 +1,24 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.AgentSessions; + +public sealed record CreateAgentProfileCommand( + string Name, + AgentRoleKind Role, + AgentProviderKind ProviderKind, + string ModelName, + string SystemPrompt, + IReadOnlyList Capabilities); + +public sealed record CreateSessionCommand( + string Title, + AgentProfileId AgentProfileId); + +public sealed record SendSessionMessageCommand( + SessionId SessionId, + string Message); + +public sealed record UpdateProviderPreferenceCommand( + AgentProviderKind ProviderKind, + bool IsEnabled); + diff --git a/DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs b/DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs new file mode 100644 index 0000000..aafd4be --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs @@ -0,0 +1,63 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.AgentSessions; + +public sealed record ProviderActionDescriptor( + string Label, + string Summary, + string Command); + +public sealed record ProviderStatusDescriptor( + ProviderId Id, + AgentProviderKind Kind, + string DisplayName, + string CommandName, + AgentProviderStatus Status, + string StatusSummary, + string? InstalledVersion, + bool IsEnabled, + bool CanCreateAgents, + IReadOnlyList Actions); + +public sealed record AgentProfileSummary( + AgentProfileId Id, + string Name, + AgentRoleKind Role, + AgentProviderKind ProviderKind, + string ProviderDisplayName, + string ModelName, + string SystemPrompt, + IReadOnlyList Capabilities, + DateTimeOffset CreatedAt); + +public sealed record SessionListItem( + SessionId Id, + string Title, + string Preview, + string StatusSummary, + DateTimeOffset UpdatedAt, + AgentProfileId PrimaryAgentId, + string PrimaryAgentName, + string ProviderDisplayName); + +public sealed record SessionStreamEntry( + string Id, + SessionId SessionId, + SessionStreamEntryKind Kind, + string Author, + string Text, + DateTimeOffset Timestamp, + AgentProfileId? AgentProfileId = null, + string? AccentLabel = null); + +public sealed record SessionTranscriptSnapshot( + SessionListItem Session, + IReadOnlyList Entries, + IReadOnlyList Participants); + +public sealed record AgentWorkspaceSnapshot( + IReadOnlyList Sessions, + IReadOnlyList Agents, + IReadOnlyList Providers, + SessionId? SelectedSessionId); + diff --git a/DotPilot.Core/Features/AgentSessions/AgentSessionHostContracts.cs b/DotPilot.Core/Features/AgentSessions/AgentSessionHostContracts.cs new file mode 100644 index 0000000..4895654 --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/AgentSessionHostContracts.cs @@ -0,0 +1,17 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.AgentSessions; + +public interface ISessionGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(SessionDescriptor session); +} + +public interface IAgentProfileGrain : IGrainWithStringKey +{ + ValueTask GetAsync(); + + ValueTask UpsertAsync(AgentProfileDescriptor agentProfile); +} diff --git a/DotPilot.Core/Features/AgentSessions/AgentSessionStates.cs b/DotPilot.Core/Features/AgentSessions/AgentSessionStates.cs new file mode 100644 index 0000000..5258f07 --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/AgentSessionStates.cs @@ -0,0 +1,29 @@ +namespace DotPilot.Core.Features.AgentSessions; + +public enum AgentProviderKind +{ + Debug, + Codex, + ClaudeCode, + GitHubCopilot, +} + +public enum AgentProviderStatus +{ + Ready, + RequiresSetup, + Disabled, + Unsupported, + Error, +} + +public enum SessionStreamEntryKind +{ + UserMessage, + AssistantMessage, + ToolStarted, + ToolCompleted, + Status, + Error, +} + diff --git a/DotPilot.Core/Features/AgentSessions/IAgentSessionService.cs b/DotPilot.Core/Features/AgentSessions/IAgentSessionService.cs new file mode 100644 index 0000000..82aa083 --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/IAgentSessionService.cs @@ -0,0 +1,26 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.AgentSessions; + +public interface IAgentSessionService +{ + ValueTask GetWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken); + + ValueTask CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken); + + ValueTask CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken); + + ValueTask UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken); + + IAsyncEnumerable SendMessageAsync( + SendSessionMessageCommand command, + CancellationToken cancellationToken); +} diff --git a/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs b/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs deleted file mode 100644 index f883b2e..0000000 --- a/DotPilot.Core/Features/RuntimeCommunication/CommunicationPrimitives.cs +++ /dev/null @@ -1,93 +0,0 @@ -using System.Collections.ObjectModel; -using System.Diagnostics.CodeAnalysis; - -namespace ManagedCode.Communication; - -public sealed class Problem -{ - private readonly Dictionary> _validationErrors = new(StringComparer.Ordinal); - - private Problem(string errorCode, string detail, int statusCode) - { - ArgumentException.ThrowIfNullOrWhiteSpace(errorCode); - ArgumentException.ThrowIfNullOrWhiteSpace(detail); - - ErrorCode = errorCode; - Detail = detail; - StatusCode = statusCode; - } - - public string ErrorCode { get; } - - public string Detail { get; } - - public int StatusCode { get; } - - public IReadOnlyDictionary> ValidationErrors => new ReadOnlyDictionary>(_validationErrors); - - public static Problem Create(TCode code, string detail, int statusCode) - where TCode : struct, Enum - { - return new Problem(code.ToString(), detail, statusCode); - } - - public void AddValidationError(string fieldName, string errorMessage) - { - ArgumentException.ThrowIfNullOrWhiteSpace(fieldName); - ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); - - if (_validationErrors.TryGetValue(fieldName, out var existingErrors)) - { - _validationErrors[fieldName] = [.. existingErrors, errorMessage]; - return; - } - - _validationErrors[fieldName] = [errorMessage]; - } - - public bool HasErrorCode(TCode code) - where TCode : struct, Enum - { - return string.Equals(ErrorCode, code.ToString(), StringComparison.Ordinal); - } - - public bool InvalidField(string fieldName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(fieldName); - return _validationErrors.ContainsKey(fieldName); - } -} - -[SuppressMessage( - "Design", - "CA1000:Do not declare static members on generic types", - Justification = "The result contract intentionally exposes static success/failure factories to preserve the existing lightweight communication API.")] -public sealed class Result -{ - private Result(T? value, Problem? problem) - { - Value = value; - Problem = problem; - } - - public T? Value { get; } - - public Problem? Problem { get; } - - public bool IsSuccess => Problem is null; - - public bool IsFailed => !IsSuccess; - - public bool HasProblem => Problem is not null; - - public static Result Succeed(T value) - { - return new Result(value, problem: null); - } - - public static Result Fail(Problem problem) - { - ArgumentNullException.ThrowIfNull(problem); - return new Result(value: default, problem); - } -} diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs deleted file mode 100644 index 0e51348..0000000 --- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblemCode.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DotPilot.Core.Features.RuntimeCommunication; - -public enum RuntimeCommunicationProblemCode -{ - PromptRequired, - ProviderUnavailable, - ProviderAuthenticationRequired, - ProviderMisconfigured, - ProviderOutdated, - RuntimeHostUnavailable, - OrchestrationUnavailable, - PolicyRejected, - SessionArchiveMissing, - ResumeCheckpointMissing, - SessionArchiveCorrupted, -} diff --git a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs b/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs deleted file mode 100644 index 91d2176..0000000 --- a/DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs +++ /dev/null @@ -1,130 +0,0 @@ -using System.Globalization; -using System.Net; -using DotPilot.Core.Features.ControlPlaneDomain; -using ManagedCode.Communication; - -namespace DotPilot.Core.Features.RuntimeCommunication; - -public static class RuntimeCommunicationProblems -{ - private const string PromptField = "Prompt"; - private const string PromptRequiredDetail = "Prompt is required before the runtime can execute a turn."; - private const string ProviderUnavailableFormat = "{0} is unavailable in the current environment."; - private const string ProviderAuthenticationRequiredFormat = "{0} requires authentication before the runtime can execute a turn."; - private const string ProviderMisconfiguredFormat = "{0} is misconfigured and cannot execute a runtime turn."; - private const string ProviderOutdatedFormat = "{0} is outdated and must be updated before the runtime can execute a turn."; - private const string RuntimeHostUnavailableDetail = "The embedded runtime host is unavailable for the requested operation."; - private const string OrchestrationUnavailableDetail = "The orchestration runtime is unavailable for the requested operation."; - private const string PolicyRejectedFormat = "The requested action was rejected by policy: {0}."; - private const string SessionArchiveMissingFormat = "No persisted runtime session archive exists for session {0}."; - private const string ResumeCheckpointMissingFormat = "Session {0} does not have a checkpoint that can be resumed."; - private const string SessionArchiveCorruptedFormat = "Session {0} has corrupted persisted runtime state."; - - public static Problem InvalidPrompt() - { - var problem = Problem.Create( - RuntimeCommunicationProblemCode.PromptRequired, - PromptRequiredDetail, - (int)HttpStatusCode.BadRequest); - - problem.AddValidationError(PromptField, PromptRequiredDetail); - return problem; - } - - public static Problem ProviderUnavailable(ProviderConnectionStatus status, string providerDisplayName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(providerDisplayName); - - return status switch - { - ProviderConnectionStatus.Available => throw new ArgumentOutOfRangeException(nameof(status), status, "Available status does not map to a problem."), - ProviderConnectionStatus.Unavailable => CreateProblem( - RuntimeCommunicationProblemCode.ProviderUnavailable, - ProviderUnavailableFormat, - providerDisplayName, - HttpStatusCode.ServiceUnavailable), - ProviderConnectionStatus.RequiresAuthentication => CreateProblem( - RuntimeCommunicationProblemCode.ProviderAuthenticationRequired, - ProviderAuthenticationRequiredFormat, - providerDisplayName, - HttpStatusCode.Unauthorized), - ProviderConnectionStatus.Misconfigured => CreateProblem( - RuntimeCommunicationProblemCode.ProviderMisconfigured, - ProviderMisconfiguredFormat, - providerDisplayName, - HttpStatusCode.FailedDependency), - ProviderConnectionStatus.Outdated => CreateProblem( - RuntimeCommunicationProblemCode.ProviderOutdated, - ProviderOutdatedFormat, - providerDisplayName, - HttpStatusCode.PreconditionFailed), - _ => throw new ArgumentOutOfRangeException(nameof(status), status, "Unknown provider status."), - }; - } - - public static Problem RuntimeHostUnavailable() - { - return Problem.Create( - RuntimeCommunicationProblemCode.RuntimeHostUnavailable, - RuntimeHostUnavailableDetail, - (int)HttpStatusCode.ServiceUnavailable); - } - - public static Problem OrchestrationUnavailable() - { - return Problem.Create( - RuntimeCommunicationProblemCode.OrchestrationUnavailable, - OrchestrationUnavailableDetail, - (int)HttpStatusCode.ServiceUnavailable); - } - - public static Problem PolicyRejected(string policyName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(policyName); - - return CreateProblem( - RuntimeCommunicationProblemCode.PolicyRejected, - PolicyRejectedFormat, - policyName, - HttpStatusCode.Forbidden); - } - - public static Problem SessionArchiveMissing(SessionId sessionId) - { - return CreateProblem( - RuntimeCommunicationProblemCode.SessionArchiveMissing, - SessionArchiveMissingFormat, - sessionId.ToString(), - HttpStatusCode.NotFound); - } - - public static Problem ResumeCheckpointMissing(SessionId sessionId) - { - return CreateProblem( - RuntimeCommunicationProblemCode.ResumeCheckpointMissing, - ResumeCheckpointMissingFormat, - sessionId.ToString(), - HttpStatusCode.Conflict); - } - - public static Problem SessionArchiveCorrupted(SessionId sessionId) - { - return CreateProblem( - RuntimeCommunicationProblemCode.SessionArchiveCorrupted, - SessionArchiveCorruptedFormat, - sessionId.ToString(), - HttpStatusCode.InternalServerError); - } - - private static Problem CreateProblem( - RuntimeCommunicationProblemCode code, - string detailFormat, - string value, - HttpStatusCode statusCode) - { - return Problem.Create( - code, - string.Format(CultureInfo.InvariantCulture, detailFormat, value), - (int)statusCode); - } -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs deleted file mode 100644 index a3f14f2..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs +++ /dev/null @@ -1,75 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Core.Features.RuntimeFoundation; - -public enum EmbeddedRuntimeHostState -{ - Stopped, - Starting, - Running, -} - -public enum EmbeddedRuntimeClusteringMode -{ - Localhost, -} - -public enum EmbeddedRuntimeStorageMode -{ - InMemory, -} - -public sealed record EmbeddedRuntimeGrainDescriptor( - string Name, - string Summary); - -public sealed record EmbeddedRuntimeHostSnapshot( - EmbeddedRuntimeHostState State, - EmbeddedRuntimeClusteringMode ClusteringMode, - EmbeddedRuntimeStorageMode GrainStorageMode, - EmbeddedRuntimeStorageMode ReminderStorageMode, - string ClusterId, - string ServiceId, - int SiloPort, - int GatewayPort, - IReadOnlyList Grains); - -public interface IEmbeddedRuntimeHostCatalog -{ - EmbeddedRuntimeHostSnapshot GetSnapshot(); -} - -public interface ISessionGrain : IGrainWithStringKey -{ - ValueTask GetAsync(); - - ValueTask UpsertAsync(SessionDescriptor session); -} - -public interface IWorkspaceGrain : IGrainWithStringKey -{ - ValueTask GetAsync(); - - ValueTask UpsertAsync(WorkspaceDescriptor workspace); -} - -public interface IFleetGrain : IGrainWithStringKey -{ - ValueTask GetAsync(); - - ValueTask UpsertAsync(FleetDescriptor fleet); -} - -public interface IPolicyGrain : IGrainWithStringKey -{ - ValueTask GetAsync(); - - ValueTask UpsertAsync(PolicyDescriptor policy); -} - -public interface IArtifactGrain : IGrainWithStringKey -{ - ValueTask GetAsync(); - - ValueTask UpsertAsync(ArtifactDescriptor artifact); -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs deleted file mode 100644 index 5c53ae0..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace DotPilot.Core.Features.RuntimeFoundation; - -public sealed record EmbeddedRuntimeTrafficTransitionDescriptor( - string Source, - string Target, - IReadOnlyList SourceMethods, - IReadOnlyList TargetMethods, - bool IsReentrant); - -public sealed record EmbeddedRuntimeTrafficPolicySnapshot( - int IssueNumber, - string IssueLabel, - string Summary, - string MermaidDiagram, - IReadOnlyList AllowedTransitions); - -public sealed record EmbeddedRuntimeTrafficProbe( - Type SourceGrainType, - string SourceMethod, - Type TargetGrainType, - string TargetMethod); - -public sealed record EmbeddedRuntimeTrafficDecision( - bool IsAllowed, - string MermaidDiagram); - -public interface IEmbeddedRuntimeTrafficPolicyCatalog -{ - EmbeddedRuntimeTrafficPolicySnapshot GetSnapshot(); - - EmbeddedRuntimeTrafficDecision Evaluate(EmbeddedRuntimeTrafficProbe probe); -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs b/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs deleted file mode 100644 index 8eb6db8..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/IAgentRuntimeClient.cs +++ /dev/null @@ -1,13 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using ManagedCode.Communication; - -namespace DotPilot.Core.Features.RuntimeFoundation; - -public interface IAgentRuntimeClient -{ - ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken); - - ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken); - - ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken); -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs b/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs deleted file mode 100644 index 3a4d8db..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/IRuntimeFoundationCatalog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotPilot.Core.Features.RuntimeFoundation; - -public interface IRuntimeFoundationCatalog -{ - RuntimeFoundationSnapshot GetSnapshot(); -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs deleted file mode 100644 index d273416..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs +++ /dev/null @@ -1,31 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Core.Features.RuntimeFoundation; - -public sealed record RuntimeSliceDescriptor( - int IssueNumber, - string IssueLabel, - string Name, - string Summary, - RuntimeSliceState State); - -public sealed record RuntimeFoundationSnapshot( - string EpicLabel, - string Summary, - string DeterministicClientName, - string DeterministicProbePrompt, - IReadOnlyList Slices, - IReadOnlyList Providers); - -public sealed record AgentTurnRequest( - SessionId SessionId, - AgentProfileId AgentProfileId, - string Prompt, - AgentExecutionMode Mode, - ProviderConnectionStatus ProviderStatus = ProviderConnectionStatus.Available); - -public sealed record AgentTurnResult( - string Summary, - SessionPhase NextPhase, - ApprovalState ApprovalState, - IReadOnlyList ProducedArtifacts); diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs deleted file mode 100644 index e3792e1..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DotPilot.Core.Features.RuntimeFoundation; - -public static class RuntimeFoundationIssues -{ - private const string IssuePrefix = "#"; - - public const int EmbeddedAgentRuntimeHostEpic = 12; - public const int DomainModel = 22; - public const int CommunicationContracts = 23; - public const int EmbeddedOrleansHost = 24; - public const int AgentFrameworkRuntime = 25; - public const int GrainTrafficPolicy = 26; - public const int SessionPersistence = 27; - - public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber); -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs deleted file mode 100644 index 2256cd2..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationStates.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DotPilot.Core.Features.RuntimeFoundation; - -public enum RuntimeSliceState -{ - Planned, - Sequenced, - ReadyForImplementation, -} - -public enum AgentExecutionMode -{ - Plan, - Execute, - Review, -} diff --git a/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs b/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs deleted file mode 100644 index 99e6d49..0000000 --- a/DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs +++ /dev/null @@ -1,25 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Core.Features.RuntimeFoundation; - -public sealed record AgentTurnResumeRequest( - SessionId SessionId, - ApprovalState ApprovalState, - string Summary); - -public sealed record RuntimeSessionReplayEntry( - string Kind, - string Summary, - SessionPhase Phase, - ApprovalState ApprovalState, - DateTimeOffset RecordedAt); - -public sealed record RuntimeSessionArchive( - SessionId SessionId, - string WorkflowSessionId, - SessionPhase Phase, - ApprovalState ApprovalState, - DateTimeOffset UpdatedAt, - string? CheckpointId, - IReadOnlyList Replay, - IReadOnlyList Artifacts); diff --git a/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs b/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs deleted file mode 100644 index 9b8e4a8..0000000 --- a/DotPilot.Core/Features/ToolchainCenter/IToolchainCenterCatalog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotPilot.Core.Features.ToolchainCenter; - -public interface IToolchainCenterCatalog -{ - ToolchainCenterSnapshot GetSnapshot(); -} diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs deleted file mode 100644 index dae02b9..0000000 --- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs +++ /dev/null @@ -1,64 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Core.Features.ToolchainCenter; - -public sealed record ToolchainCenterWorkstreamDescriptor( - int IssueNumber, - string SectionLabel, - string Name, - string Summary); - -public sealed record ToolchainActionDescriptor( - string Title, - string Summary, - ToolchainActionKind Kind, - bool IsPrimary, - bool IsEnabled); - -public sealed record ToolchainDiagnosticDescriptor( - string Name, - ToolchainDiagnosticStatus Status, - string Summary); - -public sealed record ToolchainConfigurationEntry( - string Name, - string ValueDisplay, - string Summary, - ToolchainConfigurationKind Kind, - ToolchainConfigurationStatus Status, - bool IsSensitive); - -public sealed record ToolchainPollingDescriptor( - TimeSpan RefreshInterval, - DateTimeOffset LastRefreshAt, - DateTimeOffset NextRefreshAt, - ToolchainPollingStatus Status, - string Summary); - -public sealed record ToolchainProviderSnapshot( - int IssueNumber, - string SectionLabel, - ProviderDescriptor Provider, - string ExecutablePath, - string InstalledVersion, - ToolchainReadinessState ReadinessState, - string ReadinessSummary, - ToolchainVersionStatus VersionStatus, - string VersionSummary, - ToolchainAuthStatus AuthStatus, - string AuthSummary, - ToolchainHealthStatus HealthStatus, - string HealthSummary, - IReadOnlyList Actions, - IReadOnlyList Diagnostics, - IReadOnlyList Configuration, - ToolchainPollingDescriptor Polling); - -public sealed record ToolchainCenterSnapshot( - string EpicLabel, - string Summary, - IReadOnlyList Workstreams, - IReadOnlyList Providers, - ToolchainPollingDescriptor BackgroundPolling, - int ReadyProviderCount, - int AttentionRequiredProviderCount); diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs deleted file mode 100644 index cf65cfa..0000000 --- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace DotPilot.Core.Features.ToolchainCenter; - -public static class ToolchainCenterIssues -{ - public const int ToolchainCenterEpic = 14; - public const int ToolchainCenterUi = 33; - public const int CodexReadiness = 34; - public const int ClaudeCodeReadiness = 35; - public const int GitHubCopilotReadiness = 36; - public const int ConnectionDiagnostics = 37; - public const int ProviderConfiguration = 38; - public const int BackgroundPolling = 39; - - private const string IssueLabelFormat = "ISSUE #{0}"; - private static readonly System.Text.CompositeFormat IssueLabelCompositeFormat = - System.Text.CompositeFormat.Parse(IssueLabelFormat); - - public static string FormatIssueLabel(int issueNumber) => - string.Format(System.Globalization.CultureInfo.InvariantCulture, IssueLabelCompositeFormat, issueNumber); -} diff --git a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs b/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs deleted file mode 100644 index ff1b206..0000000 --- a/DotPilot.Core/Features/ToolchainCenter/ToolchainCenterStates.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace DotPilot.Core.Features.ToolchainCenter; - -public enum ToolchainReadinessState -{ - Missing, - ActionRequired, - Limited, - Ready, -} - -public enum ToolchainVersionStatus -{ - Missing, - Unknown, - Detected, - UpdateAvailable, -} - -public enum ToolchainAuthStatus -{ - Missing, - Unknown, - Connected, -} - -public enum ToolchainHealthStatus -{ - Blocked, - Warning, - Healthy, -} - -public enum ToolchainDiagnosticStatus -{ - Blocked, - Failed, - Warning, - Ready, - Passed, -} - -public enum ToolchainConfigurationKind -{ - Secret, - EnvironmentVariable, - Setting, -} - -public enum ToolchainConfigurationStatus -{ - Missing, - Partial, - Configured, -} - -public enum ToolchainActionKind -{ - Install, - Connect, - Update, - TestConnection, - Troubleshoot, - OpenDocs, -} - -public enum ToolchainPollingStatus -{ - Idle, - Healthy, - Warning, -} diff --git a/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs b/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs deleted file mode 100644 index 3680349..0000000 --- a/DotPilot.Core/Features/Workbench/IWorkbenchCatalog.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public interface IWorkbenchCatalog -{ - WorkbenchSnapshot GetSnapshot(); -} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs deleted file mode 100644 index 8bec47e..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchDocumentContracts.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public sealed record WorkbenchDiffLine( - WorkbenchDiffLineKind Kind, - string Content); - -public sealed record WorkbenchDocumentDescriptor( - string RelativePath, - string Title, - string LanguageLabel, - string RendererLabel, - string StatusSummary, - bool IsReadOnly, - string PreviewContent, - IReadOnlyList DiffLines); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs deleted file mode 100644 index 7c877a7..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchInspectorContracts.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public sealed record WorkbenchArtifactDescriptor( - string Name, - string Kind, - string Status, - string RelativePath, - string Summary); - -public sealed record WorkbenchLogEntry( - string Timestamp, - string Level, - string Source, - string Message); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs b/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs deleted file mode 100644 index bc0de38..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchIssues.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public static class WorkbenchIssues -{ - private const string IssuePrefix = "#"; - - public const int DesktopWorkbenchEpic = 13; - public const int PrimaryShell = 28; - public const int RepositoryTree = 29; - public const int DocumentSurface = 30; - public const int ArtifactDock = 31; - public const int SettingsShell = 32; - - public static string FormatIssueLabel(int issueNumber) => string.Concat(IssuePrefix, issueNumber); -} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchModes.cs b/DotPilot.Core/Features/Workbench/WorkbenchModes.cs deleted file mode 100644 index f6aeada..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchModes.cs +++ /dev/null @@ -1,27 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public enum WorkbenchDocumentViewMode -{ - Preview, - DiffReview, -} - -public enum WorkbenchDiffLineKind -{ - Context, - Added, - Removed, -} - -public enum WorkbenchInspectorSection -{ - Artifacts, - Logs, -} - -public enum WorkbenchSessionEntryKind -{ - Operator, - Agent, - System, -} diff --git a/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs deleted file mode 100644 index 1bfab34..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchRepositoryContracts.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public sealed record WorkbenchRepositoryNode( - string RelativePath, - string DisplayLabel, - string Name, - int Depth, - bool IsDirectory, - bool CanOpen); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs deleted file mode 100644 index 801a80a..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchSessionContracts.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public sealed record WorkbenchSessionEntry( - string Title, - string Timestamp, - string Summary, - WorkbenchSessionEntryKind Kind); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs b/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs deleted file mode 100644 index 6395eb1..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchSettingsContracts.cs +++ /dev/null @@ -1,22 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public static class WorkbenchSettingsCategoryKeys -{ - public const string Toolchains = "toolchains"; - public const string Providers = "providers"; - public const string Policies = "policies"; - public const string Storage = "storage"; -} - -public sealed record WorkbenchSettingEntry( - string Name, - string Value, - string Summary, - bool IsSensitive, - bool IsActionable); - -public sealed record WorkbenchSettingsCategory( - string Key, - string Title, - string Summary, - IReadOnlyList Entries); diff --git a/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs b/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs deleted file mode 100644 index 9312cc6..0000000 --- a/DotPilot.Core/Features/Workbench/WorkbenchSnapshot.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace DotPilot.Core.Features.Workbench; - -public sealed record WorkbenchSnapshot( - string WorkspaceName, - string WorkspaceRoot, - string SearchPlaceholder, - string SessionTitle, - string SessionStage, - string SessionSummary, - IReadOnlyList SessionEntries, - IReadOnlyList RepositoryNodes, - IReadOnlyList Documents, - IReadOnlyList Artifacts, - IReadOnlyList Logs, - IReadOnlyList SettingsCategories); diff --git a/DotPilot.Runtime.Host/AGENTS.md b/DotPilot.Runtime.Host/AGENTS.md index 63971a2..09171af 100644 --- a/DotPilot.Runtime.Host/AGENTS.md +++ b/DotPilot.Runtime.Host/AGENTS.md @@ -11,7 +11,7 @@ Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host se ## Entry Points - `DotPilot.Runtime.Host.csproj` -- `Features/RuntimeFoundation/*` +- `Features/AgentSessions/*` ## Boundaries @@ -23,7 +23,7 @@ Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host se ## Local Commands - `build-host`: `dotnet build DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj` -- `test-runtime-host`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~EmbeddedRuntimeHost` +- `test-runtime-host`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~AgentSessions` ## Applicable Skills @@ -37,3 +37,4 @@ Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host se - This project must remain invisible to the browserwasm path; keep app references conditional so UI tests stay green. - Grain contracts belong in `DotPilot.Core`; do not let this project become the source of truth for shared runtime abstractions. +- Keep the host focused on agent-profile and session grains for the active chat workflow; do not reintroduce old demo host catalogs. diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentProfileGrain.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentProfileGrain.cs new file mode 100644 index 0000000..4a5c61d --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentProfileGrain.cs @@ -0,0 +1,30 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +public sealed class AgentProfileGrain( + [PersistentState(AgentSessionHostNames.AgentStateName, AgentSessionHostNames.GrainStorageProviderName)] + IPersistentState agentState) : Grain, IAgentProfileGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(agentState.RecordExists ? agentState.State : null); + } + + public async ValueTask UpsertAsync(AgentProfileDescriptor agentProfile) + { + EnsureMatchingKey(agentProfile.Id.ToString(), this.GetPrimaryKeyString(), AgentSessionHostNames.AgentGrainName); + agentState.State = agentProfile; + await agentState.WriteStateAsync(); + return agentState.State; + } + + private static void EnsureMatchingKey(string expectedKey, string actualKey, string grainName) + { + if (!string.Equals(expectedKey, actualKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Descriptor id does not match the grain primary key for {grainName}."); + } + } +} diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs new file mode 100644 index 0000000..9d57047 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Hosting; +using Orleans.Configuration; + +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +public static class AgentSessionHostBuilderExtensions +{ + public static IHostBuilder UseDotPilotAgentSessions( + this IHostBuilder builder, + AgentSessionHostOptions? options = null) + { + ArgumentNullException.ThrowIfNull(builder); + + var resolvedOptions = options ?? new AgentSessionHostOptions(); + builder.UseOrleans((context, siloBuilder) => + { + _ = context; + ConfigureSilo(siloBuilder, resolvedOptions); + }); + + return builder; + } + + internal static void ConfigureSilo(ISiloBuilder siloBuilder, AgentSessionHostOptions options) + { + ArgumentNullException.ThrowIfNull(siloBuilder); + ArgumentNullException.ThrowIfNull(options); + + siloBuilder.UseLocalhostClustering(options.SiloPort, options.GatewayPort); + siloBuilder.Configure(cluster => + { + cluster.ClusterId = options.ClusterId; + cluster.ServiceId = options.ServiceId; + }); + siloBuilder.AddMemoryGrainStorage(AgentSessionHostNames.GrainStorageProviderName); + siloBuilder.UseInMemoryReminderService(); + } +} + diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs new file mode 100644 index 0000000..9eb8154 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs @@ -0,0 +1,15 @@ +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +internal static class AgentSessionHostNames +{ + public const string DefaultClusterId = "dotpilot-local"; + public const string DefaultServiceId = "dotpilot-desktop"; + public const int DefaultSiloPort = 11_111; + public const int DefaultGatewayPort = 30_000; + public const string GrainStorageProviderName = "agent-sessions-memory"; + public const string SessionStateName = "session"; + public const string AgentStateName = "agent"; + public const string SessionGrainName = "Session"; + public const string AgentGrainName = "Agent"; +} + diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs new file mode 100644 index 0000000..2f24867 --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs @@ -0,0 +1,13 @@ +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +public sealed class AgentSessionHostOptions +{ + public string ClusterId { get; init; } = AgentSessionHostNames.DefaultClusterId; + + public string ServiceId { get; init; } = AgentSessionHostNames.DefaultServiceId; + + public int SiloPort { get; init; } = AgentSessionHostNames.DefaultSiloPort; + + public int GatewayPort { get; init; } = AgentSessionHostNames.DefaultGatewayPort; +} + diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs b/DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs new file mode 100644 index 0000000..04ebb2b --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs @@ -0,0 +1,31 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +public sealed class SessionGrain( + [PersistentState(AgentSessionHostNames.SessionStateName, AgentSessionHostNames.GrainStorageProviderName)] + IPersistentState sessionState) : Grain, ISessionGrain +{ + public ValueTask GetAsync() + { + return ValueTask.FromResult(sessionState.RecordExists ? sessionState.State : null); + } + + public async ValueTask UpsertAsync(SessionDescriptor session) + { + EnsureMatchingKey(session.Id.ToString(), this.GetPrimaryKeyString(), AgentSessionHostNames.SessionGrainName); + sessionState.State = session; + await sessionState.WriteStateAsync(); + return sessionState.State; + } + + private static void EnsureMatchingKey(string expectedKey, string actualKey, string grainName) + { + if (!string.Equals(expectedKey, actualKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Descriptor id does not match the grain primary key for {grainName}."); + } + } +} + diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs deleted file mode 100644 index 9cb8971..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/ArtifactGrain.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class ArtifactGrain( - [PersistentState(EmbeddedRuntimeHostNames.ArtifactStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] - IPersistentState artifactState) : Grain, IArtifactGrain -{ - public ValueTask GetAsync() - { - return ValueTask.FromResult(artifactState.RecordExists ? artifactState.State : null); - } - - public async ValueTask UpsertAsync(ArtifactDescriptor artifact) - { - EmbeddedRuntimeGrainGuards.EnsureMatchingKey(artifact.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.ArtifactGrainName); - artifactState.State = artifact; - await artifactState.WriteStateAsync(); - return artifactState.State; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs deleted file mode 100644 index c25ef36..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeGrainGuards.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal static class EmbeddedRuntimeGrainGuards -{ - public static void EnsureMatchingKey(string descriptorId, string grainKey, string grainName) - { - if (string.Equals(descriptorId, grainKey, StringComparison.Ordinal)) - { - return; - } - - throw new ArgumentException( - string.Concat(EmbeddedRuntimeHostNames.MismatchedPrimaryKeyPrefix, grainName), - nameof(descriptorId)); - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs deleted file mode 100644 index 59af2ed..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs +++ /dev/null @@ -1,60 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using Orleans.Configuration; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public static class EmbeddedRuntimeHostBuilderExtensions -{ - public static IHostBuilder UseDotPilotEmbeddedRuntime( - this IHostBuilder builder, - EmbeddedRuntimeHostOptions? options = null) - { - ArgumentNullException.ThrowIfNull(builder); - - var resolvedOptions = options ?? new EmbeddedRuntimeHostOptions(); - - builder.ConfigureServices(services => - { - services.AddSingleton(resolvedOptions); - services.AddSingleton(); - services.AddSingleton(serviceProvider => serviceProvider.GetRequiredService()); - services.AddSingleton(); - services.AddHostedService(); - }); - - builder.UseOrleans((context, siloBuilder) => - { - _ = context; - ConfigureSilo(siloBuilder, resolvedOptions); - }); - - return builder; - } - - internal static void ConfigureSilo(ISiloBuilder siloBuilder, EmbeddedRuntimeHostOptions options) - { - ArgumentNullException.ThrowIfNull(siloBuilder); - ArgumentNullException.ThrowIfNull(options); - - siloBuilder.UseLocalhostClustering(options.SiloPort, options.GatewayPort); - siloBuilder.Configure(cluster => - { - cluster.ClusterId = options.ClusterId; - cluster.ServiceId = options.ServiceId; - }); - siloBuilder.AddStartupTask( - static (serviceProvider, _) => - { - serviceProvider - .GetRequiredService() - .SetState(EmbeddedRuntimeHostState.Running); - - return Task.CompletedTask; - }, - ServiceLifecycleStage.Active); - siloBuilder.AddMemoryGrainStorage(EmbeddedRuntimeHostNames.GrainStorageProviderName); - siloBuilder.UseInMemoryReminderService(); - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs deleted file mode 100644 index 241106a..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostCatalog.cs +++ /dev/null @@ -1,39 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal sealed class EmbeddedRuntimeHostCatalog(EmbeddedRuntimeHostOptions options) : IEmbeddedRuntimeHostCatalog -{ - private int _state = (int)EmbeddedRuntimeHostState.Stopped; - - public EmbeddedRuntimeHostSnapshot GetSnapshot() - { - return new( - (EmbeddedRuntimeHostState)Volatile.Read(ref _state), - EmbeddedRuntimeClusteringMode.Localhost, - EmbeddedRuntimeStorageMode.InMemory, - EmbeddedRuntimeStorageMode.InMemory, - options.ClusterId, - options.ServiceId, - options.SiloPort, - options.GatewayPort, - CreateGrains()); - } - - public void SetState(EmbeddedRuntimeHostState state) - { - Volatile.Write(ref _state, (int)state); - } - - private static IReadOnlyList CreateGrains() - { - return - [ - new(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.SessionGrainSummary), - new(EmbeddedRuntimeHostNames.WorkspaceGrainName, EmbeddedRuntimeHostNames.WorkspaceGrainSummary), - new(EmbeddedRuntimeHostNames.FleetGrainName, EmbeddedRuntimeHostNames.FleetGrainSummary), - new(EmbeddedRuntimeHostNames.PolicyGrainName, EmbeddedRuntimeHostNames.PolicyGrainSummary), - new(EmbeddedRuntimeHostNames.ArtifactGrainName, EmbeddedRuntimeHostNames.ArtifactGrainSummary), - ]; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs deleted file mode 100644 index 260648f..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostLifecycleService.cs +++ /dev/null @@ -1,14 +0,0 @@ -using Microsoft.Extensions.Hosting; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal sealed class EmbeddedRuntimeHostLifecycleService(EmbeddedRuntimeHostCatalog catalog) : IHostedService -{ - public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; - - public Task StopAsync(CancellationToken cancellationToken) - { - catalog.SetState(DotPilot.Core.Features.RuntimeFoundation.EmbeddedRuntimeHostState.Stopped); - return Task.CompletedTask; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs deleted file mode 100644 index aff05e0..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostNames.cs +++ /dev/null @@ -1,28 +0,0 @@ -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal static class EmbeddedRuntimeHostNames -{ - public const string DefaultClusterId = "dotpilot-local"; - public const string DefaultServiceId = "dotpilot-desktop"; - public const int DefaultSiloPort = 11_111; - public const int DefaultGatewayPort = 30_000; - public const string GrainStorageProviderName = "runtime-foundation-memory"; - public const string ClientSourceName = "Client"; - public const string ClientSourceMethodName = "Invoke"; - public const string SessionStateName = "session"; - public const string WorkspaceStateName = "workspace"; - public const string FleetStateName = "fleet"; - public const string PolicyStateName = "policy"; - public const string ArtifactStateName = "artifact"; - public const string SessionGrainName = "Session"; - public const string WorkspaceGrainName = "Workspace"; - public const string FleetGrainName = "Fleet"; - public const string PolicyGrainName = "Policy"; - public const string ArtifactGrainName = "Artifact"; - public const string SessionGrainSummary = "Stores local session state in the embedded runtime host."; - public const string WorkspaceGrainSummary = "Stores local workspace descriptors for the embedded runtime host."; - public const string FleetGrainSummary = "Stores participating agent fleet descriptors for local orchestration."; - public const string PolicyGrainSummary = "Stores local approval and execution policy defaults."; - public const string ArtifactGrainSummary = "Stores artifact metadata for the local embedded runtime."; - public const string MismatchedPrimaryKeyPrefix = "Descriptor id does not match the grain primary key for "; -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs deleted file mode 100644 index 28fc644..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostOptions.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class EmbeddedRuntimeHostOptions -{ - public string ClusterId { get; init; } = EmbeddedRuntimeHostNames.DefaultClusterId; - - public string ServiceId { get; init; } = EmbeddedRuntimeHostNames.DefaultServiceId; - - public int SiloPort { get; init; } = EmbeddedRuntimeHostNames.DefaultSiloPort; - - public int GatewayPort { get; init; } = EmbeddedRuntimeHostNames.DefaultGatewayPort; -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs deleted file mode 100644 index 85494bd..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs +++ /dev/null @@ -1,124 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal static class EmbeddedRuntimeTrafficPolicy -{ - private const string PolicySummary = - "Client and grain transitions stay explicit so the embedded host can reject unsupported hops before the runtime model grows."; - private const string MermaidHeader = "flowchart LR"; - private const string MermaidArrow = " --> "; - private const string MermaidActiveArrow = " ==> "; - - public static string Summary => PolicySummary; - - public static IReadOnlyList AllowedTransitions => - [ - CreateClientTransition(EmbeddedRuntimeHostNames.SessionGrainName), - CreateClientTransition(EmbeddedRuntimeHostNames.WorkspaceGrainName), - CreateClientTransition(EmbeddedRuntimeHostNames.FleetGrainName), - CreateClientTransition(EmbeddedRuntimeHostNames.PolicyGrainName), - CreateClientTransition(EmbeddedRuntimeHostNames.ArtifactGrainName), - CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.WorkspaceGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IWorkspaceGrain.GetAsync)), - CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.FleetGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IFleetGrain.GetAsync)), - CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.PolicyGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IPolicyGrain.GetAsync)), - CreateTransition(EmbeddedRuntimeHostNames.SessionGrainName, EmbeddedRuntimeHostNames.ArtifactGrainName, nameof(ISessionGrain.UpsertAsync), nameof(IArtifactGrain.UpsertAsync)), - CreateTransition(EmbeddedRuntimeHostNames.FleetGrainName, EmbeddedRuntimeHostNames.PolicyGrainName, nameof(IFleetGrain.GetAsync), nameof(IPolicyGrain.GetAsync)), - ]; - - public static bool IsAllowed(EmbeddedRuntimeTrafficProbe probe) - { - ArgumentNullException.ThrowIfNull(probe); - - var sourceName = GetGrainName(probe.SourceGrainType); - var targetName = GetGrainName(probe.TargetGrainType); - - return AllowedTransitions.Any(transition => - string.Equals(transition.Source, sourceName, StringComparison.Ordinal) && - string.Equals(transition.Target, targetName, StringComparison.Ordinal) && - transition.SourceMethods.Contains(probe.SourceMethod, StringComparer.Ordinal) && - transition.TargetMethods.Contains(probe.TargetMethod, StringComparer.Ordinal)); - } - - public static string CreateMermaidDiagram() - { - return CreateMermaidDiagramCore(activeTransition: null); - } - - public static string CreateMermaidDiagram(EmbeddedRuntimeTrafficProbe probe) - { - ArgumentNullException.ThrowIfNull(probe); - - var activeTransition = ( - Source: GetGrainName(probe.SourceGrainType), - Target: GetGrainName(probe.TargetGrainType), - SourceMethod: probe.SourceMethod, - TargetMethod: probe.TargetMethod); - return CreateMermaidDiagramCore(activeTransition); - } - - private static EmbeddedRuntimeTrafficTransitionDescriptor CreateClientTransition(string target) - { - return new( - EmbeddedRuntimeHostNames.ClientSourceName, - target, - [EmbeddedRuntimeHostNames.ClientSourceMethodName], - [nameof(ISessionGrain.GetAsync), nameof(ISessionGrain.UpsertAsync)], - false); - } - - private static EmbeddedRuntimeTrafficTransitionDescriptor CreateTransition( - string source, - string target, - string sourceMethod, - string targetMethod) - { - return new( - source, - target, - [sourceMethod], - [targetMethod], - false); - } - - private static string GetGrainName(Type grainType) - { - ArgumentNullException.ThrowIfNull(grainType); - - return grainType == typeof(ISessionGrain) ? EmbeddedRuntimeHostNames.SessionGrainName - : grainType == typeof(IWorkspaceGrain) ? EmbeddedRuntimeHostNames.WorkspaceGrainName - : grainType == typeof(IFleetGrain) ? EmbeddedRuntimeHostNames.FleetGrainName - : grainType == typeof(IPolicyGrain) ? EmbeddedRuntimeHostNames.PolicyGrainName - : grainType == typeof(IArtifactGrain) ? EmbeddedRuntimeHostNames.ArtifactGrainName - : grainType.Name; - } - - private static string CreateMermaidDiagramCore((string Source, string Target, string SourceMethod, string TargetMethod)? activeTransition) - { - var lines = new List(AllowedTransitions.Count + 1) - { - MermaidHeader, - }; - - foreach (var transition in AllowedTransitions) - { - var isActive = activeTransition is not null && - string.Equals(transition.Source, activeTransition.Value.Source, StringComparison.Ordinal) && - string.Equals(transition.Target, activeTransition.Value.Target, StringComparison.Ordinal) && - transition.SourceMethods.Contains(activeTransition.Value.SourceMethod, StringComparer.Ordinal) && - transition.TargetMethods.Contains(activeTransition.Value.TargetMethod, StringComparer.Ordinal); - var arrow = isActive ? MermaidActiveArrow : MermaidArrow; - lines.Add( - string.Concat( - transition.Source, - arrow, - transition.Target, - " : ", - string.Join(", ", transition.SourceMethods), - " -> ", - string.Join(", ", transition.TargetMethods))); - } - - return string.Join(Environment.NewLine, lines); - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs deleted file mode 100644 index f9236c8..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs +++ /dev/null @@ -1,25 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -internal sealed class EmbeddedRuntimeTrafficPolicyCatalog : IEmbeddedRuntimeTrafficPolicyCatalog -{ - public EmbeddedRuntimeTrafficPolicySnapshot GetSnapshot() - { - return new( - RuntimeFoundationIssues.GrainTrafficPolicy, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), - EmbeddedRuntimeTrafficPolicy.Summary, - EmbeddedRuntimeTrafficPolicy.CreateMermaidDiagram(), - EmbeddedRuntimeTrafficPolicy.AllowedTransitions); - } - - public EmbeddedRuntimeTrafficDecision Evaluate(EmbeddedRuntimeTrafficProbe probe) - { - ArgumentNullException.ThrowIfNull(probe); - - return new EmbeddedRuntimeTrafficDecision( - EmbeddedRuntimeTrafficPolicy.IsAllowed(probe), - EmbeddedRuntimeTrafficPolicy.CreateMermaidDiagram(probe)); - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs deleted file mode 100644 index 59e8a55..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/FleetGrain.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class FleetGrain( - [PersistentState(EmbeddedRuntimeHostNames.FleetStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] - IPersistentState fleetState) : Grain, IFleetGrain -{ - public ValueTask GetAsync() - { - return ValueTask.FromResult(fleetState.RecordExists ? fleetState.State : null); - } - - public async ValueTask UpsertAsync(FleetDescriptor fleet) - { - EmbeddedRuntimeGrainGuards.EnsureMatchingKey(fleet.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.FleetGrainName); - fleetState.State = fleet; - await fleetState.WriteStateAsync(); - return fleetState.State; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs deleted file mode 100644 index 457d41c..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/PolicyGrain.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class PolicyGrain( - [PersistentState(EmbeddedRuntimeHostNames.PolicyStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] - IPersistentState policyState) : Grain, IPolicyGrain -{ - public ValueTask GetAsync() - { - return ValueTask.FromResult(policyState.RecordExists ? policyState.State : null); - } - - public async ValueTask UpsertAsync(PolicyDescriptor policy) - { - EmbeddedRuntimeGrainGuards.EnsureMatchingKey(policy.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.PolicyGrainName); - policyState.State = policy; - await policyState.WriteStateAsync(); - return policyState.State; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs deleted file mode 100644 index 1a7769e..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class SessionGrain( - [PersistentState(EmbeddedRuntimeHostNames.SessionStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] - IPersistentState sessionState) : Grain, ISessionGrain -{ - public ValueTask GetAsync() - { - return ValueTask.FromResult(sessionState.RecordExists ? sessionState.State : null); - } - - public async ValueTask UpsertAsync(SessionDescriptor session) - { - EmbeddedRuntimeGrainGuards.EnsureMatchingKey(session.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.SessionGrainName); - sessionState.State = session; - await sessionState.WriteStateAsync(); - return sessionState.State; - } -} diff --git a/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs b/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs deleted file mode 100644 index 0c21710..0000000 --- a/DotPilot.Runtime.Host/Features/RuntimeFoundation/WorkspaceGrain.cs +++ /dev/null @@ -1,22 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Host.Features.RuntimeFoundation; - -public sealed class WorkspaceGrain( - [PersistentState(EmbeddedRuntimeHostNames.WorkspaceStateName, EmbeddedRuntimeHostNames.GrainStorageProviderName)] - IPersistentState workspaceState) : Grain, IWorkspaceGrain -{ - public ValueTask GetAsync() - { - return ValueTask.FromResult(workspaceState.RecordExists ? workspaceState.State : null); - } - - public async ValueTask UpsertAsync(WorkspaceDescriptor workspace) - { - EmbeddedRuntimeGrainGuards.EnsureMatchingKey(workspace.Id.ToString(), this.GetPrimaryKeyString(), EmbeddedRuntimeHostNames.WorkspaceGrainName); - workspaceState.State = workspace; - await workspaceState.WriteStateAsync(); - return workspaceState.State; - } -} diff --git a/DotPilot.Runtime/AGENTS.md b/DotPilot.Runtime/AGENTS.md index c53f5f1..866b232 100644 --- a/DotPilot.Runtime/AGENTS.md +++ b/DotPilot.Runtime/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md Project: `DotPilot.Runtime` -Stack: `.NET 10`, class library, provider-independent runtime services and diagnostics +Stack: `.NET 10`, class library, provider-backed runtime services, local persistence, and deterministic session orchestration ## Purpose @@ -11,21 +11,21 @@ Stack: `.NET 10`, class library, provider-independent runtime services and diagn ## Entry Points - `DotPilot.Runtime.csproj` -- `Features/RuntimeFoundation/*` +- `Features/AgentSessions/*` - `Features/HttpDiagnostics/DebugHttpHandler.cs` ## Boundaries - Keep this project free of `Uno Platform`, XAML, and page/view-model logic. - Implement feature slices against `DotPilot.Core` contracts instead of reaching back into the app project. -- Prefer deterministic runtime behavior and environment probing here so tests can exercise real flows without mocks. +- Prefer deterministic runtime behavior, provider readiness probing, and `SQLite`-backed persistence here so tests can exercise real flows without mocks. - Keep external-provider assumptions soft: absence of Codex, Claude Code, or GitHub Copilot in CI must not break the provider-independent baseline. - For the first embedded Orleans host implementation, stay local-first with `UseLocalhostClustering` and in-memory storage/reminders so the desktop runtime remains self-contained. ## Local Commands - `build-runtime`: `dotnet build DotPilot.Runtime/DotPilot.Runtime.csproj` -- `test-runtime`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeFoundation` +- `test-runtime`: `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~AgentSessions` ## Applicable Skills @@ -37,5 +37,5 @@ Stack: `.NET 10`, class library, provider-independent runtime services and diagn ## Local Risks Or Protected Areas -- Runtime services introduced here will become composition roots for later Orleans and Agent Framework work, so keep boundaries explicit. -- Toolchain probing must stay deterministic and side-effect free; do not turn startup checks into live external calls. +- Runtime services introduced here are the composition root for provider readiness, agent creation, session persistence, and streaming transcript state, so keep boundaries explicit. +- CLI probing must stay deterministic and side-effect free; do not turn startup checks into live external calls. diff --git a/DotPilot.Runtime/DotPilot.Runtime.csproj b/DotPilot.Runtime/DotPilot.Runtime.csproj index 729ab80..a568d4f 100644 --- a/DotPilot.Runtime/DotPilot.Runtime.csproj +++ b/DotPilot.Runtime/DotPilot.Runtime.csproj @@ -7,7 +7,14 @@ + + + + + + + diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionCommandProbe.cs similarity index 75% rename from DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs rename to DotPilot.Runtime/Features/AgentSessions/AgentSessionCommandProbe.cs index e2e7468..05aab35 100644 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCommandProbe.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionCommandProbe.cs @@ -1,62 +1,70 @@ using System.Diagnostics; +using System.Runtime.InteropServices; -namespace DotPilot.Runtime.Features.ToolchainCenter; +namespace DotPilot.Runtime.Features.AgentSessions; -internal static class ToolchainCommandProbe +internal static class AgentSessionCommandProbe { private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(2); private static readonly TimeSpan RedirectDrainTimeout = TimeSpan.FromSeconds(1); private const string VersionSeparator = "version"; private const string EmptyOutput = ""; - public static string? ResolveExecutablePath(string commandName) => - RuntimeFoundation.ProviderToolchainProbe.ResolveExecutablePath(commandName); + public static string? ResolveExecutablePath(string commandName) + { + if (OperatingSystem.IsBrowser()) + { + return null; + } - public static string ReadVersion(string executablePath, IReadOnlyList arguments) - => ProbeVersion(executablePath, arguments).Version; + var searchPaths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) + .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - public static ToolchainVersionProbeResult ProbeVersion(string executablePath, IReadOnlyList arguments) - { - ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); - ArgumentNullException.ThrowIfNull(arguments); + foreach (var searchPath in searchPaths) + { + foreach (var candidate in EnumerateCandidates(searchPath, commandName)) + { + if (File.Exists(candidate)) + { + return candidate; + } + } + } + return null; + } + + public static string ReadVersion(string executablePath, IReadOnlyList arguments) + { var execution = Execute(executablePath, arguments); if (!execution.Succeeded) { - return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched }; + return EmptyOutput; } var output = string.IsNullOrWhiteSpace(execution.StandardOutput) ? execution.StandardError : execution.StandardOutput; - var firstLine = output .Split(Environment.NewLine, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) .FirstOrDefault(); if (string.IsNullOrWhiteSpace(firstLine)) { - return ToolchainVersionProbeResult.Missing with { Launched = execution.Launched }; + return EmptyOutput; } var separatorIndex = firstLine.IndexOf(VersionSeparator, StringComparison.OrdinalIgnoreCase); - var version = separatorIndex >= 0 + return separatorIndex >= 0 ? firstLine[(separatorIndex + VersionSeparator.Length)..].Trim(' ', ':') : firstLine.Trim(); - - return new(execution.Launched, version); } - public static bool CanExecute(string executablePath, IReadOnlyList arguments) + private static ToolchainCommandExecution Execute(string executablePath, IReadOnlyList arguments) { ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); ArgumentNullException.ThrowIfNull(arguments); - return Execute(executablePath, arguments).Succeeded; - } - - private static ToolchainCommandExecution Execute(string executablePath, IReadOnlyList arguments) - { var startInfo = new ProcessStartInfo { FileName = executablePath, @@ -111,6 +119,22 @@ private static ToolchainCommandExecution Execute(string executablePath, IReadOnl } } + private static IEnumerable EnumerateCandidates(string searchPath, string commandName) + { + yield return Path.Combine(searchPath, commandName); + + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + yield break; + } + + foreach (var extension in (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT") + .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + yield return Path.Combine(searchPath, string.Concat(commandName, extension)); + } + } + private static Task ObserveRedirectedStream(Task readTask) { _ = readTask.ContinueWith( @@ -150,7 +174,6 @@ private static void TryTerminate(Process process) } catch { - // Best-effort cleanup only. } } @@ -165,15 +188,9 @@ private static void WaitForTermination(Process process) } catch { - // Best-effort cleanup only. } } - public readonly record struct ToolchainVersionProbeResult(bool Launched, string Version) - { - public static ToolchainVersionProbeResult Missing => new(false, EmptyOutput); - } - private readonly record struct ToolchainCommandExecution(bool Launched, bool Succeeded, string StandardOutput, string StandardError) { public static ToolchainCommandExecution LaunchFailed => new(false, false, EmptyOutput, EmptyOutput); diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionDeterministicIdentity.cs similarity index 54% rename from DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs rename to DotPilot.Runtime/Features/AgentSessions/AgentSessionDeterministicIdentity.cs index a06f8a4..39381c9 100644 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationDeterministicIdentity.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionDeterministicIdentity.cs @@ -2,21 +2,13 @@ using System.Text; using DotPilot.Core.Features.ControlPlaneDomain; -namespace DotPilot.Runtime.Features.RuntimeFoundation; +namespace DotPilot.Runtime.Features.AgentSessions; -internal static class RuntimeFoundationDeterministicIdentity +internal static class AgentSessionDeterministicIdentity { - private const string ArtifactSeedPrefix = "runtime-foundation-artifact"; - private const string ProviderSeedPrefix = "runtime-foundation-provider"; + private const string ProviderSeedPrefix = "agent-session-provider"; private const string SeedSeparator = "|"; - public static DateTimeOffset ArtifactCreatedAt { get; } = new(2026, 3, 13, 0, 0, 0, TimeSpan.Zero); - - public static ArtifactId CreateArtifactId(SessionId sessionId, string artifactName) - { - return new(CreateGuid(string.Concat(ArtifactSeedPrefix, SeedSeparator, sessionId, SeedSeparator, artifactName))); - } - public static ProviderId CreateProviderId(string commandName) { return new(CreateGuid(string.Concat(ProviderSeedPrefix, SeedSeparator, commandName))); @@ -32,3 +24,4 @@ private static Guid CreateGuid(string seed) return new Guid(guidBytes); } } + diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionJsonSerializerContext.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionJsonSerializerContext.cs new file mode 100644 index 0000000..1c3ff05 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionJsonSerializerContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace DotPilot.Runtime.Features.AgentSessions; + +[JsonSerializable(typeof(string[]))] +internal sealed partial class AgentSessionJsonSerializerContext : JsonSerializerContext +{ +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs new file mode 100644 index 0000000..f75fdb7 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs @@ -0,0 +1,54 @@ +using DotPilot.Core.Features.AgentSessions; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal static class AgentSessionProviderCatalog +{ + private const string DebugDisplayName = "Debug Provider"; + private const string DebugCommandName = "debug"; + private const string DebugModelName = "debug-echo"; + private const string DebugInstallCommand = "built-in"; + + private const string CodexDisplayName = "Codex"; + private const string CodexCommandName = "codex"; + private const string CodexModelName = "gpt-5"; + private const string CodexInstallCommand = "npm install -g @openai/codex"; + + private const string ClaudeDisplayName = "Claude Code"; + private const string ClaudeCommandName = "claude"; + private const string ClaudeModelName = "claude-sonnet-4-5"; + private const string ClaudeInstallCommand = "npm install -g @anthropic-ai/claude-code"; + + private const string CopilotDisplayName = "GitHub Copilot"; + private const string CopilotCommandName = "copilot"; + private const string CopilotModelName = "gpt-5"; + private const string CopilotInstallCommand = "npm install -g @github/copilot"; + + private static readonly IReadOnlyDictionary ProfilesByKind = + CreateProfiles() + .ToDictionary(profile => profile.Kind); + + public static IReadOnlyList All => [.. ProfilesByKind.Values]; + + public static AgentSessionProviderProfile Get(AgentProviderKind kind) => ProfilesByKind[kind]; + + private static IReadOnlyList CreateProfiles() + { + return + [ + new(AgentProviderKind.Debug, DebugDisplayName, DebugCommandName, DebugModelName, DebugInstallCommand, true), + new(AgentProviderKind.Codex, CodexDisplayName, CodexCommandName, CodexModelName, CodexInstallCommand, false), + new(AgentProviderKind.ClaudeCode, ClaudeDisplayName, ClaudeCommandName, ClaudeModelName, ClaudeInstallCommand, false), + new(AgentProviderKind.GitHubCopilot, CopilotDisplayName, CopilotCommandName, CopilotModelName, CopilotInstallCommand, false), + ]; + } +} + +internal sealed record AgentSessionProviderProfile( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string DefaultModelName, + string InstallCommand, + bool IsBuiltIn); + diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs new file mode 100644 index 0000000..3eea7dc --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs @@ -0,0 +1,575 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class AgentSessionService( + IDbContextFactory dbContextFactory, + IServiceProvider serviceProvider, + TimeProvider timeProvider) + : IAgentSessionService, IDisposable +{ + private const string BrowserStatusSummary = "Desktop CLI probing is unavailable in the browser automation head."; + private const string DisabledStatusSummary = "Provider is disabled for local agent creation."; + private const string BuiltInStatusSummary = "Built in and ready for deterministic local testing."; + private const string MissingCliSummaryFormat = "{0} CLI is not installed."; + private const string ReadySummaryFormat = "{0} CLI is available on PATH."; + private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice."; + private const string SessionReadyText = "Session created. Send the first message to start the workflow."; + private const string UserAuthor = "You"; + private const string ToolAuthor = "Tool"; + private const string StatusAuthor = "System"; + private const string DebugToolStartText = "Preparing local debug workflow."; + private const string DebugToolDoneText = "Debug workflow finished."; + private const string ToolAccentLabel = "tool"; + private const string StatusAccentLabel = "status"; + private const string ErrorAccentLabel = "error"; + private static readonly System.Text.CompositeFormat MissingCliSummaryCompositeFormat = + System.Text.CompositeFormat.Parse(MissingCliSummaryFormat); + private static readonly System.Text.CompositeFormat ReadySummaryCompositeFormat = + System.Text.CompositeFormat.Parse(ReadySummaryFormat); + private static readonly System.Text.CompositeFormat NotYetImplementedCompositeFormat = + System.Text.CompositeFormat.Parse(NotYetImplementedFormat); + private readonly SemaphoreSlim _initializationGate = new(1, 1); + private bool _initialized; + + public async ValueTask GetWorkspaceAsync(CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var agents = await dbContext.AgentProfiles + .OrderBy(record => record.Name) + .ToListAsync(cancellationToken); + var sessions = await dbContext.Sessions + .OrderByDescending(record => record.UpdatedAt) + .ToListAsync(cancellationToken); + var entries = await dbContext.SessionEntries + .OrderBy(record => record.Timestamp) + .ToListAsync(cancellationToken); + + var agentsById = agents.ToDictionary(record => record.Id); + var sessionItems = sessions + .Select(record => MapSessionListItem(record, agentsById, entries)) + .ToArray(); + + return new AgentWorkspaceSnapshot( + sessionItems, + agents.Select(MapAgentSummary).ToArray(), + await BuildProviderStatusesAsync(dbContext, cancellationToken), + sessionItems.Length > 0 ? sessionItems[0].Id : null); + } + + public async ValueTask GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken) + { + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var session = await dbContext.Sessions + .FirstOrDefaultAsync(record => record.Id == sessionId.Value, cancellationToken); + if (session is null) + { + return null; + } + + var agents = await dbContext.AgentProfiles + .Where(record => record.Id == session.PrimaryAgentProfileId) + .ToListAsync(cancellationToken); + var agentsById = agents.ToDictionary(record => record.Id); + var entries = await dbContext.SessionEntries + .Where(record => record.SessionId == sessionId.Value) + .OrderBy(record => record.Timestamp) + .ToListAsync(cancellationToken); + + return new SessionTranscriptSnapshot( + MapSessionListItem(session, agentsById, entries), + entries.Select(MapEntry).ToArray(), + agents.Select(MapAgentSummary).ToArray()); + } + + public async ValueTask CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var providers = await BuildProviderStatusesAsync(dbContext, cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + if (!provider.CanCreateAgents) + { + throw new InvalidOperationException(provider.StatusSummary); + } + + var createdAt = timeProvider.GetUtcNow(); + var record = new AgentProfileRecord + { + Id = Guid.CreateVersion7(), + Name = command.Name.Trim(), + Role = (int)command.Role, + ProviderKind = (int)command.ProviderKind, + ModelName = command.ModelName.Trim(), + SystemPrompt = command.SystemPrompt.Trim(), + CapabilitiesJson = SerializeCapabilities(command.Capabilities), + CreatedAt = createdAt, + }; + + dbContext.AgentProfiles.Add(record); + await dbContext.SaveChangesAsync(cancellationToken); + await UpsertAgentGrainAsync(MapAgentDescriptor(record)); + + return MapAgentSummary(record); + } + + public async ValueTask CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var agent = await dbContext.AgentProfiles + .FirstAsync(record => record.Id == command.AgentProfileId.Value, cancellationToken); + var now = timeProvider.GetUtcNow(); + var sessionId = SessionId.New(); + var session = new SessionRecord + { + Id = sessionId.Value, + Title = command.Title.Trim(), + PrimaryAgentProfileId = agent.Id, + CreatedAt = now, + UpdatedAt = now, + }; + + dbContext.Sessions.Add(session); + dbContext.SessionEntries.Add(CreateEntryRecord(sessionId, SessionStreamEntryKind.Status, StatusAuthor, SessionReadyText, now, accentLabel: StatusAccentLabel)); + await dbContext.SaveChangesAsync(cancellationToken); + await UpsertSessionGrainAsync(session); + + return await GetSessionAsync(sessionId, cancellationToken) ?? + throw new InvalidOperationException("Created session could not be reloaded."); + } + + public async ValueTask UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var record = await dbContext.ProviderPreferences + .FirstOrDefaultAsync(preference => preference.ProviderKind == (int)command.ProviderKind, cancellationToken); + if (record is null) + { + record = new ProviderPreferenceRecord + { + ProviderKind = (int)command.ProviderKind, + }; + dbContext.ProviderPreferences.Add(record); + } + + record.IsEnabled = command.IsEnabled; + record.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + + return (await BuildProviderStatusesAsync(dbContext, cancellationToken)) + .First(status => status.Kind == command.ProviderKind); + } + + public async IAsyncEnumerable SendMessageAsync( + SendSessionMessageCommand command, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + await EnsureInitializedAsync(cancellationToken); + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var session = await dbContext.Sessions + .FirstAsync(record => record.Id == command.SessionId.Value, cancellationToken); + var agent = await dbContext.AgentProfiles + .FirstAsync(record => record.Id == session.PrimaryAgentProfileId, cancellationToken); + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agent.ProviderKind); + var providerStatuses = await BuildProviderStatusesAsync(dbContext, cancellationToken); + var providerStatus = providerStatuses.First(status => status.Kind == providerProfile.Kind); + var now = timeProvider.GetUtcNow(); + + var userEntry = CreateEntryRecord(command.SessionId, SessionStreamEntryKind.UserMessage, UserAuthor, command.Message.Trim(), now); + dbContext.SessionEntries.Add(userEntry); + session.UpdatedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return MapEntry(userEntry); + + var statusEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Status, + StatusAuthor, + $"Running {agent.Name} with {providerProfile.DisplayName}.", + timeProvider.GetUtcNow(), + accentLabel: StatusAccentLabel); + yield return MapEntry(statusEntry); + + if (!providerStatus.CanCreateAgents || providerProfile.Kind is not AgentProviderKind.Debug) + { + var notImplementedEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Error, + StatusAuthor, + string.Format( + System.Globalization.CultureInfo.InvariantCulture, + NotYetImplementedCompositeFormat, + providerProfile.DisplayName), + timeProvider.GetUtcNow(), + accentLabel: ErrorAccentLabel); + dbContext.SessionEntries.Add(notImplementedEntry); + session.UpdatedAt = notImplementedEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return MapEntry(notImplementedEntry); + yield break; + } + + var toolStartEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.ToolStarted, + ToolAuthor, + DebugToolStartText, + timeProvider.GetUtcNow(), + agentProfileId: new AgentProfileId(agent.Id), + accentLabel: ToolAccentLabel); + dbContext.SessionEntries.Add(toolStartEntry); + await dbContext.SaveChangesAsync(cancellationToken); + yield return MapEntry(toolStartEntry); + + var debugClient = new DebugChatClient(agent.Name, timeProvider); + var messageHistory = await BuildMessageHistoryAsync(dbContext, command.SessionId, cancellationToken); + var streamedMessageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture); + var accumulated = new System.Text.StringBuilder(); + + await foreach (var update in debugClient.GetStreamingResponseAsync(messageHistory, cancellationToken: cancellationToken)) + { + accumulated.Append(update.Text); + yield return new SessionStreamEntry( + streamedMessageId, + command.SessionId, + SessionStreamEntryKind.AssistantMessage, + agent.Name, + accumulated.ToString(), + update.CreatedAt ?? timeProvider.GetUtcNow(), + new AgentProfileId(agent.Id)); + } + + var assistantEntry = new SessionEntryRecord + { + Id = streamedMessageId, + SessionId = command.SessionId.Value, + AgentProfileId = agent.Id, + Kind = (int)SessionStreamEntryKind.AssistantMessage, + Author = agent.Name, + Text = accumulated.ToString(), + Timestamp = timeProvider.GetUtcNow(), + }; + var toolDoneEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.ToolCompleted, + ToolAuthor, + DebugToolDoneText, + timeProvider.GetUtcNow(), + agentProfileId: new AgentProfileId(agent.Id), + accentLabel: ToolAccentLabel); + + dbContext.SessionEntries.Add(assistantEntry); + dbContext.SessionEntries.Add(toolDoneEntry); + session.UpdatedAt = assistantEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return MapEntry(toolDoneEntry); + } + + private async Task EnsureInitializedAsync(CancellationToken cancellationToken) + { + if (_initialized) + { + return; + } + + await _initializationGate.WaitAsync(cancellationToken); + try + { + if (_initialized) + { + return; + } + + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + _initialized = true; + } + finally + { + _initializationGate.Release(); + } + } + + private static async Task> BuildProviderStatusesAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + var preferences = await dbContext.ProviderPreferences + .ToDictionaryAsync( + preference => (AgentProviderKind)preference.ProviderKind, + cancellationToken); + + return AgentSessionProviderCatalog.All + .Select(profile => BuildProviderStatus(profile, GetProviderPreference(profile.Kind, preferences))) + .ToArray(); + } + + private static ProviderPreferenceRecord GetProviderPreference( + AgentProviderKind kind, + Dictionary preferences) + { + return preferences.TryGetValue(kind, out var preference) + ? preference + : new ProviderPreferenceRecord + { + ProviderKind = (int)kind, + IsEnabled = false, + UpdatedAt = DateTimeOffset.MinValue, + }; + } + + private static ProviderStatusDescriptor BuildProviderStatus( + AgentSessionProviderProfile profile, + ProviderPreferenceRecord preference) + { + var providerId = AgentSessionDeterministicIdentity.CreateProviderId(profile.CommandName); + var actions = new List(); + string? installedVersion = null; + var status = AgentProviderStatus.Ready; + var statusSummary = BuiltInStatusSummary; + var canCreateAgents = true; + + if (OperatingSystem.IsBrowser() && !profile.IsBuiltIn) + { + actions.Add(new ProviderActionDescriptor("Install", "Run this on desktop.", profile.InstallCommand)); + status = AgentProviderStatus.Unsupported; + statusSummary = BrowserStatusSummary; + canCreateAgents = false; + } + else if (profile.IsBuiltIn) + { + installedVersion = profile.DefaultModelName; + } + else + { + var executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); + if (string.IsNullOrWhiteSpace(executablePath)) + { + actions.Add(new ProviderActionDescriptor("Install", "Install the CLI, then refresh settings.", profile.InstallCommand)); + status = AgentProviderStatus.RequiresSetup; + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, MissingCliSummaryCompositeFormat, profile.DisplayName); + canCreateAgents = false; + } + else + { + installedVersion = AgentSessionCommandProbe.ReadVersion(executablePath, ["--version"]); + actions.Add(new ProviderActionDescriptor("Open CLI", "CLI detected on PATH.", $"{profile.CommandName} --version")); + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); + } + } + + if (!preference.IsEnabled) + { + status = AgentProviderStatus.Disabled; + statusSummary = $"{DisabledStatusSummary} {statusSummary}"; + canCreateAgents = false; + } + + return new ProviderStatusDescriptor( + providerId, + profile.Kind, + profile.DisplayName, + profile.CommandName, + status, + statusSummary, + installedVersion, + preference.IsEnabled, + canCreateAgents, + actions); + } + + private static async Task> BuildMessageHistoryAsync( + LocalAgentSessionDbContext dbContext, + SessionId sessionId, + CancellationToken cancellationToken) + { + var entries = await dbContext.SessionEntries + .Where(record => record.SessionId == sessionId.Value) + .OrderBy(record => record.Timestamp) + .ToListAsync(cancellationToken); + + return entries + .Where(record => record.Kind is (int)SessionStreamEntryKind.UserMessage or (int)SessionStreamEntryKind.AssistantMessage) + .Select(record => new ChatMessage( + record.Kind == (int)SessionStreamEntryKind.UserMessage ? ChatRole.User : ChatRole.Assistant, + record.Text) + { + AuthorName = record.Author, + CreatedAt = record.Timestamp, + MessageId = record.Id, + }) + .ToArray(); + } + + private static SessionEntryRecord CreateEntryRecord( + SessionId sessionId, + SessionStreamEntryKind kind, + string author, + string text, + DateTimeOffset timestamp, + AgentProfileId? agentProfileId = null, + string? accentLabel = null) + { + return new SessionEntryRecord + { + Id = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture), + SessionId = sessionId.Value, + AgentProfileId = agentProfileId?.Value, + Kind = (int)kind, + Author = author, + Text = text, + Timestamp = timestamp, + AccentLabel = accentLabel, + }; + } + + private static SessionStreamEntry MapEntry(SessionEntryRecord record) + { + return new SessionStreamEntry( + record.Id, + new SessionId(record.SessionId), + (SessionStreamEntryKind)record.Kind, + record.Author, + record.Text, + record.Timestamp, + record.AgentProfileId is Guid agentProfileId ? new AgentProfileId(agentProfileId) : null, + record.AccentLabel); + } + + private static SessionListItem MapSessionListItem( + SessionRecord record, + Dictionary agentsById, + IReadOnlyList entries) + { + var agent = agentsById[record.PrimaryAgentProfileId]; + var preview = entries + .Where(entry => entry.SessionId == record.Id) + .OrderByDescending(entry => entry.Timestamp) + .Select(entry => entry.Text) + .FirstOrDefault() ?? string.Empty; + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agent.ProviderKind); + + return new SessionListItem( + new SessionId(record.Id), + record.Title, + preview, + providerProfile.DisplayName, + record.UpdatedAt, + new AgentProfileId(agent.Id), + agent.Name, + providerProfile.DisplayName); + } + + private static AgentProfileSummary MapAgentSummary(AgentProfileRecord record) + { + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)record.ProviderKind); + return new AgentProfileSummary( + new AgentProfileId(record.Id), + record.Name, + (AgentRoleKind)record.Role, + (AgentProviderKind)record.ProviderKind, + providerProfile.DisplayName, + record.ModelName, + record.SystemPrompt, + DeserializeCapabilities(record.CapabilitiesJson), + record.CreatedAt); + } + + private static AgentProfileDescriptor MapAgentDescriptor(AgentProfileRecord record) + { + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)record.ProviderKind); + return new AgentProfileDescriptor + { + Id = new AgentProfileId(record.Id), + Name = record.Name, + Role = (AgentRoleKind)record.Role, + ProviderId = AgentSessionDeterministicIdentity.CreateProviderId(providerProfile.CommandName), + ModelRuntimeId = null, + Tags = DeserializeCapabilities(record.CapabilitiesJson).ToArray(), + }; + } + + private static string SerializeCapabilities(IReadOnlyList capabilities) + { + return System.Text.Json.JsonSerializer.Serialize( + capabilities.ToArray(), + AgentSessionJsonSerializerContext.Default.StringArray); + } + + private static string[] DeserializeCapabilities(string capabilitiesJson) + { + return System.Text.Json.JsonSerializer.Deserialize( + capabilitiesJson, + AgentSessionJsonSerializerContext.Default.StringArray) ?? []; + } + + private async Task UpsertAgentGrainAsync(AgentProfileDescriptor descriptor) + { + var grainFactory = serviceProvider.GetService(); + if (grainFactory is null) + { + return; + } + + await grainFactory + .GetGrain(descriptor.Id.ToString()) + .UpsertAsync(descriptor); + } + + private async Task UpsertSessionGrainAsync(SessionRecord record) + { + var grainFactory = serviceProvider.GetService(); + if (grainFactory is null) + { + return; + } + + await grainFactory + .GetGrain(record.Id.ToString("N", System.Globalization.CultureInfo.InvariantCulture)) + .UpsertAsync( + new SessionDescriptor + { + Id = new SessionId(record.Id), + WorkspaceId = WorkspaceId.New(), + Title = record.Title, + Phase = SessionPhase.Execute, + ApprovalState = ApprovalState.NotRequired, + FleetId = null, + AgentProfileIds = [new AgentProfileId(record.PrimaryAgentProfileId)], + CreatedAt = record.CreatedAt, + UpdatedAt = record.UpdatedAt, + }); + } + + public void Dispose() + { + _initializationGate.Dispose(); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs new file mode 100644 index 0000000..5dfd86c --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Runtime.Features.AgentSessions; + +public static class AgentSessionServiceCollectionExtensions +{ + private const string DatabaseFileName = "dotpilot-agent-sessions.db"; + private const string DatabaseFolderName = "DotPilot"; + + public static IServiceCollection AddAgentSessions( + this IServiceCollection services, + AgentSessionStorageOptions? storageOptions = null) + { + services.AddSingleton(storageOptions ?? new AgentSessionStorageOptions()); + services.AddDbContextFactory(ConfigureDbContext); + services.AddSingleton(); + return services; + } + + private static void ConfigureDbContext(IServiceProvider serviceProvider, DbContextOptionsBuilder builder) + { + var storageOptions = serviceProvider.GetRequiredService(); + + if (OperatingSystem.IsBrowser() || storageOptions.UseInMemoryDatabase) + { + builder.UseInMemoryDatabase(storageOptions.InMemoryDatabaseName); + return; + } + + var databasePath = storageOptions.DatabasePath; + if (string.IsNullOrWhiteSpace(databasePath)) + { + var rootPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + DatabaseFolderName); + Directory.CreateDirectory(rootPath); + databasePath = Path.Combine(rootPath, DatabaseFileName); + } + else + { + var databaseDirectory = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(databaseDirectory)) + { + Directory.CreateDirectory(databaseDirectory); + } + } + + builder.UseSqlite($"Data Source={databasePath}"); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs new file mode 100644 index 0000000..6a4442e --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs @@ -0,0 +1,10 @@ +namespace DotPilot.Runtime.Features.AgentSessions; + +public sealed class AgentSessionStorageOptions +{ + public bool UseInMemoryDatabase { get; init; } + + public string InMemoryDatabaseName { get; init; } = "DotPilotAgentSessions"; + + public string? DatabasePath { get; init; } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/DebugChatClient.cs b/DotPilot.Runtime/Features/AgentSessions/DebugChatClient.cs new file mode 100644 index 0000000..dec063b --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/DebugChatClient.cs @@ -0,0 +1,108 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class DebugChatClient(string agentName, TimeProvider timeProvider) : IChatClient +{ + private const int ChunkDelayMilliseconds = 45; + private const string FallbackPrompt = "the latest request"; + private const string Newline = "\n"; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var responseText = CreateResponseText(messages); + var timestamp = timeProvider.GetUtcNow(); + var message = new ChatMessage(ChatRole.Assistant, responseText) + { + AuthorName = agentName, + CreatedAt = timestamp, + MessageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture), + }; + + var response = new ChatResponse(message) + { + CreatedAt = timestamp, + }; + + return Task.FromResult(response); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var responseText = CreateResponseText(messages); + var messageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture); + + foreach (var chunk in SplitIntoChunks(responseText)) + { + cancellationToken.ThrowIfCancellationRequested(); + await Task.Delay(ChunkDelayMilliseconds, cancellationToken); + + yield return new ChatResponseUpdate(ChatRole.Assistant, chunk) + { + AuthorName = agentName, + CreatedAt = timeProvider.GetUtcNow(), + MessageId = messageId, + }; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return serviceType == typeof(IChatClient) ? this : null; + } + + public void Dispose() + { + } + + private static IEnumerable SplitIntoChunks(string text) + { + var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var chunk = new List(4); + + foreach (var word in words) + { + chunk.Add(word); + if (chunk.Count < 4) + { + continue; + } + + yield return string.Join(' ', chunk) + " "; + chunk.Clear(); + } + + if (chunk.Count > 0) + { + yield return string.Join(' ', chunk); + } + } + + private static string CreateResponseText(IEnumerable messages) + { + var prompt = messages + .LastOrDefault(message => message.Role == ChatRole.User) + ?.Text + ?.Trim(); + + var effectivePrompt = string.IsNullOrWhiteSpace(prompt) ? FallbackPrompt : prompt; + return string.Join( + Newline, + [ + $"Debug provider received: {effectivePrompt}", + "This response is deterministic so the desktop shell and UI tests can validate streaming behavior.", + "Tool activity is simulated inline before the final assistant answer completes.", + ]); + } +} + diff --git a/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionDbContext.cs b/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionDbContext.cs new file mode 100644 index 0000000..0acd927 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionDbContext.cs @@ -0,0 +1,108 @@ +using Microsoft.EntityFrameworkCore; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class LocalAgentSessionDbContext(DbContextOptions options) + : DbContext(options) +{ + public DbSet AgentProfiles => Set(); + + public DbSet Sessions => Set(); + + public DbSet SessionEntries => Set(); + + public DbSet ProviderPreferences => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(record => record.Id); + entity.Property(record => record.Name).IsRequired(); + entity.Property(record => record.ModelName).IsRequired(); + entity.Property(record => record.SystemPrompt).IsRequired(); + entity.Property(record => record.CapabilitiesJson).IsRequired(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(record => record.Id); + entity.Property(record => record.Title).IsRequired(); + entity.HasIndex(record => record.UpdatedAt); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(record => record.Id); + entity.Property(record => record.Author).IsRequired(); + entity.Property(record => record.Text).IsRequired(); + entity.HasIndex(record => new { record.SessionId, record.Timestamp }); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(record => record.ProviderKind); + entity.Property(record => record.ProviderKind).ValueGeneratedNever(); + }); + } +} + +internal sealed class AgentProfileRecord +{ + public Guid Id { get; set; } + + public string Name { get; set; } = string.Empty; + + public int Role { get; set; } + + public int ProviderKind { get; set; } + + public string ModelName { get; set; } = string.Empty; + + public string SystemPrompt { get; set; } = string.Empty; + + public string CapabilitiesJson { get; set; } = string.Empty; + + public DateTimeOffset CreatedAt { get; set; } +} + +internal sealed class SessionRecord +{ + public Guid Id { get; set; } + + public string Title { get; set; } = string.Empty; + + public Guid PrimaryAgentProfileId { get; set; } + + public DateTimeOffset CreatedAt { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} + +internal sealed class SessionEntryRecord +{ + public string Id { get; set; } = string.Empty; + + public Guid SessionId { get; set; } + + public Guid? AgentProfileId { get; set; } + + public int Kind { get; set; } + + public string Author { get; set; } = string.Empty; + + public string Text { get; set; } = string.Empty; + + public string? AccentLabel { get; set; } + + public DateTimeOffset Timestamp { get; set; } +} + +internal sealed class ProviderPreferenceRecord +{ + public int ProviderKind { get; set; } + + public bool IsEnabled { get; set; } + + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs b/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs deleted file mode 100644 index bba90f6..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs +++ /dev/null @@ -1,447 +0,0 @@ -using System.Text.Json; -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeCommunication; -using DotPilot.Core.Features.RuntimeFoundation; -using ManagedCode.Communication; -using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Checkpointing; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public sealed class AgentFrameworkRuntimeClient : IAgentRuntimeClient -{ - private const string WorkflowName = "DotPilotRuntimeFoundationWorkflow"; - private const string WorkflowDescription = - "Runs the local-first runtime flow with checkpointed pause and resume support for approval-gated sessions."; - private const string ExecutorId = "runtime-foundation"; - private const string StateKey = "runtime-foundation-state"; - private const string StartReplayKind = "run-started"; - private const string PauseReplayKind = "approval-pending"; - private const string ResumeReplayKind = "run-resumed"; - private const string RejectedReplayKind = "approval-rejected"; - private const string CompletedReplayKind = "run-completed"; - private const string ResumeNotAllowedDetailFormat = - "Session {0} is not paused with pending approval and cannot be resumed."; - private static readonly System.Text.CompositeFormat ResumeNotAllowedDetailCompositeFormat = - System.Text.CompositeFormat.Parse(ResumeNotAllowedDetailFormat); - private readonly IGrainFactory _grainFactory; - private readonly RuntimeSessionArchiveStore _archiveStore; - private readonly DeterministicAgentTurnEngine _turnEngine; - private readonly Workflow _workflow; - private readonly TimeProvider _timeProvider; - - public AgentFrameworkRuntimeClient(IGrainFactory grainFactory, RuntimeSessionArchiveStore archiveStore) - : this(grainFactory, archiveStore, TimeProvider.System) - { - } - - internal AgentFrameworkRuntimeClient( - IGrainFactory grainFactory, - RuntimeSessionArchiveStore archiveStore, - TimeProvider timeProvider) - { - _grainFactory = grainFactory; - _archiveStore = archiveStore; - _timeProvider = timeProvider; - _turnEngine = new DeterministicAgentTurnEngine(timeProvider); - _workflow = BuildWorkflow(); - } - - public async ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var validation = _turnEngine.Execute(request); - if (validation.IsFailed) - { - return validation; - } - - var checkpointDirectory = _archiveStore.CreateCheckpointDirectory(request.SessionId); - using var checkpointStore = new FileSystemJsonCheckpointStore(checkpointDirectory); - var checkpointManager = CheckpointManager.CreateJson(checkpointStore); - await using var run = await InProcessExecution.RunAsync( - _workflow, - RuntimeWorkflowSignal.Start(request), - checkpointManager, - request.SessionId.ToString(), - cancellationToken); - - var result = ExtractOutput(run); - if (result is null) - { - return Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()); - } - - var checkpoint = await ResolveCheckpointAsync(run, checkpointDirectory, request.SessionId.ToString(), cancellationToken); - await PersistRuntimeStateAsync( - request, - result, - checkpoint, - result.ApprovalState is ApprovalState.Pending ? PauseReplayKind : StartReplayKind, - cancellationToken); - - return Result.Succeed(result); - } - - public async ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - StoredRuntimeSessionArchive? archive; - try - { - archive = await _archiveStore.LoadAsync(request.SessionId, cancellationToken); - } - catch (JsonException) - { - return Result.Fail(RuntimeCommunicationProblems.SessionArchiveCorrupted(request.SessionId)); - } - - if (archive is null) - { - return Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(request.SessionId)); - } - - if (archive.Phase is not SessionPhase.Paused || archive.ApprovalState is not ApprovalState.Pending) - { - return Result.Fail(CreateResumeNotAllowedProblem(request.SessionId)); - } - - if (string.IsNullOrWhiteSpace(archive.CheckpointId)) - { - return Result.Fail(RuntimeCommunicationProblems.ResumeCheckpointMissing(request.SessionId)); - } - - var checkpointDirectory = _archiveStore.CreateCheckpointDirectory(request.SessionId); - using var checkpointStore = new FileSystemJsonCheckpointStore(checkpointDirectory); - var checkpointManager = CheckpointManager.CreateJson(checkpointStore); - await using var restoredRun = await InProcessExecution.ResumeAsync( - _workflow, - new CheckpointInfo(archive.WorkflowSessionId, archive.CheckpointId), - checkpointManager, - cancellationToken); - _ = await restoredRun.ResumeAsync(cancellationToken, [RuntimeWorkflowSignal.Resume(request)]); - - var result = ExtractOutput(restoredRun); - if (result is null) - { - return Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()); - } - - var resolvedCheckpoint = - await ResolveCheckpointAsync(restoredRun, checkpointDirectory, archive.WorkflowSessionId, cancellationToken) ?? - new CheckpointInfo(archive.WorkflowSessionId, archive.CheckpointId); - await PersistRuntimeStateAsync( - archive.OriginalRequest, - result, - resolvedCheckpoint, - ResolveResumeReplayKind(request.ApprovalState), - cancellationToken); - - return Result.Succeed(result); - } - - public async ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - try - { - var archive = await _archiveStore.LoadAsync(sessionId, cancellationToken); - if (archive is null) - { - return Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(sessionId)); - } - - return Result.Succeed(RuntimeSessionArchiveStore.ToSnapshot(archive)); - } - catch (JsonException) - { - return Result.Fail(RuntimeCommunicationProblems.SessionArchiveCorrupted(sessionId)); - } - } - - private static AgentTurnResult? ExtractOutput(Run run) - { - return run.OutgoingEvents - .OfType() - .Select(output => output.Data) - .OfType() - .LastOrDefault(); - } - - private static CheckpointInfo? ExtractCheckpoint(Run run) - { - var checkpoints = run.Checkpoints; - return run.OutgoingEvents - .OfType() - .Select(step => step.CompletionInfo?.Checkpoint) - .LastOrDefault(checkpoint => checkpoint is not null) ?? - run.LastCheckpoint ?? - (checkpoints.Count > 0 ? checkpoints[checkpoints.Count - 1] : null); - } - - private static ValueTask ResolveCheckpointAsync( - Run run, - DirectoryInfo checkpointDirectory, - string workflowSessionId, - CancellationToken cancellationToken) - { - return ResolveCheckpointCoreAsync(run, checkpointDirectory, workflowSessionId, cancellationToken); - } - - private static async ValueTask ResolveCheckpointCoreAsync( - Run run, - DirectoryInfo checkpointDirectory, - string workflowSessionId, - CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - for (var attempt = 0; attempt < 200; attempt++) - { - var inMemoryCheckpoint = ExtractCheckpoint(run); - if (inMemoryCheckpoint is not null) - { - return inMemoryCheckpoint; - } - - var persistedCheckpoint = checkpointDirectory - .EnumerateFiles($"{workflowSessionId}_*.json", SearchOption.TopDirectoryOnly) - .OrderByDescending(file => file.LastWriteTimeUtc) - .Select(file => TryCreateCheckpointInfo(workflowSessionId, file)) - .FirstOrDefault(checkpoint => checkpoint is not null); - if (persistedCheckpoint is not null) - { - return persistedCheckpoint; - } - - var status = await run.GetStatusAsync(cancellationToken); - if (status is not RunStatus.Running) - { - await Task.Yield(); - } - - await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationToken); - } - - return ExtractCheckpoint(run); - } - - private static CheckpointInfo? TryCreateCheckpointInfo(string workflowSessionId, FileInfo file) - { - var fileName = Path.GetFileNameWithoutExtension(file.Name); - var prefix = $"{workflowSessionId}_"; - if (!fileName.StartsWith(prefix, StringComparison.Ordinal)) - { - return null; - } - - var checkpointId = fileName[prefix.Length..]; - return string.IsNullOrWhiteSpace(checkpointId) - ? null - : new CheckpointInfo(workflowSessionId, checkpointId); - } - - private async ValueTask PersistRuntimeStateAsync( - AgentTurnRequest originalRequest, - AgentTurnResult result, - CheckpointInfo? checkpoint, - string replayKind, - CancellationToken cancellationToken) - { - var existingArchive = await _archiveStore.LoadAsync(originalRequest.SessionId, cancellationToken); - var replay = existingArchive?.Replay.ToList() ?? []; - var recordedAt = _timeProvider.GetUtcNow(); - replay.Add( - new RuntimeSessionReplayEntry( - replayKind, - result.Summary, - result.NextPhase, - result.ApprovalState, - recordedAt)); - if (result.NextPhase is SessionPhase.Execute or SessionPhase.Review or SessionPhase.Failed) - { - replay.Add( - new RuntimeSessionReplayEntry( - CompletedReplayKind, - result.Summary, - result.NextPhase, - result.ApprovalState, - recordedAt)); - } - - var archive = new StoredRuntimeSessionArchive( - originalRequest.SessionId, - checkpoint?.SessionId ?? existingArchive?.WorkflowSessionId ?? originalRequest.SessionId.ToString(), - checkpoint?.CheckpointId, - originalRequest, - result.NextPhase, - result.ApprovalState, - recordedAt, - replay, - result.ProducedArtifacts); - - await _archiveStore.SaveAsync(archive, cancellationToken); - await UpsertSessionStateAsync(originalRequest, result, recordedAt); - await UpsertArtifactsAsync(result.ProducedArtifacts); - } - - private async ValueTask UpsertSessionStateAsync(AgentTurnRequest request, AgentTurnResult result, DateTimeOffset timestamp) - { - var session = new SessionDescriptor - { - Id = request.SessionId, - WorkspaceId = new WorkspaceId(Guid.Empty), - Title = request.Prompt, - Phase = result.NextPhase, - ApprovalState = result.ApprovalState, - AgentProfileIds = [request.AgentProfileId], - CreatedAt = timestamp, - UpdatedAt = timestamp, - }; - - await _grainFactory.GetGrain(request.SessionId.ToString()).UpsertAsync(session); - } - - private async ValueTask UpsertArtifactsAsync(IReadOnlyList artifacts) - { - foreach (var artifact in artifacts) - { - await _grainFactory.GetGrain(artifact.Id.ToString()).UpsertAsync(artifact); - } - } - - private Workflow BuildWorkflow() - { - var runtimeExecutor = new FunctionExecutor( - ExecutorId, - HandleSignalAsync, - outputTypes: [typeof(AgentTurnResult)], - declareCrossRunShareable: true); - var builder = new WorkflowBuilder(runtimeExecutor) - .WithName(WorkflowName) - .WithDescription(WorkflowDescription) - .WithOutputFrom(runtimeExecutor); - return builder.Build(); - } - - private async ValueTask HandleSignalAsync( - RuntimeWorkflowSignal signal, - IWorkflowContext context, - CancellationToken cancellationToken) - { - var state = await context.ReadOrInitStateAsync(StateKey, static () => new RuntimeWorkflowState(), cancellationToken); - - switch (signal.Kind) - { - case RuntimeWorkflowSignalKind.Start: - await HandleStartAsync(signal, context, cancellationToken); - return; - case RuntimeWorkflowSignalKind.Resume: - await HandleResumeAsync(signal, state, context, cancellationToken); - return; - default: - await context.RequestHaltAsync(); - return; - } - } - - private async ValueTask HandleStartAsync( - RuntimeWorkflowSignal signal, - IWorkflowContext context, - CancellationToken cancellationToken) - { - var request = signal.Request ?? throw new InvalidOperationException("Runtime workflow start requires an AgentTurnRequest."); - var result = _turnEngine.Execute(request); - if (result.IsFailed) - { - await context.RequestHaltAsync(); - return; - } - - var output = result.Value!; - await context.QueueStateUpdateAsync( - StateKey, - new RuntimeWorkflowState - { - OriginalRequest = request, - ApprovalPending = output.ApprovalState is ApprovalState.Pending, - }, - cancellationToken); - await context.YieldOutputAsync(output, cancellationToken); - await context.RequestHaltAsync(); - } - - private static string ResolveResumeReplayKind(ApprovalState approvalState) - { - return approvalState switch - { - ApprovalState.Approved => ResumeReplayKind, - ApprovalState.Rejected => RejectedReplayKind, - _ => PauseReplayKind, - }; - } - - private static Problem CreateResumeNotAllowedProblem(SessionId sessionId) - { - return Problem.Create( - RuntimeCommunicationProblemCode.ResumeCheckpointMissing, - string.Format( - System.Globalization.CultureInfo.InvariantCulture, - ResumeNotAllowedDetailCompositeFormat, - sessionId), - (int)System.Net.HttpStatusCode.Conflict); - } - - private async ValueTask HandleResumeAsync( - RuntimeWorkflowSignal signal, - RuntimeWorkflowState state, - IWorkflowContext context, - CancellationToken cancellationToken) - { - if (state.OriginalRequest is null) - { - await context.RequestHaltAsync(); - return; - } - - var resumeRequest = signal.ResumeRequest ?? throw new InvalidOperationException("Runtime workflow resume requires an AgentTurnResumeRequest."); - var resumedOutput = _turnEngine.Resume(state.OriginalRequest, resumeRequest); - await context.QueueStateUpdateAsync( - StateKey, - state with - { - ApprovalPending = resumedOutput.ApprovalState is ApprovalState.Pending, - }, - cancellationToken); - await context.YieldOutputAsync(resumedOutput, cancellationToken); - await context.RequestHaltAsync(); - } -} - -internal enum RuntimeWorkflowSignalKind -{ - Start, - Resume, -} - -internal sealed record RuntimeWorkflowSignal( - RuntimeWorkflowSignalKind Kind, - AgentTurnRequest? Request, - AgentTurnResumeRequest? ResumeRequest) -{ - public static RuntimeWorkflowSignal Start(AgentTurnRequest request) => - new(RuntimeWorkflowSignalKind.Start, request, null); - - public static RuntimeWorkflowSignal Resume(AgentTurnResumeRequest request) => - new(RuntimeWorkflowSignalKind.Resume, null, request); -} - -internal sealed record RuntimeWorkflowState -{ - public AgentTurnRequest? OriginalRequest { get; init; } - - public bool ApprovalPending { get; init; } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs deleted file mode 100644 index 1d32121..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs +++ /dev/null @@ -1,60 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeCommunication; -using DotPilot.Core.Features.RuntimeFoundation; -using ManagedCode.Communication; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public sealed class DeterministicAgentRuntimeClient : IAgentRuntimeClient -{ - private readonly DeterministicAgentTurnEngine _engine; - - public DeterministicAgentRuntimeClient() - : this(TimeProvider.System) - { - } - - internal DeterministicAgentRuntimeClient(TimeProvider timeProvider) - { - _engine = new DeterministicAgentTurnEngine(timeProvider); - } - - public ValueTask> ExecuteAsync(AgentTurnRequest request, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(NormalizeArtifacts(_engine.Execute(request))); - } - - public ValueTask> ResumeAsync(AgentTurnResumeRequest request, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - _ = request; - return ValueTask.FromResult(Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable())); - } - - public ValueTask> GetSessionArchiveAsync(SessionId sessionId, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - return ValueTask.FromResult(Result.Fail(RuntimeCommunicationProblems.SessionArchiveMissing(sessionId))); - } - - private static Result NormalizeArtifacts(Result result) - { - if (result.IsFailed || result.Value is null) - { - return result; - } - - var outcome = result.Value; - var normalizedArtifacts = outcome.ProducedArtifacts - .Select(artifact => artifact with { CreatedAt = RuntimeFoundationDeterministicIdentity.ArtifactCreatedAt }) - .ToArray(); - - return Result.Succeed( - new AgentTurnResult( - outcome.Summary, - outcome.NextPhase, - outcome.ApprovalState, - normalizedArtifacts)); - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs b/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs deleted file mode 100644 index 264956d..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeCommunication; -using DotPilot.Core.Features.RuntimeFoundation; -using ManagedCode.Communication; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -internal sealed class DeterministicAgentTurnEngine(TimeProvider timeProvider) -{ - private const string ApprovalKeyword = "approval"; - private const string PlanSummary = - "Prepared a local-first runtime plan with isolated orchestration, storage, and policy boundaries."; - private const string ExecuteSummary = - "Completed the deterministic runtime flow and produced the expected local session artifacts."; - private const string PendingApprovalSummary = - "Paused the deterministic runtime flow because the prompt requested an approval checkpoint."; - private const string ResumedExecutionSummary = - "Resumed the persisted runtime flow after approval and completed the pending execution step."; - private const string RejectedExecutionSummary = - "Stopped the persisted runtime flow because the approval checkpoint was rejected."; - private const string ReviewSummary = - "Reviewed the runtime output and prepared the local session summary for the next operator action."; - private const string PlanArtifact = "runtime-foundation.plan.md"; - private const string ExecuteArtifact = "runtime-foundation.snapshot.json"; - private const string ReviewArtifact = "runtime-foundation.review.md"; - private const string ArtifactIdentityDelimiter = "|"; - - public Result Execute(AgentTurnRequest request) - { - if (string.IsNullOrWhiteSpace(request.Prompt)) - { - return Result.Fail(RuntimeCommunicationProblems.InvalidPrompt()); - } - - if (request.ProviderStatus is not ProviderConnectionStatus.Available) - { - return Result.Fail( - RuntimeCommunicationProblems.ProviderUnavailable( - request.ProviderStatus, - ProviderToolchainNames.DeterministicClientDisplayName)); - } - - return request.Mode switch - { - AgentExecutionMode.Plan => Result.Succeed( - CreateResult( - request.SessionId, - request.AgentProfileId, - PlanSummary, - SessionPhase.Plan, - ApprovalState.NotRequired, - PlanArtifact, - ArtifactKind.Plan)), - AgentExecutionMode.Execute when RequiresApproval(request.Prompt) => Result.Succeed( - CreateResult( - request.SessionId, - request.AgentProfileId, - PendingApprovalSummary, - SessionPhase.Paused, - ApprovalState.Pending, - ExecuteArtifact, - ArtifactKind.Snapshot)), - AgentExecutionMode.Execute => Result.Succeed( - CreateResult( - request.SessionId, - request.AgentProfileId, - ExecuteSummary, - SessionPhase.Execute, - ApprovalState.NotRequired, - ExecuteArtifact, - ArtifactKind.Snapshot)), - AgentExecutionMode.Review => Result.Succeed( - CreateResult( - request.SessionId, - request.AgentProfileId, - ReviewSummary, - SessionPhase.Review, - ApprovalState.Approved, - ReviewArtifact, - ArtifactKind.Report)), - _ => Result.Fail(RuntimeCommunicationProblems.OrchestrationUnavailable()), - }; - } - - public AgentTurnResult Resume(AgentTurnRequest request, AgentTurnResumeRequest resumeRequest) - { - ArgumentNullException.ThrowIfNull(request); - ArgumentNullException.ThrowIfNull(resumeRequest); - - return resumeRequest.ApprovalState switch - { - ApprovalState.Approved => CreateResult( - request.SessionId, - request.AgentProfileId, - ResumedExecutionSummary, - SessionPhase.Execute, - ApprovalState.Approved, - ExecuteArtifact, - ArtifactKind.Snapshot), - ApprovalState.Rejected => CreateResult( - request.SessionId, - request.AgentProfileId, - string.IsNullOrWhiteSpace(resumeRequest.Summary) ? RejectedExecutionSummary : resumeRequest.Summary, - SessionPhase.Failed, - ApprovalState.Rejected, - ReviewArtifact, - ArtifactKind.Report), - _ => CreateResult( - request.SessionId, - request.AgentProfileId, - PendingApprovalSummary, - SessionPhase.Paused, - ApprovalState.Pending, - ExecuteArtifact, - ArtifactKind.Snapshot), - }; - } - - public static bool RequiresApproval(string prompt) - { - return prompt.Contains(ApprovalKeyword, StringComparison.OrdinalIgnoreCase); - } - - private AgentTurnResult CreateResult( - SessionId sessionId, - AgentProfileId agentProfileId, - string summary, - SessionPhase nextPhase, - ApprovalState approvalState, - string artifactName, - ArtifactKind artifactKind) - { - return new AgentTurnResult( - summary, - nextPhase, - approvalState, - [CreateArtifact(sessionId, agentProfileId, artifactName, artifactKind)]); - } - - private ArtifactDescriptor CreateArtifact( - SessionId sessionId, - AgentProfileId agentProfileId, - string artifactName, - ArtifactKind artifactKind) - { - return new ArtifactDescriptor - { - Id = new ArtifactId(CreateDeterministicGuid(sessionId, artifactName, artifactKind)), - SessionId = sessionId, - AgentProfileId = agentProfileId, - Name = artifactName, - Kind = artifactKind, - RelativePath = artifactName, - CreatedAt = timeProvider.GetUtcNow(), - }; - } - - private static Guid CreateDeterministicGuid(SessionId sessionId, string artifactName, ArtifactKind artifactKind) - { - var rawIdentity = string.Join( - ArtifactIdentityDelimiter, - sessionId.ToString(), - artifactName, - artifactKind.ToString()); - Span bytes = stackalloc byte[32]; - SHA256.HashData(Encoding.UTF8.GetBytes(rawIdentity), bytes); - return new Guid(bytes[..16]); - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs deleted file mode 100644 index dc9c3b7..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainNames.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -internal static class ProviderToolchainNames -{ - public const string DeterministicClientDisplayName = "In-Repo Test Client"; - public const string DeterministicClientCommandName = "embedded"; - public const string CodexDisplayName = "Codex CLI"; - public const string CodexCommandName = "codex"; - public const string ClaudeCodeDisplayName = "Claude Code"; - public const string ClaudeCodeCommandName = "claude"; - public const string GitHubCopilotDisplayName = "GitHub Copilot"; - public const string GitHubCopilotCommandName = "gh"; -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs b/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs deleted file mode 100644 index 5645132..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs +++ /dev/null @@ -1,77 +0,0 @@ -using System.Runtime.InteropServices; -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -internal sealed class ProviderToolchainProbe -{ - private const string MissingStatusSummaryFormat = "{0} is not on PATH."; - private const string AvailableStatusSummaryFormat = "{0} is available on PATH."; - private static readonly System.Text.CompositeFormat MissingStatusSummaryCompositeFormat = - System.Text.CompositeFormat.Parse(MissingStatusSummaryFormat); - private static readonly System.Text.CompositeFormat AvailableStatusSummaryCompositeFormat = - System.Text.CompositeFormat.Parse(AvailableStatusSummaryFormat); - - public static ProviderDescriptor Probe(string displayName, string commandName, bool requiresExternalToolchain) - { - var status = ResolveExecutablePath(commandName) is null - ? ProviderConnectionStatus.Unavailable - : ProviderConnectionStatus.Available; - var statusSummary = string.Format( - System.Globalization.CultureInfo.InvariantCulture, - status is ProviderConnectionStatus.Available - ? AvailableStatusSummaryCompositeFormat - : MissingStatusSummaryCompositeFormat, - displayName); - - return new ProviderDescriptor - { - Id = RuntimeFoundationDeterministicIdentity.CreateProviderId(commandName), - DisplayName = displayName, - CommandName = commandName, - Status = status, - StatusSummary = statusSummary, - RequiresExternalToolchain = requiresExternalToolchain, - }; - } - - internal static string? ResolveExecutablePath(string commandName) - { - if (OperatingSystem.IsBrowser()) - { - return null; - } - - var searchPaths = (Environment.GetEnvironmentVariable("PATH") ?? string.Empty) - .Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - - foreach (var searchPath in searchPaths) - { - foreach (var candidate in EnumerateCandidates(searchPath, commandName)) - { - if (File.Exists(candidate)) - { - return candidate; - } - } - } - - return null; - } - - private static IEnumerable EnumerateCandidates(string searchPath, string commandName) - { - yield return Path.Combine(searchPath, commandName); - - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - yield break; - } - - foreach (var extension in (Environment.GetEnvironmentVariable("PATHEXT") ?? ".EXE;.CMD;.BAT") - .Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - yield return Path.Combine(searchPath, string.Concat(commandName, extension)); - } - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs deleted file mode 100644 index 1c31d8b..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs +++ /dev/null @@ -1,109 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public sealed class RuntimeFoundationCatalog : IRuntimeFoundationCatalog -{ - private const string EpicSummary = - "The embedded runtime stays local-first by isolating contracts, host wiring, orchestration, policy, and durable session archives away from the Uno presentation layer."; - private const string EpicLabelValue = "LOCAL RUNTIME READINESS"; - private const string DeterministicProbePrompt = - "Summarize the runtime foundation readiness for a local-first session that may require approval."; - private const string DeterministicClientStatusSummary = "Always available for in-repo and CI validation."; - private const string DomainModelLabel = "DOMAIN"; - private const string DomainModelName = "Domain contracts"; - private const string DomainModelSummary = - "Typed identifiers and durable agent, session, fleet, provider, and runtime contracts live outside the Uno app."; - private const string CommunicationLabel = "CONTRACTS"; - private const string CommunicationName = "Communication contracts"; - private const string CommunicationSummary = - "Public result and problem boundaries are isolated so later provider and orchestration slices share one contract language."; - private const string HostLabel = "HOST"; - private const string HostName = "Embedded host"; - private const string HostSummary = - "The Orleans host integration point is sequenced behind dedicated runtime contracts instead of being baked into page code."; - private const string OrchestrationLabel = "ORCHESTRATION"; - private const string OrchestrationName = "Orchestration runtime"; - private const string OrchestrationSummary = - "Agent Framework orchestrates local runs, approvals, and checkpoints without moving execution logic into the Uno app."; - private const string TrafficPolicyName = "Traffic policy"; - private const string TrafficPolicySummary = - "Allowed grain transitions are explicit, testable, and surfaced through the embedded traffic-policy Mermaid catalog instead of hidden conventions."; - private const string SessionPersistenceName = "Session persistence"; - private const string SessionPersistenceSummary = - "Checkpoint, replay, and resume data survive host restarts in local session archives without changing the Orleans storage topology."; - private readonly IReadOnlyList _providers; - - public RuntimeFoundationCatalog() => _providers = Array.AsReadOnly(CreateProviders()); - - public RuntimeFoundationSnapshot GetSnapshot() - { - return new( - EpicLabelValue, - EpicSummary, - ProviderToolchainNames.DeterministicClientDisplayName, - DeterministicProbePrompt, - CreateSlices(), - _providers); - } - - private static IReadOnlyList CreateSlices() - { - return - [ - new( - RuntimeFoundationIssues.DomainModel, - DomainModelLabel, - DomainModelName, - DomainModelSummary, - RuntimeSliceState.ReadyForImplementation), - new( - RuntimeFoundationIssues.CommunicationContracts, - CommunicationLabel, - CommunicationName, - CommunicationSummary, - RuntimeSliceState.Sequenced), - new( - RuntimeFoundationIssues.EmbeddedOrleansHost, - HostLabel, - HostName, - HostSummary, - RuntimeSliceState.Sequenced), - new( - RuntimeFoundationIssues.AgentFrameworkRuntime, - OrchestrationLabel, - OrchestrationName, - OrchestrationSummary, - RuntimeSliceState.Sequenced), - new( - RuntimeFoundationIssues.GrainTrafficPolicy, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), - TrafficPolicyName, - TrafficPolicySummary, - RuntimeSliceState.Sequenced), - new( - RuntimeFoundationIssues.SessionPersistence, - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.SessionPersistence), - SessionPersistenceName, - SessionPersistenceSummary, - RuntimeSliceState.Sequenced), - ]; - } - - private static ProviderDescriptor[] CreateProviders() - { - return - [ - new ProviderDescriptor - { - Id = RuntimeFoundationDeterministicIdentity.CreateProviderId(ProviderToolchainNames.DeterministicClientCommandName), - DisplayName = ProviderToolchainNames.DeterministicClientDisplayName, - CommandName = ProviderToolchainNames.DeterministicClientCommandName, - Status = ProviderConnectionStatus.Available, - StatusSummary = DeterministicClientStatusSummary, - RequiresExternalToolchain = false, - }, - ]; - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs deleted file mode 100644 index 67d39ad..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationServiceCollectionExtensions.cs +++ /dev/null @@ -1,29 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; -using Microsoft.Extensions.DependencyInjection; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public static class RuntimeFoundationServiceCollectionExtensions -{ - public static IServiceCollection AddDesktopRuntimeFoundation( - this IServiceCollection services, - RuntimePersistenceOptions? persistenceOptions = null) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddSingleton(persistenceOptions ?? new RuntimePersistenceOptions()); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - return services; - } - - public static IServiceCollection AddBrowserRuntimeFoundation(this IServiceCollection services) - { - ArgumentNullException.ThrowIfNull(services); - - services.AddSingleton(); - services.AddSingleton(); - return services; - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs deleted file mode 100644 index 463cdca..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimePersistenceOptions.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public sealed class RuntimePersistenceOptions -{ - private const string DotPilotDirectoryName = "dotPilot"; - private const string RuntimeDirectoryName = "runtime"; - private const string SessionsDirectoryName = "sessions"; - - public string RootDirectoryPath { get; init; } = CreateDefaultRootDirectoryPath(); - - public static string CreateDefaultRootDirectoryPath() - { - return Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - DotPilotDirectoryName, - RuntimeDirectoryName, - SessionsDirectoryName); - } -} diff --git a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs b/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs deleted file mode 100644 index 6cf6e19..0000000 --- a/DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs +++ /dev/null @@ -1,123 +0,0 @@ -using System.Text; -using System.Text.Json; -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Features.RuntimeFoundation; - -public sealed class RuntimeSessionArchiveStore(RuntimePersistenceOptions options) -{ - private const string ArchiveFileName = "archive.json"; - private const string ReplayFileName = "replay.md"; - private const string CheckpointsDirectoryName = "checkpoints"; - private const string ReplayBulletPrefix = "- "; - private const string ReplaySeparator = " | "; - private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web) - { - WriteIndented = true, - }; - - internal async ValueTask LoadAsync(SessionId sessionId, CancellationToken cancellationToken) - { - var archivePath = GetArchivePath(sessionId); - if (!File.Exists(archivePath)) - { - return null; - } - - await using var stream = File.OpenRead(archivePath); - return await JsonSerializer.DeserializeAsync(stream, SerializerOptions, cancellationToken); - } - - internal async ValueTask SaveAsync(StoredRuntimeSessionArchive archive, CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(archive); - - var sessionDirectory = GetSessionDirectory(archive.SessionId); - Directory.CreateDirectory(sessionDirectory); - var archivePath = GetArchivePath(archive.SessionId); - await using (var stream = File.Create(archivePath)) - { - await JsonSerializer.SerializeAsync(stream, archive, SerializerOptions, cancellationToken); - } - - await File.WriteAllTextAsync( - GetReplayPath(archive.SessionId), - BuildReplayMarkdown(archive.Replay), - Encoding.UTF8, - cancellationToken); - } - - internal DirectoryInfo CreateCheckpointDirectory(SessionId sessionId) - { - var checkpointDirectory = GetCheckpointDirectoryPath(sessionId); - Directory.CreateDirectory(checkpointDirectory); - return new DirectoryInfo(checkpointDirectory); - } - - internal static RuntimeSessionArchive ToSnapshot(StoredRuntimeSessionArchive archive) - { - ArgumentNullException.ThrowIfNull(archive); - - return new RuntimeSessionArchive( - archive.SessionId, - archive.WorkflowSessionId, - archive.Phase, - archive.ApprovalState, - archive.UpdatedAt, - archive.CheckpointId, - archive.Replay, - archive.Artifacts); - } - - private string GetSessionDirectory(SessionId sessionId) - { - return Path.Combine(options.RootDirectoryPath, sessionId.ToString()); - } - - private string GetArchivePath(SessionId sessionId) - { - return Path.Combine(GetSessionDirectory(sessionId), ArchiveFileName); - } - - private string GetReplayPath(SessionId sessionId) - { - return Path.Combine(GetSessionDirectory(sessionId), ReplayFileName); - } - - private string GetCheckpointDirectoryPath(SessionId sessionId) - { - return Path.Combine(GetSessionDirectory(sessionId), CheckpointsDirectoryName); - } - - private static string BuildReplayMarkdown(IReadOnlyList replayEntries) - { - var builder = new StringBuilder(); - foreach (var entry in replayEntries) - { - _ = builder.Append(ReplayBulletPrefix) - .Append(entry.RecordedAt.ToString("O", System.Globalization.CultureInfo.InvariantCulture)) - .Append(ReplaySeparator) - .Append(entry.Kind) - .Append(ReplaySeparator) - .Append(entry.Phase) - .Append(ReplaySeparator) - .Append(entry.ApprovalState) - .Append(ReplaySeparator) - .AppendLine(entry.Summary); - } - - return builder.ToString(); - } -} - -internal sealed record StoredRuntimeSessionArchive( - SessionId SessionId, - string WorkflowSessionId, - string? CheckpointId, - AgentTurnRequest OriginalRequest, - SessionPhase Phase, - ApprovalState ApprovalState, - DateTimeOffset UpdatedAt, - IReadOnlyList Replay, - IReadOnlyList Artifacts); diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs deleted file mode 100644 index 60f6b84..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs +++ /dev/null @@ -1,144 +0,0 @@ -using DotPilot.Core.Features.ToolchainCenter; - -namespace DotPilot.Runtime.Features.ToolchainCenter; - -public sealed class ToolchainCenterCatalog : IToolchainCenterCatalog, IDisposable -{ - private const string EpicLabelValue = "PRE-SESSION READINESS"; - private const string EpicSummary = - "Provider installation, launch checks, authentication, configuration, and refresh state stay visible before the first live session."; - private const string UiWorkstreamLabel = "SURFACE"; - private const string UiWorkstreamName = "Toolchain Center UI"; - private const string UiWorkstreamSummary = - "The settings shell exposes a first-class desktop Toolchain Center with provider cards, detail panes, and operator actions."; - private const string DiagnosticsWorkstreamLabel = "DIAGNOSTICS"; - private const string DiagnosticsWorkstreamName = "Connection diagnostics"; - private const string DiagnosticsWorkstreamSummary = - "Launch, connection, resume, tool access, and auth diagnostics stay attributable before live work starts."; - private const string ConfigurationWorkstreamLabel = "CONFIGURATION"; - private const string ConfigurationWorkstreamName = "Secrets and environment"; - private const string ConfigurationWorkstreamSummary = - "Provider secrets, local overrides, and non-secret environment configuration stay visible without leaking values."; - private const string PollingWorkstreamLabel = "POLLING"; - private const string PollingWorkstreamName = "Background polling"; - private const string PollingWorkstreamSummary = - "Version and auth readiness refresh in the background so the app can surface stale state early."; - private readonly TimeProvider _timeProvider; - private readonly CancellationTokenSource _disposeTokenSource = new(); - private readonly PeriodicTimer? _pollingTimer; - private readonly Task _pollingTask; - private ToolchainCenterSnapshot _snapshot; - private int _disposeState; - - public ToolchainCenterCatalog() - : this(TimeProvider.System, startBackgroundPolling: true) - { - } - - public ToolchainCenterCatalog(TimeProvider timeProvider, bool startBackgroundPolling) - { - ArgumentNullException.ThrowIfNull(timeProvider); - - _timeProvider = timeProvider; - _snapshot = EvaluateSnapshot(); - if (startBackgroundPolling) - { - _pollingTimer = new PeriodicTimer(TimeSpan.FromMinutes(5), timeProvider); - _pollingTask = Task.Run(PollAsync); - } - else - { - _pollingTask = Task.CompletedTask; - } - } - - public ToolchainCenterSnapshot GetSnapshot() => Volatile.Read(ref _snapshot); - - public void Dispose() - { - if (Interlocked.Exchange(ref _disposeState, 1) != 0) - { - return; - } - - _disposeTokenSource.Cancel(); - _pollingTimer?.Dispose(); - - try - { - _pollingTask.GetAwaiter().GetResult(); - } - catch (OperationCanceledException) - { - // Expected during shutdown. - } - - _disposeTokenSource.Dispose(); - GC.SuppressFinalize(this); - } - - private async Task PollAsync() - { - if (_pollingTimer is null) - { - return; - } - - try - { - while (await _pollingTimer.WaitForNextTickAsync(_disposeTokenSource.Token)) - { - Volatile.Write(ref _snapshot, EvaluateSnapshot()); - } - } - catch (OperationCanceledException) - { - // Expected during app shutdown. - } - catch (ObjectDisposedException) when (_disposeTokenSource.IsCancellationRequested) - { - // Expected when the timer is disposed during shutdown. - } - } - - private ToolchainCenterSnapshot EvaluateSnapshot() - { - var evaluatedAt = _timeProvider.GetUtcNow(); - var providers = ToolchainProviderSnapshotFactory.Create(evaluatedAt); - return new( - EpicLabelValue, - EpicSummary, - CreateWorkstreams(), - providers, - ToolchainProviderSnapshotFactory.CreateBackgroundPolling(providers, evaluatedAt), - providers.Count(provider => provider.ReadinessState is ToolchainReadinessState.Ready), - providers.Count(provider => provider.ReadinessState is not ToolchainReadinessState.Ready)); - } - - private static IReadOnlyList CreateWorkstreams() - { - return - [ - new( - ToolchainCenterIssues.ToolchainCenterUi, - UiWorkstreamLabel, - UiWorkstreamName, - UiWorkstreamSummary), - new( - ToolchainCenterIssues.ConnectionDiagnostics, - DiagnosticsWorkstreamLabel, - DiagnosticsWorkstreamName, - DiagnosticsWorkstreamSummary), - new( - ToolchainCenterIssues.ProviderConfiguration, - ConfigurationWorkstreamLabel, - ConfigurationWorkstreamName, - ConfigurationWorkstreamSummary), - new( - ToolchainCenterIssues.BackgroundPolling, - PollingWorkstreamLabel, - PollingWorkstreamName, - PollingWorkstreamSummary), - ]; - } -} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs deleted file mode 100644 index 87b81ac..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainConfigurationSignal.cs +++ /dev/null @@ -1,10 +0,0 @@ -using DotPilot.Core.Features.ToolchainCenter; - -namespace DotPilot.Runtime.Features.ToolchainCenter; - -internal sealed record ToolchainConfigurationSignal( - string Name, - string Summary, - ToolchainConfigurationKind Kind, - bool IsSensitive, - bool IsRequiredForReadiness); diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs deleted file mode 100644 index 13e5ebb..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainDeterministicIdentity.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System.Security.Cryptography; -using System.Text; -using DotPilot.Core.Features.ControlPlaneDomain; - -namespace DotPilot.Runtime.Features.ToolchainCenter; - -internal static class ToolchainDeterministicIdentity -{ - public static ProviderId CreateProviderId(string commandName) - { - ArgumentException.ThrowIfNullOrWhiteSpace(commandName); - - var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(commandName)); - Span guidBytes = stackalloc byte[16]; - bytes.AsSpan(0, guidBytes.Length).CopyTo(guidBytes); - - guidBytes[6] = (byte)((guidBytes[6] & 0x0F) | 0x50); - guidBytes[8] = (byte)((guidBytes[8] & 0x3F) | 0x80); - - return new ProviderId(new Guid(guidBytes)); - } -} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs deleted file mode 100644 index 4b0171e..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfile.cs +++ /dev/null @@ -1,14 +0,0 @@ -namespace DotPilot.Runtime.Features.ToolchainCenter; - -internal sealed record ToolchainProviderProfile( - int IssueNumber, - string SectionLabel, - string DisplayName, - string CommandName, - IReadOnlyList VersionArguments, - IReadOnlyList ToolAccessArguments, - string ToolAccessDiagnosticName, - string ToolAccessReadySummary, - string ToolAccessBlockedSummary, - IReadOnlyList AuthenticationEnvironmentVariables, - IReadOnlyList ConfigurationSignals); diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs deleted file mode 100644 index e22bf44..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderProfiles.cs +++ /dev/null @@ -1,79 +0,0 @@ -using DotPilot.Core.Features.ToolchainCenter; -using DotPilot.Runtime.Features.RuntimeFoundation; - -namespace DotPilot.Runtime.Features.ToolchainCenter; - -internal static class ToolchainProviderProfiles -{ - private const string OpenAiApiKey = "OPENAI_API_KEY"; - private const string OpenAiBaseUrl = "OPENAI_BASE_URL"; - private const string AnthropicApiKey = "ANTHROPIC_API_KEY"; - private const string AnthropicBaseUrl = "ANTHROPIC_BASE_URL"; - private const string GitHubToken = "GITHUB_TOKEN"; - private const string GitHubHostToken = "GH_TOKEN"; - private const string GitHubModelsApiKey = "GITHUB_MODELS_API_KEY"; - private const string OpenAiApiKeySummary = "Required secret for Codex-ready non-interactive sessions."; - private const string OpenAiBaseUrlSummary = "Optional endpoint override for Codex-compatible deployments."; - private const string AnthropicApiKeySummary = "Required secret for Claude Code non-interactive sessions."; - private const string AnthropicBaseUrlSummary = "Optional endpoint override for Claude-compatible routing."; - private const string GitHubTokenSummary = "GitHub token for Copilot and GitHub CLI authenticated flows."; - private const string GitHubHostTokenSummary = "Alternative GitHub host token for CLI-authenticated Copilot flows."; - private const string GitHubModelsApiKeySummary = "Optional BYOK key for GitHub Models-backed Copilot routing."; - private const string CodexSectionLabel = "CODEX"; - private const string ClaudeSectionLabel = "CLAUDE"; - private const string GitHubSectionLabel = "GITHUB"; - private static readonly string[] VersionArguments = ["--version"]; - - public static IReadOnlyList All { get; } = - [ - new( - ToolchainCenterIssues.CodexReadiness, - CodexSectionLabel, - ProviderToolchainNames.CodexDisplayName, - ProviderToolchainNames.CodexCommandName, - VersionArguments, - ToolAccessArguments: [], - ToolAccessDiagnosticName: "Tool access", - ToolAccessReadySummary: "The Codex CLI command surface is reachable for session startup.", - ToolAccessBlockedSummary: "Install Codex CLI before tool access can be validated.", - AuthenticationEnvironmentVariables: [OpenAiApiKey], - ConfigurationSignals: - [ - new(OpenAiApiKey, OpenAiApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), - new(OpenAiBaseUrl, OpenAiBaseUrlSummary, ToolchainConfigurationKind.EnvironmentVariable, IsSensitive: false, IsRequiredForReadiness: false), - ]), - new( - ToolchainCenterIssues.ClaudeCodeReadiness, - ClaudeSectionLabel, - ProviderToolchainNames.ClaudeCodeDisplayName, - ProviderToolchainNames.ClaudeCodeCommandName, - VersionArguments, - ToolAccessArguments: [], - ToolAccessDiagnosticName: "MCP surface", - ToolAccessReadySummary: "Claude Code is installed and can expose its MCP-oriented CLI surface.", - ToolAccessBlockedSummary: "Install Claude Code before MCP-oriented checks can run.", - AuthenticationEnvironmentVariables: [AnthropicApiKey], - ConfigurationSignals: - [ - new(AnthropicApiKey, AnthropicApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), - new(AnthropicBaseUrl, AnthropicBaseUrlSummary, ToolchainConfigurationKind.EnvironmentVariable, IsSensitive: false, IsRequiredForReadiness: false), - ]), - new( - ToolchainCenterIssues.GitHubCopilotReadiness, - GitHubSectionLabel, - ProviderToolchainNames.GitHubCopilotDisplayName, - ProviderToolchainNames.GitHubCopilotCommandName, - VersionArguments, - ToolAccessArguments: ["copilot", "--help"], - ToolAccessDiagnosticName: "Copilot command group", - ToolAccessReadySummary: "GitHub CLI exposes the Copilot command group for SDK-first adapter work.", - ToolAccessBlockedSummary: "GitHub CLI is present, but the Copilot command group is not available yet.", - AuthenticationEnvironmentVariables: [GitHubHostToken, GitHubToken], - ConfigurationSignals: - [ - new(GitHubHostToken, GitHubHostTokenSummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), - new(GitHubToken, GitHubTokenSummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: true), - new(GitHubModelsApiKey, GitHubModelsApiKeySummary, ToolchainConfigurationKind.Secret, IsSensitive: true, IsRequiredForReadiness: false), - ]), - ]; -} diff --git a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs b/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs deleted file mode 100644 index 6dd988a..0000000 --- a/DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs +++ /dev/null @@ -1,395 +0,0 @@ -using DotPilot.Core.Features.ControlPlaneDomain; -using DotPilot.Core.Features.ToolchainCenter; - -namespace DotPilot.Runtime.Features.ToolchainCenter; - -internal static class ToolchainProviderSnapshotFactory -{ - private static readonly TimeSpan BackgroundRefreshInterval = TimeSpan.FromMinutes(5); - private const string InstallActionTitleFormat = "Install {0}"; - private const string ConnectActionTitleFormat = "Connect {0}"; - private const string UpdateActionTitleFormat = "Update {0}"; - private const string TestActionTitleFormat = "Test {0}"; - private const string TroubleshootActionTitleFormat = "Troubleshoot {0}"; - private const string DocsActionTitleFormat = "Review {0} setup"; - private const string MissingExecutablePath = "Not detected"; - private const string MissingVersion = "Unavailable"; - private const string MissingVersionSummary = "Install the CLI before version checks can run."; - private const string UnknownVersionSummary = "The executable is present, but the version could not be confirmed automatically."; - private const string VersionSummaryFormat = "Detected version {0}."; - private const string AuthMissingSummary = "No non-interactive authentication signal was detected."; - private const string AuthConnectedSummary = "A non-interactive authentication signal is configured."; - private const string ReadinessMissingSummaryFormat = "{0} is not installed on PATH."; - private const string ReadinessLaunchFailedSummaryFormat = "{0} is on PATH, but dotPilot could not launch the CLI automatically."; - private const string ReadinessAuthRequiredSummaryFormat = "{0} is installed, but authentication still needs operator attention."; - private const string ReadinessLimitedSummaryFormat = "{0} is installed, but one or more readiness prerequisites still need attention."; - private const string ReadinessReadySummaryFormat = "{0} is ready for pre-session operator checks."; - private const string HealthBlockedMissingSummaryFormat = "{0} launch is blocked until the CLI is installed."; - private const string HealthBlockedLaunchSummaryFormat = "{0} launch is blocked until dotPilot can start the CLI successfully."; - private const string HealthBlockedAuthSummaryFormat = "{0} launch is blocked until authentication is configured."; - private const string HealthWarningSummaryFormat = "{0} is installed, but diagnostics still show warnings."; - private const string HealthReadySummaryFormat = "{0} passed the available pre-session readiness checks."; - private const string LaunchDiagnosticName = "Launch"; - private const string VersionDiagnosticName = "Version"; - private const string AuthDiagnosticName = "Authentication"; - private const string ConnectionDiagnosticName = "Connection test"; - private const string ResumeDiagnosticName = "Resume test"; - private const string LaunchPassedSummary = "The executable is installed and launchable from PATH."; - private const string LaunchFailedSummary = "The executable is not available on PATH."; - private const string LaunchUnavailableSummary = "The executable was detected, but dotPilot could not launch it automatically."; - private const string VersionFailedSummary = "The version could not be resolved automatically."; - private const string ConnectionReadySummary = "The provider is ready for a live connection test from the Toolchain Center."; - private const string ConnectionBlockedSummary = "Fix installation and authentication before running a live connection test."; - private const string ResumeReadySummary = "Resume diagnostics can run after the connection test succeeds."; - private const string ResumeBlockedSummary = "Resume diagnostics stay blocked until the connection test is ready."; - private const string BackgroundPollingSummaryFormat = "Background polling refreshes every {0} minutes to surface stale versions and broken auth state."; - private const string ProviderPollingHealthySummaryFormat = "Readiness was checked just now. The next background refresh runs in {0} minutes."; - private const string ProviderPollingWarningSummaryFormat = "Readiness needs attention. The next background refresh runs in {0} minutes."; - private static readonly System.Text.CompositeFormat InstallActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(InstallActionTitleFormat); - private static readonly System.Text.CompositeFormat ConnectActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(ConnectActionTitleFormat); - private static readonly System.Text.CompositeFormat UpdateActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(UpdateActionTitleFormat); - private static readonly System.Text.CompositeFormat TestActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(TestActionTitleFormat); - private static readonly System.Text.CompositeFormat TroubleshootActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(TroubleshootActionTitleFormat); - private static readonly System.Text.CompositeFormat DocsActionTitleCompositeFormat = System.Text.CompositeFormat.Parse(DocsActionTitleFormat); - private static readonly System.Text.CompositeFormat VersionSummaryCompositeFormat = System.Text.CompositeFormat.Parse(VersionSummaryFormat); - private static readonly System.Text.CompositeFormat ReadinessMissingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessMissingSummaryFormat); - private static readonly System.Text.CompositeFormat ReadinessLaunchFailedSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessLaunchFailedSummaryFormat); - private static readonly System.Text.CompositeFormat ReadinessAuthRequiredSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessAuthRequiredSummaryFormat); - private static readonly System.Text.CompositeFormat ReadinessLimitedSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessLimitedSummaryFormat); - private static readonly System.Text.CompositeFormat ReadinessReadySummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadinessReadySummaryFormat); - private static readonly System.Text.CompositeFormat HealthBlockedMissingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedMissingSummaryFormat); - private static readonly System.Text.CompositeFormat HealthBlockedLaunchSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedLaunchSummaryFormat); - private static readonly System.Text.CompositeFormat HealthBlockedAuthSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthBlockedAuthSummaryFormat); - private static readonly System.Text.CompositeFormat HealthWarningSummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthWarningSummaryFormat); - private static readonly System.Text.CompositeFormat HealthReadySummaryCompositeFormat = System.Text.CompositeFormat.Parse(HealthReadySummaryFormat); - private static readonly System.Text.CompositeFormat BackgroundPollingSummaryCompositeFormat = System.Text.CompositeFormat.Parse(BackgroundPollingSummaryFormat); - private static readonly System.Text.CompositeFormat ProviderPollingHealthySummaryCompositeFormat = System.Text.CompositeFormat.Parse(ProviderPollingHealthySummaryFormat); - private static readonly System.Text.CompositeFormat ProviderPollingWarningSummaryCompositeFormat = System.Text.CompositeFormat.Parse(ProviderPollingWarningSummaryFormat); - - public static IReadOnlyList Create(DateTimeOffset evaluatedAt) - { - return ToolchainProviderProfiles.All - .Select(profile => Create(profile, evaluatedAt)) - .ToArray(); - } - - public static ToolchainPollingDescriptor CreateBackgroundPolling(IReadOnlyList providers, DateTimeOffset evaluatedAt) - { - ArgumentNullException.ThrowIfNull(providers); - - var status = providers.Any(provider => provider.ReadinessState is not ToolchainReadinessState.Ready) - ? ToolchainPollingStatus.Warning - : ToolchainPollingStatus.Healthy; - - return new( - BackgroundRefreshInterval, - evaluatedAt, - evaluatedAt.Add(BackgroundRefreshInterval), - status, - string.Format( - System.Globalization.CultureInfo.InvariantCulture, - BackgroundPollingSummaryCompositeFormat, - BackgroundRefreshInterval.TotalMinutes)); - } - - private static ToolchainProviderSnapshot Create(ToolchainProviderProfile profile, DateTimeOffset evaluatedAt) - { - var executablePath = ToolchainCommandProbe.ResolveExecutablePath(profile.CommandName); - var isInstalled = !string.IsNullOrWhiteSpace(executablePath); - var versionProbe = isInstalled - ? ToolchainCommandProbe.ProbeVersion(executablePath!, profile.VersionArguments) - : ToolchainCommandProbe.ToolchainVersionProbeResult.Missing; - var launchAvailable = isInstalled && versionProbe.Launched; - var installedVersion = launchAvailable ? versionProbe.Version : string.Empty; - var authConfigured = profile.AuthenticationEnvironmentVariables - .Select(Environment.GetEnvironmentVariable) - .Any(static value => !string.IsNullOrWhiteSpace(value)); - var toolAccessAvailable = launchAvailable && ( - profile.ToolAccessArguments.Count == 0 || - ToolchainCommandProbe.CanExecute(executablePath!, profile.ToolAccessArguments)); - - var providerStatus = ResolveProviderStatus(isInstalled, launchAvailable, authConfigured, toolAccessAvailable); - var readinessState = ResolveReadinessState(isInstalled, launchAvailable, authConfigured, toolAccessAvailable, installedVersion); - var versionStatus = ResolveVersionStatus(isInstalled, installedVersion); - var authStatus = authConfigured ? ToolchainAuthStatus.Connected : ToolchainAuthStatus.Missing; - var healthStatus = ResolveHealthStatus(isInstalled, launchAvailable, authConfigured, toolAccessAvailable, installedVersion); - var polling = CreateProviderPolling(evaluatedAt, readinessState); - - return new( - profile.IssueNumber, - profile.SectionLabel, - new ProviderDescriptor - { - Id = ToolchainDeterministicIdentity.CreateProviderId(profile.CommandName), - DisplayName = profile.DisplayName, - CommandName = profile.CommandName, - Status = providerStatus, - StatusSummary = ResolveReadinessSummary(profile.DisplayName, isInstalled, launchAvailable, readinessState), - RequiresExternalToolchain = true, - }, - executablePath ?? MissingExecutablePath, - string.IsNullOrWhiteSpace(installedVersion) ? MissingVersion : installedVersion, - readinessState, - ResolveReadinessSummary(profile.DisplayName, isInstalled, launchAvailable, readinessState), - versionStatus, - ResolveVersionSummary(versionStatus, installedVersion), - authStatus, - authConfigured ? AuthConnectedSummary : AuthMissingSummary, - healthStatus, - ResolveHealthSummary(profile.DisplayName, healthStatus, isInstalled, launchAvailable, authConfigured), - CreateActions(profile, readinessState), - CreateDiagnostics(profile, isInstalled, launchAvailable, authConfigured, installedVersion, toolAccessAvailable), - CreateConfiguration(profile), - polling); - } - - private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool launchAvailable, bool authConfigured, bool toolAccessAvailable) - { - if (!isInstalled || !launchAvailable) - { - return ProviderConnectionStatus.Unavailable; - } - - if (!authConfigured) - { - return ProviderConnectionStatus.RequiresAuthentication; - } - - return toolAccessAvailable - ? ProviderConnectionStatus.Available - : ProviderConnectionStatus.Misconfigured; - } - - private static ToolchainReadinessState ResolveReadinessState( - bool isInstalled, - bool launchAvailable, - bool authConfigured, - bool toolAccessAvailable, - string installedVersion) - { - if (!isInstalled || !launchAvailable) - { - return ToolchainReadinessState.Missing; - } - - if (!authConfigured) - { - return ToolchainReadinessState.ActionRequired; - } - - if (!toolAccessAvailable || string.IsNullOrWhiteSpace(installedVersion)) - { - return ToolchainReadinessState.Limited; - } - - return ToolchainReadinessState.Ready; - } - - private static ToolchainVersionStatus ResolveVersionStatus(bool isInstalled, string installedVersion) - { - if (!isInstalled) - { - return ToolchainVersionStatus.Missing; - } - - return string.IsNullOrWhiteSpace(installedVersion) - ? ToolchainVersionStatus.Unknown - : ToolchainVersionStatus.Detected; - } - - private static ToolchainHealthStatus ResolveHealthStatus( - bool isInstalled, - bool launchAvailable, - bool authConfigured, - bool toolAccessAvailable, - string installedVersion) - { - if (!isInstalled || !launchAvailable || !authConfigured) - { - return ToolchainHealthStatus.Blocked; - } - - return toolAccessAvailable && !string.IsNullOrWhiteSpace(installedVersion) - ? ToolchainHealthStatus.Healthy - : ToolchainHealthStatus.Warning; - } - - private static string ResolveReadinessSummary( - string displayName, - bool isInstalled, - bool launchAvailable, - ToolchainReadinessState readinessState) => - readinessState switch - { - ToolchainReadinessState.Missing when isInstalled && !launchAvailable => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessLaunchFailedSummaryCompositeFormat, displayName), - ToolchainReadinessState.Missing => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessMissingSummaryCompositeFormat, displayName), - ToolchainReadinessState.ActionRequired => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessAuthRequiredSummaryCompositeFormat, displayName), - ToolchainReadinessState.Limited => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessLimitedSummaryCompositeFormat, displayName), - _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadinessReadySummaryCompositeFormat, displayName), - }; - - private static string ResolveVersionSummary(ToolchainVersionStatus versionStatus, string installedVersion) => - versionStatus switch - { - ToolchainVersionStatus.Missing => MissingVersionSummary, - ToolchainVersionStatus.Unknown => UnknownVersionSummary, - _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, VersionSummaryCompositeFormat, installedVersion), - }; - - private static string ResolveHealthSummary( - string displayName, - ToolchainHealthStatus healthStatus, - bool isInstalled, - bool launchAvailable, - bool authConfigured) => - healthStatus switch - { - ToolchainHealthStatus.Blocked when !isInstalled => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedMissingSummaryCompositeFormat, displayName), - ToolchainHealthStatus.Blocked when !launchAvailable => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedLaunchSummaryCompositeFormat, displayName), - ToolchainHealthStatus.Blocked when !authConfigured => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthBlockedAuthSummaryCompositeFormat, displayName), - ToolchainHealthStatus.Warning => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthWarningSummaryCompositeFormat, displayName), - _ => string.Format(System.Globalization.CultureInfo.InvariantCulture, HealthReadySummaryCompositeFormat, displayName), - }; - - private static ToolchainActionDescriptor[] CreateActions( - ToolchainProviderProfile profile, - ToolchainReadinessState readinessState) - { - var installEnabled = readinessState is ToolchainReadinessState.Missing; - var connectEnabled = readinessState is ToolchainReadinessState.ActionRequired or ToolchainReadinessState.Limited or ToolchainReadinessState.Ready; - var testEnabled = readinessState is ToolchainReadinessState.Limited or ToolchainReadinessState.Ready; - - return - [ - new( - FormatDisplayName(InstallActionTitleCompositeFormat, profile.DisplayName), - "Install the provider CLI before the first live session.", - ToolchainActionKind.Install, - IsPrimary: installEnabled, - IsEnabled: installEnabled), - new( - FormatDisplayName(ConnectActionTitleCompositeFormat, profile.DisplayName), - "Configure authentication so dotPilot can verify readiness before session start.", - ToolchainActionKind.Connect, - IsPrimary: readinessState is ToolchainReadinessState.ActionRequired, - IsEnabled: connectEnabled), - new( - FormatDisplayName(UpdateActionTitleCompositeFormat, profile.DisplayName), - "Recheck the installed version and apply provider updates outside the app when required.", - ToolchainActionKind.Update, - IsPrimary: false, - IsEnabled: connectEnabled), - new( - FormatDisplayName(TestActionTitleCompositeFormat, profile.DisplayName), - "Run the provider connection diagnostics before opening a live session.", - ToolchainActionKind.TestConnection, - IsPrimary: readinessState is ToolchainReadinessState.Ready, - IsEnabled: testEnabled), - new( - FormatDisplayName(TroubleshootActionTitleCompositeFormat, profile.DisplayName), - "Inspect prerequisites, broken auth, and blocked diagnostics without leaving the Toolchain Center.", - ToolchainActionKind.Troubleshoot, - IsPrimary: false, - IsEnabled: true), - new( - FormatDisplayName(DocsActionTitleCompositeFormat, profile.DisplayName), - "Review the provider setup guidance and operator runbook notes.", - ToolchainActionKind.OpenDocs, - IsPrimary: false, - IsEnabled: true), - ]; - } - - private static ToolchainDiagnosticDescriptor[] CreateDiagnostics( - ToolchainProviderProfile profile, - bool isInstalled, - bool launchAvailable, - bool authConfigured, - string installedVersion, - bool toolAccessAvailable) - { - var launchPassed = launchAvailable; - var versionPassed = !string.IsNullOrWhiteSpace(installedVersion); - var connectionReady = launchPassed && authConfigured; - var resumeReady = connectionReady; - - return - [ - new(LaunchDiagnosticName, launchPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Failed, launchPassed ? LaunchPassedSummary : (isInstalled ? LaunchUnavailableSummary : LaunchFailedSummary)), - new(VersionDiagnosticName, launchPassed ? (versionPassed ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, versionPassed ? ResolveVersionSummary(ToolchainVersionStatus.Detected, installedVersion) : VersionFailedSummary), - new(AuthDiagnosticName, launchPassed ? (authConfigured ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, authConfigured ? AuthConnectedSummary : AuthMissingSummary), - new(profile.ToolAccessDiagnosticName, launchPassed ? (toolAccessAvailable ? ToolchainDiagnosticStatus.Passed : ToolchainDiagnosticStatus.Warning) : ToolchainDiagnosticStatus.Blocked, toolAccessAvailable ? profile.ToolAccessReadySummary : profile.ToolAccessBlockedSummary), - new(ConnectionDiagnosticName, connectionReady ? ToolchainDiagnosticStatus.Ready : ToolchainDiagnosticStatus.Blocked, connectionReady ? ConnectionReadySummary : ConnectionBlockedSummary), - new(ResumeDiagnosticName, resumeReady ? ToolchainDiagnosticStatus.Ready : ToolchainDiagnosticStatus.Blocked, resumeReady ? ResumeReadySummary : ResumeBlockedSummary), - ]; - } - - private static ToolchainConfigurationEntry[] CreateConfiguration(ToolchainProviderProfile profile) - { - var resolvedPath = ToolchainCommandProbe.ResolveExecutablePath(profile.CommandName); - - return profile.ConfigurationSignals - .Select(signal => - { - var isConfigured = !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(signal.Name)); - var valueDisplay = signal.IsSensitive - ? (isConfigured ? "Configured" : "Missing") - : (Environment.GetEnvironmentVariable(signal.Name) ?? "Not set"); - - return new ToolchainConfigurationEntry( - signal.Name, - valueDisplay, - signal.Summary, - signal.Kind, - ResolveConfigurationStatus(signal, isConfigured), - signal.IsSensitive); - }) - .Append( - new ToolchainConfigurationEntry( - $"{profile.CommandName} path", - resolvedPath ?? MissingExecutablePath, - "Resolved executable path for the provider CLI.", - ToolchainConfigurationKind.Setting, - resolvedPath is null - ? ToolchainConfigurationStatus.Missing - : ToolchainConfigurationStatus.Configured, - IsSensitive: false)) - .ToArray(); - } - - private static ToolchainConfigurationStatus ResolveConfigurationStatus(ToolchainConfigurationSignal signal, bool isConfigured) - { - if (isConfigured) - { - return ToolchainConfigurationStatus.Configured; - } - - return signal.IsRequiredForReadiness - ? ToolchainConfigurationStatus.Missing - : ToolchainConfigurationStatus.Partial; - } - - private static ToolchainPollingDescriptor CreateProviderPolling( - DateTimeOffset evaluatedAt, - ToolchainReadinessState readinessState) - { - var status = readinessState is ToolchainReadinessState.Ready - ? ToolchainPollingStatus.Healthy - : ToolchainPollingStatus.Warning; - - return new( - BackgroundRefreshInterval, - evaluatedAt, - evaluatedAt.Add(BackgroundRefreshInterval), - status, - string.Format( - System.Globalization.CultureInfo.InvariantCulture, - readinessState is ToolchainReadinessState.Ready - ? ProviderPollingHealthySummaryCompositeFormat - : ProviderPollingWarningSummaryCompositeFormat, - BackgroundRefreshInterval.TotalMinutes)); - } - - private static string FormatDisplayName(System.Text.CompositeFormat compositeFormat, string displayName) => - string.Format(System.Globalization.CultureInfo.InvariantCulture, compositeFormat, displayName); -} diff --git a/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs b/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs deleted file mode 100644 index c0b766c..0000000 --- a/DotPilot.Runtime/Features/Workbench/GitIgnoreRuleSet.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System.IO.Enumeration; - -namespace DotPilot.Runtime.Features.Workbench; - -internal sealed class GitIgnoreRuleSet -{ - private const char CommentPrefix = '#'; - private const char NegationPrefix = '!'; - private const char DirectorySuffix = '/'; - private const char PathSeparator = '/'; - private const string GitIgnoreFileName = ".gitignore"; - - private static readonly HashSet AlwaysIgnoredNames = new(StringComparer.OrdinalIgnoreCase) - { - ".codex", - ".git", - ".vs", - "bin", - "obj", - "TestResults", - }; - - private readonly IReadOnlyList _patterns; - - private GitIgnoreRuleSet(IReadOnlyList patterns) - { - _patterns = patterns; - } - - public static GitIgnoreRuleSet Load(string workspaceRoot) - { - ArgumentException.ThrowIfNullOrWhiteSpace(workspaceRoot); - - var gitIgnorePath = Path.Combine(workspaceRoot, GitIgnoreFileName); - if (!File.Exists(gitIgnorePath)) - { - return new([]); - } - - var patterns = File.ReadLines(gitIgnorePath) - .Select(ParseLine) - .OfType() - .ToArray(); - - return new(patterns); - } - - public bool IsIgnored(string relativePath, bool isDirectory) - { - ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); - - var normalizedPath = Normalize(relativePath); - var segments = normalizedPath.Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries); - if (segments.Any(static segment => AlwaysIgnoredNames.Contains(segment))) - { - return true; - } - - foreach (var pattern in _patterns) - { - if (pattern.IsMatch(normalizedPath, segments, isDirectory)) - { - return true; - } - } - - return false; - } - - private static GitIgnorePattern? ParseLine(string rawLine) - { - var trimmed = rawLine.Trim(); - if (string.IsNullOrWhiteSpace(trimmed) || - trimmed[0] is CommentPrefix or NegationPrefix) - { - return null; - } - - var directoryOnly = trimmed.EndsWith(DirectorySuffix); - var rooted = trimmed.StartsWith(PathSeparator); - var normalizedPattern = Normalize(trimmed.TrimStart(PathSeparator).TrimEnd(DirectorySuffix)); - if (string.IsNullOrWhiteSpace(normalizedPattern)) - { - return null; - } - - return new GitIgnorePattern( - normalizedPattern, - directoryOnly, - rooted, - normalizedPattern.Contains(PathSeparator)); - } - - private static string Normalize(string path) - { - return path.Replace(Path.DirectorySeparatorChar, PathSeparator) - .Replace(Path.AltDirectorySeparatorChar, PathSeparator) - .Trim(); - } - - private sealed record GitIgnorePattern( - string Pattern, - bool DirectoryOnly, - bool Rooted, - bool HasPathSeparator) - { - public bool IsMatch(string normalizedPath, IReadOnlyList segments, bool isDirectory) - { - if (DirectoryOnly && !isDirectory) - { - return false; - } - - if (Rooted) - { - return MatchesPath(normalizedPath); - } - - if (HasPathSeparator) - { - return MatchesPath(normalizedPath) || - normalizedPath.Contains(string.Concat(PathSeparator, Pattern), StringComparison.OrdinalIgnoreCase); - } - - return segments.Any(segment => FileSystemName.MatchesSimpleExpression(Pattern, segment, ignoreCase: true)); - } - - private bool MatchesPath(string normalizedPath) - { - if (FileSystemName.MatchesSimpleExpression(Pattern, normalizedPath, ignoreCase: true)) - { - return true; - } - - return normalizedPath.Equals(Pattern, StringComparison.OrdinalIgnoreCase) || - normalizedPath.StartsWith(string.Concat(Pattern, PathSeparator), StringComparison.OrdinalIgnoreCase) || - normalizedPath.EndsWith(string.Concat(PathSeparator, Pattern), StringComparison.OrdinalIgnoreCase) || - normalizedPath.Contains(string.Concat(PathSeparator, Pattern, PathSeparator), StringComparison.OrdinalIgnoreCase); - } - } -} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs deleted file mode 100644 index cdf9f87..0000000 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchCatalog.cs +++ /dev/null @@ -1,31 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; -using DotPilot.Core.Features.Workbench; - -namespace DotPilot.Runtime.Features.Workbench; - -public sealed class WorkbenchCatalog : IWorkbenchCatalog -{ - private readonly IRuntimeFoundationCatalog _runtimeFoundationCatalog; - private readonly string? _workspaceRootOverride; - - public WorkbenchCatalog(IRuntimeFoundationCatalog runtimeFoundationCatalog) - : this(runtimeFoundationCatalog, workspaceRootOverride: null) - { - } - - public WorkbenchCatalog(IRuntimeFoundationCatalog runtimeFoundationCatalog, string? workspaceRootOverride) - { - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - _runtimeFoundationCatalog = runtimeFoundationCatalog; - _workspaceRootOverride = workspaceRootOverride; - } - - public WorkbenchSnapshot GetSnapshot() - { - var runtimeFoundationSnapshot = _runtimeFoundationCatalog.GetSnapshot(); - var workspace = WorkbenchWorkspaceResolver.Resolve(_workspaceRootOverride); - return workspace.IsAvailable - ? new WorkbenchWorkspaceSnapshotBuilder(workspace, runtimeFoundationSnapshot).Build() - : WorkbenchSeedData.Create(runtimeFoundationSnapshot); - } -} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs deleted file mode 100644 index 78c7246..0000000 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchSeedData.cs +++ /dev/null @@ -1,241 +0,0 @@ -using DotPilot.Core.Features.RuntimeFoundation; -using DotPilot.Core.Features.Workbench; - -namespace DotPilot.Runtime.Features.Workbench; - -internal static class WorkbenchSeedData -{ - private const string WorkspaceName = "Browser sandbox"; - private const string WorkspaceRoot = "Seeded browser-safe workspace"; - private const string SearchPlaceholder = "Search the workspace tree"; - private const string SessionTitle = "Issue #13 workbench slice"; - private const string SessionStage = "Review"; - private const string SessionSummary = - "Seeded workbench data keeps browser automation deterministic while the desktop host can use the real repository."; - private const string MonacoRendererLabel = "Monaco-aligned preview"; - private const string ReadOnlyStatusSummary = "Read-only workspace reference"; - private const string DiffReviewNote = "workbench review baseline"; - private const string ToolchainCategoryTitle = "Toolchain Center"; - private const string ToolchainCategorySummary = "Install, connect, diagnose, and poll Codex, Claude Code, and GitHub Copilot."; - private const string ProviderCategoryTitle = "Providers"; - private const string PolicyCategoryTitle = "Policies"; - private const string StorageCategoryTitle = "Storage"; - private const string ProviderCategorySummary = "Provider toolchains and runtime readiness"; - private const string PolicyCategorySummary = "Approval and review defaults"; - private const string StorageCategorySummary = "Workspace and artifact retention"; - private const string ReviewPath = "docs/Features/workbench-foundation.md"; - private const string PlanPath = "issue-13-workbench-foundation.plan.md"; - private const string MainPagePath = "DotPilot/Presentation/MainPage.xaml"; - private const string SettingsPath = "DotPilot/Presentation/SettingsPage.xaml"; - private const string ArchitecturePath = "docs/Architecture.md"; - private const string ArtifactsRelativePath = "artifacts/workbench-shell.png"; - private const string SessionOutputPath = "artifacts/session-output.log"; - private const string CurrentWorkspaceEntryName = "Current workspace"; - private const string ArtifactRetentionEntryName = "Artifact retention"; - private const string ApprovalModeEntryName = "Approval mode"; - private const string ReviewGateEntryName = "Diff review gate"; - private const string ReviewGateEntryValue = "Required"; - private const string ArtifactRetentionEntryValue = "14 days"; - private const string ApprovalModeEntryValue = "Operator confirmation"; - private const string TimestampOne = "09:10"; - private const string TimestampTwo = "09:12"; - private const string TimestampThree = "09:14"; - private const string TimestampFour = "09:15"; - private const string InfoLevel = "INFO"; - private const string ReviewLevel = "REVIEW"; - private const string AgentSource = "design-agent"; - private const string RuntimeSource = "runtime"; - private const string SettingsSource = "settings"; - private const string ReviewMessage = "Prepared the workbench shell review with repository navigation, diff mode, and settings coverage."; - private const string IndexMessage = "Loaded the browser-safe seeded workspace."; - private const string DiffMessage = "Queued a review diff for the primary workbench page."; - private const string SettingsMessage = "Published the unified settings shell categories."; - public static WorkbenchSnapshot Create(RuntimeFoundationSnapshot runtimeFoundationSnapshot) - { - ArgumentNullException.ThrowIfNull(runtimeFoundationSnapshot); - - var repositoryNodes = CreateRepositoryNodes(); - var documents = CreateDocuments(); - - return new( - WorkspaceName, - WorkspaceRoot, - SearchPlaceholder, - SessionTitle, - SessionStage, - SessionSummary, - CreateSessionEntries(), - repositoryNodes, - documents, - CreateArtifacts(), - CreateLogs(), - CreateSettingsCategories(runtimeFoundationSnapshot)); - } - - private static IReadOnlyList CreateRepositoryNodes() - { - return - [ - new("docs", "docs", "docs", 0, true, false), - new(ArchitecturePath, ArchitecturePath, "Architecture.md", 1, false, true), - new(ReviewPath, ReviewPath, "workbench-foundation.md", 1, false, true), - new("DotPilot", "DotPilot", "DotPilot", 0, true, false), - new("DotPilot/Presentation", "DotPilot/Presentation", "Presentation", 1, true, false), - new(MainPagePath, MainPagePath, "MainPage.xaml", 2, false, true), - new(SettingsPath, SettingsPath, "SettingsPage.xaml", 2, false, true), - new(PlanPath, PlanPath, "issue-13-workbench-foundation.plan.md", 0, false, true), - ]; - } - - private static IReadOnlyList CreateDocuments() - { - return - [ - CreateDocument( - MainPagePath, - "MainPage.xaml", - "XAML", - MonacoRendererLabel, - ReadOnlyStatusSummary, - isReadOnly: true, - """ - - - - - - """), - CreateDocument( - SettingsPath, - "SettingsPage.xaml", - "XAML", - MonacoRendererLabel, - ReadOnlyStatusSummary, - isReadOnly: true, - """ - - - - - """), - CreateDocument( - ReviewPath, - "workbench-foundation.md", - "Markdown", - MonacoRendererLabel, - ReadOnlyStatusSummary, - isReadOnly: true, - """ - # Workbench Foundation - - Epic #13 keeps the current desktop shell while replacing sample data with a repository tree, - a file surface, an artifact dock, and a unified settings shell. - """), - ]; - } - - private static WorkbenchDocumentDescriptor CreateDocument( - string relativePath, - string title, - string languageLabel, - string rendererLabel, - string statusSummary, - bool isReadOnly, - string previewContent) - { - return new( - relativePath, - title, - languageLabel, - rendererLabel, - statusSummary, - isReadOnly, - previewContent, - CreateDiffLines(title, relativePath)); - } - - private static IReadOnlyList CreateArtifacts() - { - return - [ - new("Workbench feature doc", "Documentation", "Ready", ReviewPath, "Tracks epic #13 scope, flow, and verification."), - new("Workbench implementation plan", "Plan", "Ready", PlanPath, "Records ordered implementation and validation work."), - new("Workbench shell proof", "Screenshot", "Queued", ArtifactsRelativePath, "Reserved for browser UI test screenshots."), - new("Session output", "Console", "Streaming", SessionOutputPath, "The runtime console stays attached to the current workbench."), - ]; - } - - private static IReadOnlyList CreateLogs() - { - return - [ - new(TimestampOne, InfoLevel, RuntimeSource, IndexMessage), - new(TimestampTwo, ReviewLevel, AgentSource, ReviewMessage), - new(TimestampThree, ReviewLevel, RuntimeSource, DiffMessage), - new(TimestampFour, InfoLevel, SettingsSource, SettingsMessage), - ]; - } - - private static IReadOnlyList CreateSessionEntries() - { - return - [ - new("Plan baseline", TimestampOne, "Locked the issue #13 workbench plan and preserved the green solution baseline.", WorkbenchSessionEntryKind.Operator), - new("Tree indexed", TimestampTwo, "Loaded a deterministic repository tree for browser-hosted validation.", WorkbenchSessionEntryKind.System), - new("Diff review", TimestampThree, "Prepared the MainPage review surface with a Monaco-aligned preview and diff mode.", WorkbenchSessionEntryKind.Agent), - new("Settings shell", TimestampFour, "Published providers, policies, and storage categories as a first-class route.", WorkbenchSessionEntryKind.System), - ]; - } - - private static IReadOnlyList CreateSettingsCategories(RuntimeFoundationSnapshot runtimeFoundationSnapshot) - { - return - [ - new( - WorkbenchSettingsCategoryKeys.Toolchains, - ToolchainCategoryTitle, - ToolchainCategorySummary, - []), - new( - WorkbenchSettingsCategoryKeys.Providers, - ProviderCategoryTitle, - ProviderCategorySummary, - runtimeFoundationSnapshot.Providers - .Select(provider => new WorkbenchSettingEntry( - provider.DisplayName, - provider.Status.ToString(), - provider.StatusSummary, - IsSensitive: false, - IsActionable: provider.RequiresExternalToolchain)) - .ToArray()), - new( - WorkbenchSettingsCategoryKeys.Policies, - PolicyCategoryTitle, - PolicyCategorySummary, - [ - new(ApprovalModeEntryName, ApprovalModeEntryValue, "All file and tool changes stay operator-approved.", IsSensitive: false, IsActionable: true), - new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals must stay reviewable before acceptance.", IsSensitive: false, IsActionable: true), - ]), - new( - WorkbenchSettingsCategoryKeys.Storage, - StorageCategoryTitle, - StorageCategorySummary, - [ - new(CurrentWorkspaceEntryName, WorkspaceRoot, "Browser-hosted automation uses seeded workspace metadata.", IsSensitive: false, IsActionable: false), - new(ArtifactRetentionEntryName, ArtifactRetentionEntryValue, "Artifacts stay visible from the main workbench dock.", IsSensitive: false, IsActionable: true), - ]), - ]; - } - - private static IReadOnlyList CreateDiffLines(string title, string relativePath) - { - return - [ - new(WorkbenchDiffLineKind.Context, $"@@ {relativePath} @@"), - new(WorkbenchDiffLineKind.Removed, $"- prototype-only state for {title}"), - new(WorkbenchDiffLineKind.Added, $"+ runtime-backed workbench state for {DiffReviewNote}"), - ]; - } -} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs deleted file mode 100644 index a1c0920..0000000 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceResolver.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace DotPilot.Runtime.Features.Workbench; - -internal static class WorkbenchWorkspaceResolver -{ - private const string SolutionFileName = "DotPilot.slnx"; - private const string GitDirectoryName = ".git"; - - public static ResolvedWorkspace Resolve(string? workspaceRootOverride) - { - if (!string.IsNullOrWhiteSpace(workspaceRootOverride) && - Directory.Exists(workspaceRootOverride)) - { - return CreateResolvedWorkspace(workspaceRootOverride); - } - - if (OperatingSystem.IsBrowser()) - { - return ResolvedWorkspace.Unavailable; - } - - foreach (var candidate in GetCandidateDirectories()) - { - var resolvedRoot = FindWorkspaceRoot(candidate); - if (resolvedRoot is not null) - { - return CreateResolvedWorkspace(resolvedRoot); - } - } - - return ResolvedWorkspace.Unavailable; - } - - private static IEnumerable GetCandidateDirectories() - { - return new[] - { - Environment.CurrentDirectory, - AppContext.BaseDirectory, - } - .Where(static candidate => !string.IsNullOrWhiteSpace(candidate) && Directory.Exists(candidate)) - .Distinct(StringComparer.OrdinalIgnoreCase); - } - - private static string? FindWorkspaceRoot(string startDirectory) - { - for (var current = new DirectoryInfo(startDirectory); current is not null; current = current.Parent) - { - if (File.Exists(Path.Combine(current.FullName, SolutionFileName)) || - Directory.Exists(Path.Combine(current.FullName, GitDirectoryName))) - { - return current.FullName; - } - } - - return null; - } - - private static ResolvedWorkspace CreateResolvedWorkspace(string workspaceRoot) - { - var workspaceName = new DirectoryInfo(workspaceRoot).Name; - return new ResolvedWorkspace(workspaceRoot, workspaceName, IsAvailable: true); - } -} - -internal sealed record ResolvedWorkspace( - string Root, - string Name, - bool IsAvailable) -{ - public static ResolvedWorkspace Unavailable { get; } = new(string.Empty, string.Empty, IsAvailable: false); -} diff --git a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs b/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs deleted file mode 100644 index 1beb6e2..0000000 --- a/DotPilot.Runtime/Features/Workbench/WorkbenchWorkspaceSnapshotBuilder.cs +++ /dev/null @@ -1,352 +0,0 @@ -using System.Collections.Frozen; -using DotPilot.Core.Features.RuntimeFoundation; -using DotPilot.Core.Features.Workbench; - -namespace DotPilot.Runtime.Features.Workbench; - -internal sealed class WorkbenchWorkspaceSnapshotBuilder -{ - private const int MaxDocumentCount = 12; - private const int MaxNodeCount = 96; - private const int MaxPreviewLines = 18; - private const int MaxTraversalDepth = 4; - private const string SearchPlaceholder = "Search the workspace tree"; - private const string SessionStage = "Execute"; - private const string MonacoRendererLabel = "Monaco-aligned preview"; - private const string StructuredRendererLabel = "Structured preview"; - private const string ReadOnlyStatusSummary = "Read-only workspace reference"; - private const string DiffReviewNote = "issue #13 runtime-backed review"; - private const string ToolchainCategoryTitle = "Toolchain Center"; - private const string ToolchainCategorySummary = "Install, connect, diagnose, and poll Codex, Claude Code, and GitHub Copilot."; - private const string ProvidersCategoryTitle = "Providers"; - private const string PoliciesCategoryTitle = "Policies"; - private const string StorageCategoryTitle = "Storage"; - private const string ProvidersCategorySummary = "Provider readiness stays visible from the unified settings shell."; - private const string PoliciesCategorySummary = "Review and approval defaults for operator sessions."; - private const string StorageCategorySummary = "Workspace root and artifact handling."; - private const string ApprovalModeEntryName = "Approval mode"; - private const string ApprovalModeEntryValue = "Operator confirmation"; - private const string ReviewGateEntryName = "Diff review gate"; - private const string ReviewGateEntryValue = "Required"; - private const string WorkspaceRootEntryName = "Workspace root"; - private const string ArtifactRetentionEntryName = "Artifact retention"; - private const string ArtifactRetentionEntryValue = "14 days"; - private const string WorkbenchDocPath = "docs/Features/workbench-foundation.md"; - private const string ArchitecturePath = "docs/Architecture.md"; - private const string PlanPath = "issue-13-workbench-foundation.plan.md"; - private const string ConsolePath = "artifacts/session-output.log"; - private const string ScreenshotPath = "artifacts/workbench-shell.png"; - private const string TimestampOne = "09:10"; - private const string TimestampTwo = "09:13"; - private const string TimestampThree = "09:15"; - private const string TimestampFour = "09:17"; - private const string InfoLevel = "INFO"; - private const string ReviewLevel = "REVIEW"; - private const string RuntimeSource = "runtime"; - private const string AgentSource = "agent"; - private const string SettingsSource = "settings"; - private const string SettingsMessage = "Published unified settings categories for providers, policies, and storage."; - private const string SessionEntryPlanTitle = "Plan baseline"; - private const string SessionEntryIndexTitle = "Workspace indexed"; - private const string SessionEntryReviewTitle = "Review ready"; - private const string SessionEntrySettingsTitle = "Settings published"; - private const string SessionEntryPlanSummary = "Preserved the issue #13 workbench plan before implementation."; - private const string SessionEntrySettingsSummary = "Surfaced providers, policies, and storage as first-class settings categories."; - - private static readonly FrozenSet SupportedDocumentExtensions = new[] - { - ".cs", - ".csproj", - ".json", - ".md", - ".props", - ".slnx", - ".targets", - ".xaml", - ".xml", - ".yml", - ".yaml", - }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - - private static readonly FrozenSet MonacoPreviewExtensions = new[] - { - ".cs", - ".json", - ".md", - ".xaml", - ".xml", - ".yml", - ".yaml", - }.ToFrozenSet(StringComparer.OrdinalIgnoreCase); - - private readonly ResolvedWorkspace _workspace; - private readonly RuntimeFoundationSnapshot _runtimeFoundationSnapshot; - private readonly GitIgnoreRuleSet _ignoreRules; - - public WorkbenchWorkspaceSnapshotBuilder( - ResolvedWorkspace workspace, - RuntimeFoundationSnapshot runtimeFoundationSnapshot) - { - ArgumentNullException.ThrowIfNull(workspace); - ArgumentNullException.ThrowIfNull(runtimeFoundationSnapshot); - - _workspace = workspace; - _runtimeFoundationSnapshot = runtimeFoundationSnapshot; - _ignoreRules = GitIgnoreRuleSet.Load(workspace.Root); - } - - public WorkbenchSnapshot Build() - { - var repositoryNodes = BuildRepositoryNodes(); - var documents = BuildDocuments(repositoryNodes); - if (repositoryNodes.Count == 0 || documents.Count == 0) - { - return WorkbenchSeedData.Create(_runtimeFoundationSnapshot); - } - - return new( - _workspace.Name, - _workspace.Root, - SearchPlaceholder, - $"{_workspace.Name} operator workbench", - SessionStage, - $"Indexed {repositoryNodes.Count} workspace nodes and prepared {documents.Count} reviewable documents.", - CreateSessionEntries(documents[0].Title), - repositoryNodes, - documents, - CreateArtifacts(documents), - CreateLogs(documents.Count, documents[0].Title), - CreateSettingsCategories()); - } - - private List BuildRepositoryNodes() - { - List nodes = []; - TraverseDirectory(_workspace.Root, relativePath: string.Empty, depth: 0, nodes); - return nodes; - } - - private void TraverseDirectory(string absoluteDirectory, string relativePath, int depth, List nodes) - { - if (depth > MaxTraversalDepth || nodes.Count >= MaxNodeCount) - { - return; - } - - foreach (var directoryPath in EnumerateEntries(absoluteDirectory, searchDirectories: true)) - { - if (nodes.Count >= MaxNodeCount) - { - return; - } - - var directoryName = Path.GetFileName(directoryPath); - var directoryRelativePath = CombineRelative(relativePath, directoryName); - if (_ignoreRules.IsIgnored(directoryRelativePath, isDirectory: true)) - { - continue; - } - - nodes.Add(new(directoryRelativePath, directoryRelativePath, directoryName, depth, IsDirectory: true, CanOpen: false)); - TraverseDirectory(directoryPath, directoryRelativePath, depth + 1, nodes); - } - - foreach (var filePath in EnumerateEntries(absoluteDirectory, searchDirectories: false)) - { - if (nodes.Count >= MaxNodeCount) - { - return; - } - - var fileName = Path.GetFileName(filePath); - var fileRelativePath = CombineRelative(relativePath, fileName); - if (_ignoreRules.IsIgnored(fileRelativePath, isDirectory: false) || - !SupportedDocumentExtensions.Contains(Path.GetExtension(filePath))) - { - continue; - } - - nodes.Add(new(fileRelativePath, fileRelativePath, fileName, depth, IsDirectory: false, CanOpen: true)); - } - } - - private List BuildDocuments(IReadOnlyList repositoryNodes) - { - List documents = []; - foreach (var node in repositoryNodes.Where(static node => node.CanOpen).Take(MaxDocumentCount)) - { - var absolutePath = Path.Combine(_workspace.Root, node.RelativePath.Replace('/', Path.DirectorySeparatorChar)); - var previewContent = ReadPreview(absolutePath); - if (string.IsNullOrWhiteSpace(previewContent)) - { - continue; - } - - var extension = Path.GetExtension(absolutePath); - documents.Add(new( - node.RelativePath, - node.Name, - ResolveLanguageLabel(extension), - ResolveRendererLabel(extension), - ReadOnlyStatusSummary, - IsReadOnly: true, - previewContent, - CreateDiffLines(node.Name, node.RelativePath))); - } - - return documents; - } - - private IReadOnlyList CreateArtifacts(List documents) - { - var primaryDocument = documents[0]; - return - [ - new("Workbench feature doc", "Documentation", File.Exists(Path.Combine(_workspace.Root, WorkbenchDocPath)) ? "Ready" : "Pending", WorkbenchDocPath, "Tracks epic #13 scope and workbench flow."), - new("Architecture overview", "Documentation", File.Exists(Path.Combine(_workspace.Root, ArchitecturePath)) ? "Ready" : "Pending", ArchitecturePath, "Routes agents through the active solution boundaries."), - new("Issue #13 plan", "Plan", File.Exists(Path.Combine(_workspace.Root, PlanPath)) ? "Ready" : "Pending", PlanPath, "Captures ordered implementation and validation work."), - new(primaryDocument.Title, "Review target", "Open", primaryDocument.RelativePath, "The current file surface mirrors the selected workspace document."), - new("Session output", "Console", "Streaming", ConsolePath, "The runtime log console stays bound to the current workbench session."), - new("Workbench shell proof", "Screenshot", "Queued", ScreenshotPath, "Reserved for browser UI test screenshot attachments."), - ]; - } - - private IReadOnlyList CreateLogs(int documentCount, string primaryDocumentTitle) - { - return - [ - new(TimestampOne, InfoLevel, RuntimeSource, $"Indexed the workspace rooted at {_workspace.Root}."), - new(TimestampTwo, InfoLevel, RuntimeSource, $"Prepared Monaco-aligned previews for {documentCount} documents."), - new(TimestampThree, ReviewLevel, AgentSource, $"Queued an explicit diff review for {primaryDocumentTitle}."), - new(TimestampFour, InfoLevel, SettingsSource, SettingsMessage), - ]; - } - - private IReadOnlyList CreateSessionEntries(string primaryDocumentTitle) - { - return - [ - new(SessionEntryPlanTitle, TimestampOne, SessionEntryPlanSummary, WorkbenchSessionEntryKind.Operator), - new(SessionEntryIndexTitle, TimestampTwo, $"Indexed the live workspace rooted at {_workspace.Root}.", WorkbenchSessionEntryKind.System), - new(SessionEntryReviewTitle, TimestampThree, $"Prepared a diff review and preview surface for {primaryDocumentTitle}.", WorkbenchSessionEntryKind.Agent), - new(SessionEntrySettingsTitle, TimestampFour, SessionEntrySettingsSummary, WorkbenchSessionEntryKind.System), - ]; - } - - private IReadOnlyList CreateSettingsCategories() - { - return - [ - new( - WorkbenchSettingsCategoryKeys.Toolchains, - ToolchainCategoryTitle, - ToolchainCategorySummary, - []), - new( - WorkbenchSettingsCategoryKeys.Providers, - ProvidersCategoryTitle, - ProvidersCategorySummary, - _runtimeFoundationSnapshot.Providers - .Select(provider => new WorkbenchSettingEntry( - provider.DisplayName, - provider.Status.ToString(), - provider.StatusSummary, - IsSensitive: false, - IsActionable: provider.RequiresExternalToolchain)) - .ToArray()), - new( - WorkbenchSettingsCategoryKeys.Policies, - PoliciesCategoryTitle, - PoliciesCategorySummary, - [ - new(ApprovalModeEntryName, ApprovalModeEntryValue, "All file and tool changes stay operator-approved.", IsSensitive: false, IsActionable: true), - new(ReviewGateEntryName, ReviewGateEntryValue, "Agent proposals remain reviewable before acceptance.", IsSensitive: false, IsActionable: true), - ]), - new( - WorkbenchSettingsCategoryKeys.Storage, - StorageCategoryTitle, - StorageCategorySummary, - [ - new(WorkspaceRootEntryName, _workspace.Root, "The workbench binds to the live workspace when available.", IsSensitive: false, IsActionable: false), - new(ArtifactRetentionEntryName, ArtifactRetentionEntryValue, "Artifacts remain visible from the dock and console.", IsSensitive: false, IsActionable: true), - ]), - ]; - } - - private static string[] EnumerateEntries(string absoluteDirectory, bool searchDirectories) - { - try - { - var entries = searchDirectories - ? Directory.EnumerateDirectories(absoluteDirectory) - : Directory.EnumerateFiles(absoluteDirectory); - - return entries.OrderBy(static entry => entry, StringComparer.OrdinalIgnoreCase).ToArray(); - } - catch (IOException) - { - return []; - } - catch (UnauthorizedAccessException) - { - return []; - } - } - - private static string ReadPreview(string absolutePath) - { - try - { - return string.Join( - Environment.NewLine, - File.ReadLines(absolutePath) - .Take(MaxPreviewLines)); - } - catch (IOException) - { - return string.Empty; - } - catch (UnauthorizedAccessException) - { - return string.Empty; - } - } - - private static IReadOnlyList CreateDiffLines(string title, string relativePath) - { - return - [ - new(WorkbenchDiffLineKind.Context, $"@@ {relativePath} @@"), - new(WorkbenchDiffLineKind.Removed, $"- prototype-only state for {title}"), - new(WorkbenchDiffLineKind.Added, $"+ runtime-backed workbench state for {DiffReviewNote}"), - ]; - } - - private static string ResolveLanguageLabel(string extension) - { - return extension.ToLowerInvariant() switch - { - ".cs" => "C#", - ".csproj" => "MSBuild", - ".json" => "JSON", - ".md" => "Markdown", - ".props" or ".targets" or ".xml" => "XML", - ".slnx" => "Solution", - ".xaml" => "XAML", - ".yml" or ".yaml" => "YAML", - _ => "Text", - }; - } - - private static string ResolveRendererLabel(string extension) - { - return MonacoPreviewExtensions.Contains(extension) - ? MonacoRendererLabel - : StructuredRendererLabel; - } - - private static string CombineRelative(string relativePath, string name) - { - return string.IsNullOrEmpty(relativePath) ? name : string.Concat(relativePath, "/", name); - } -} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentSessionServiceTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentSessionServiceTests.cs new file mode 100644 index 0000000..31c59b7 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentSessionServiceTests.cs @@ -0,0 +1,146 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class AgentSessionServiceTests +{ + [Test] + public async Task GetWorkspaceAsyncReturnsProviderCatalogAndNoSessionsForNewStore() + { + await using var fixture = CreateFixture(); + + var workspace = await fixture.Service.GetWorkspaceAsync(CancellationToken.None); + + workspace.Sessions.Should().BeEmpty(); + workspace.Agents.Should().BeEmpty(); + workspace.Providers.Should().HaveCount(4); + workspace.Providers.Should().ContainSingle(provider => provider.Kind == AgentProviderKind.Debug); + workspace.Providers.Should().OnlyContain(provider => !provider.IsEnabled); + } + + [Test] + public async Task CreateAgentAsyncPersistsAnEnabledDebugProviderProfile() + { + await using var fixture = CreateFixture(); + await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + + var created = await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Debug Agent", + AgentRoleKind.Coding, + AgentProviderKind.Debug, + "debug-echo", + "Act as a deterministic local test agent.", + ["Shell", "Files"]), + CancellationToken.None); + + var workspace = await fixture.Service.GetWorkspaceAsync(CancellationToken.None); + + created.Name.Should().Be("Debug Agent"); + created.ProviderKind.Should().Be(AgentProviderKind.Debug); + workspace.Agents.Should().ContainSingle(agent => agent.Id == created.Id); + workspace.Providers.Should().ContainSingle(provider => + provider.Kind == AgentProviderKind.Debug && + provider.IsEnabled && + provider.CanCreateAgents); + } + + [Test] + public async Task CreateSessionAsyncCreatesInitialTranscriptState() + { + await using var fixture = CreateFixture(); + var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Session Agent"); + + var session = await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Session with Session Agent", agent.Id), + CancellationToken.None); + + session.Session.Title.Should().Be("Session with Session Agent"); + session.Entries.Should().ContainSingle(entry => + entry.Kind == SessionStreamEntryKind.Status && + entry.Text.Contains("Session created", StringComparison.Ordinal)); + } + + [Test] + public async Task SendMessageAsyncStreamsDebugEntriesAndPersistsTranscript() + { + await using var fixture = CreateFixture(); + var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Streaming Agent"); + var session = await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Streaming session", agent.Id), + CancellationToken.None); + + List streamedEntries = []; + await foreach (var entry in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello from tests"), + CancellationToken.None)) + { + streamedEntries.Add(entry); + } + + var reloaded = await fixture.Service.GetSessionAsync(session.Session.Id, CancellationToken.None); + + streamedEntries.Should().Contain(entry => entry.Kind == SessionStreamEntryKind.UserMessage); + streamedEntries.Should().Contain(entry => entry.Kind == SessionStreamEntryKind.ToolStarted); + streamedEntries.Should().Contain(entry => entry.Kind == SessionStreamEntryKind.ToolCompleted); + streamedEntries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.AssistantMessage && + entry.Text.Contains("Debug provider received: hello from tests", StringComparison.Ordinal)); + + reloaded.Should().NotBeNull(); + reloaded!.Entries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.AssistantMessage && + entry.Text.Contains("Debug provider received: hello from tests", StringComparison.Ordinal)); + reloaded.Entries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.ToolCompleted && + entry.Text.Contains("Debug workflow finished", StringComparison.Ordinal)); + } + + private static async Task EnableDebugAndCreateAgentAsync( + IAgentSessionService service, + string name) + { + await service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + + return await service.CreateAgentAsync( + new CreateAgentProfileCommand( + name, + AgentRoleKind.Operator, + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification.", + ["Shell"]), + CancellationToken.None); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + return new TestFixture(provider, service); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentSessionService service) : IAsyncDisposable + { + public IAgentSessionService Service { get; } = service; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs b/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs index 9e81cb0..0d148b7 100644 --- a/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs +++ b/DotPilot.Tests/Features/ControlPlaneDomain/ControlPlaneDomainContractsTests.cs @@ -172,8 +172,8 @@ private static ControlPlaneDomainEnvelope CreateEnvelope() Id = TelemetryRecordId.New(), SessionId = session.Id, Kind = TelemetrySignalKind.Trace, - Name = "RuntimeFoundation.Execute", - Summary = "Deterministic provider-independent trace", + Name = "AgentSession.Execute", + Summary = "Deterministic local session trace", RecordedAt = UpdatedAt, }; diff --git a/DotPilot.Tests/Features/RuntimeCommunication/DeterministicAgentRuntimeClientContractTests.cs b/DotPilot.Tests/Features/RuntimeCommunication/DeterministicAgentRuntimeClientContractTests.cs deleted file mode 100644 index 3bfcfb3..0000000 --- a/DotPilot.Tests/Features/RuntimeCommunication/DeterministicAgentRuntimeClientContractTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace DotPilot.Tests.Features.RuntimeCommunication; - -public sealed class DeterministicAgentRuntimeClientContractTests -{ - private const string ApprovalPrompt = "Execute the local-first flow and request approval before changing files."; - - [Test] - public async Task ExecuteAsyncReturnsSucceededResultWithoutProblemForPlanMode() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest("Plan the contract foundation rollout.", AgentExecutionMode.Plan), CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.IsFailed.Should().BeFalse(); - result.HasProblem.Should().BeFalse(); - result.Value.Should().NotBeNull(); - result.Value!.NextPhase.Should().Be(SessionPhase.Plan); - } - - [Test] - public async Task ExecuteAsyncTreatsApprovalPauseAsASuccessfulStateTransition() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute), CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.HasProblem.Should().BeFalse(); - result.Value.Should().NotBeNull(); - result.Value!.NextPhase.Should().Be(SessionPhase.Paused); - result.Value.ApprovalState.Should().Be(ApprovalState.Pending); - } - - [TestCase(ProviderConnectionStatus.Unavailable, RuntimeCommunicationProblemCode.ProviderUnavailable, 503)] - [TestCase(ProviderConnectionStatus.RequiresAuthentication, RuntimeCommunicationProblemCode.ProviderAuthenticationRequired, 401)] - [TestCase(ProviderConnectionStatus.Misconfigured, RuntimeCommunicationProblemCode.ProviderMisconfigured, 424)] - [TestCase(ProviderConnectionStatus.Outdated, RuntimeCommunicationProblemCode.ProviderOutdated, 412)] - public async Task ExecuteAsyncMapsProviderStatesToTypedProblems( - ProviderConnectionStatus providerStatus, - RuntimeCommunicationProblemCode expectedCode, - int expectedStatusCode) - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync( - CreateRequest("Run the provider-independent runtime flow.", AgentExecutionMode.Execute, providerStatus), - CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.HasProblem.Should().BeTrue(); - result.Value.Should().BeNull(); - result.Problem.Should().NotBeNull(); - result.Problem!.HasErrorCode(expectedCode).Should().BeTrue(); - result.Problem.StatusCode.Should().Be(expectedStatusCode); - } - - [Test] - public async Task ExecuteAsyncReturnsOrchestrationProblemForUnsupportedExecutionModes() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync( - CreateRequest("Use an invalid execution mode.", (AgentExecutionMode)999), - CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.HasProblem.Should().BeTrue(); - result.Value.Should().BeNull(); - result.Problem.Should().NotBeNull(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.OrchestrationUnavailable).Should().BeTrue(); - result.Problem.StatusCode.Should().Be(503); - } - - private static AgentTurnRequest CreateRequest( - string prompt, - AgentExecutionMode mode, - ProviderConnectionStatus providerStatus = ProviderConnectionStatus.Available) - { - return new AgentTurnRequest(SessionId.New(), AgentProfileId.New(), prompt, mode, providerStatus); - } -} diff --git a/DotPilot.Tests/Features/RuntimeCommunication/RuntimeCommunicationProblemsTests.cs b/DotPilot.Tests/Features/RuntimeCommunication/RuntimeCommunicationProblemsTests.cs deleted file mode 100644 index 547b1e9..0000000 --- a/DotPilot.Tests/Features/RuntimeCommunication/RuntimeCommunicationProblemsTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -namespace DotPilot.Tests.Features.RuntimeCommunication; - -public class RuntimeCommunicationProblemsTests -{ - [TestCase(ProviderConnectionStatus.Unavailable, RuntimeCommunicationProblemCode.ProviderUnavailable, System.Net.HttpStatusCode.ServiceUnavailable)] - [TestCase(ProviderConnectionStatus.RequiresAuthentication, RuntimeCommunicationProblemCode.ProviderAuthenticationRequired, System.Net.HttpStatusCode.Unauthorized)] - [TestCase(ProviderConnectionStatus.Misconfigured, RuntimeCommunicationProblemCode.ProviderMisconfigured, System.Net.HttpStatusCode.FailedDependency)] - [TestCase(ProviderConnectionStatus.Outdated, RuntimeCommunicationProblemCode.ProviderOutdated, System.Net.HttpStatusCode.PreconditionFailed)] - public void ProviderUnavailableMapsStatusesToExplicitProblemCodes( - ProviderConnectionStatus status, - RuntimeCommunicationProblemCode expectedCode, - System.Net.HttpStatusCode expectedStatusCode) - { - var problem = RuntimeCommunicationProblems.ProviderUnavailable(status, "Codex"); - - problem.HasErrorCode(expectedCode).Should().BeTrue(); - problem.StatusCode.Should().Be((int)expectedStatusCode); - problem.Detail.Should().Contain("Codex"); - } - - [Test] - public void ProviderUnavailableRejectsAvailableStatus() - { - var action = () => RuntimeCommunicationProblems.ProviderUnavailable(ProviderConnectionStatus.Available, "Codex"); - - action.Should().Throw(); - } - - [Test] - public void ProviderUnavailableRejectsBlankProviderNames() - { - var action = () => RuntimeCommunicationProblems.ProviderUnavailable(ProviderConnectionStatus.Unavailable, " "); - - action.Should().Throw(); - } - - [Test] - public void InvalidPromptCreatesValidationProblem() - { - var problem = RuntimeCommunicationProblems.InvalidPrompt(); - - problem.HasErrorCode(RuntimeCommunicationProblemCode.PromptRequired).Should().BeTrue(); - problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.BadRequest); - problem.InvalidField("Prompt").Should().BeTrue(); - } - - [Test] - public void RuntimeHostUnavailableCreatesServiceUnavailableProblem() - { - var problem = RuntimeCommunicationProblems.RuntimeHostUnavailable(); - - problem.HasErrorCode(RuntimeCommunicationProblemCode.RuntimeHostUnavailable).Should().BeTrue(); - problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.ServiceUnavailable); - } - - [Test] - public void OrchestrationUnavailableCreatesServiceUnavailableProblem() - { - var problem = RuntimeCommunicationProblems.OrchestrationUnavailable(); - - problem.HasErrorCode(RuntimeCommunicationProblemCode.OrchestrationUnavailable).Should().BeTrue(); - problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.ServiceUnavailable); - } - - [Test] - public void PolicyRejectedCreatesForbiddenProblem() - { - var problem = RuntimeCommunicationProblems.PolicyRejected("file-write policy"); - - problem.HasErrorCode(RuntimeCommunicationProblemCode.PolicyRejected).Should().BeTrue(); - problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.Forbidden); - problem.Detail.Should().Contain("file-write policy"); - } - - [Test] - public void PolicyRejectedRejectsBlankPolicyNames() - { - var action = () => RuntimeCommunicationProblems.PolicyRejected(" "); - - action.Should().Throw(); - } -} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs deleted file mode 100644 index 8436f49..0000000 --- a/DotPilot.Tests/Features/RuntimeFoundation/AgentFrameworkRuntimeClientTests.cs +++ /dev/null @@ -1,371 +0,0 @@ -using System.Reflection; -using Microsoft.Extensions.DependencyInjection; - -namespace DotPilot.Tests.Features.RuntimeFoundation; - -public sealed class AgentFrameworkRuntimeClientTests -{ - private const string ApprovalPrompt = "Execute the runtime flow and stop for approval before any file change."; - private const string PlanPrompt = "Plan the embedded runtime rollout."; - private const string ApprovedResumeSummary = "Approved by the operator."; - private const string RejectedResumeSummary = "Rejected by the operator."; - private const string ResumeRejectedKind = "approval-rejected"; - private const string ArchiveFileName = "archive.json"; - private const string ReplayFileName = "replay.md"; - private static readonly DateTimeOffset FixedTimestamp = new(2026, 3, 14, 9, 30, 0, TimeSpan.Zero); - - [Test] - public async Task ExecuteAsyncPersistsAReplayArchiveForPlanMode() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - var request = CreateRequest(PlanPrompt, AgentExecutionMode.Plan); - - var result = await client.ExecuteAsync(request, CancellationToken.None); - var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); - - result.IsSuccess.Should().BeTrue(); - result.Value!.NextPhase.Should().Be(SessionPhase.Plan); - archiveResult.IsSuccess.Should().BeTrue(); - archiveResult.Value!.Phase.Should().Be(SessionPhase.Plan); - archiveResult.Value.Replay.Should().ContainSingle(entry => entry.Kind == "run-started"); - File.Exists(Path.Combine(runtimeDirectory.Root, request.SessionId.ToString(), ArchiveFileName)).Should().BeTrue(); - File.Exists(Path.Combine(runtimeDirectory.Root, request.SessionId.ToString(), ReplayFileName)).Should().BeTrue(); - } - - [Test] - public async Task ExecuteAsyncPausesForApprovalAndResumeAsyncCompletesAfterHostRestart() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); - - { - using var firstHost = CreateHost(runtimeDirectory.Root); - - await firstHost.StartAsync(); - var firstClient = firstHost.Services.GetRequiredService(); - - var pausedResult = await firstClient.ExecuteAsync(request, CancellationToken.None); - - pausedResult.IsSuccess.Should().BeTrue(); - pausedResult.Value!.NextPhase.Should().Be(SessionPhase.Paused); - pausedResult.Value.ApprovalState.Should().Be(ApprovalState.Pending); - } - - { - using var secondHost = CreateHost(runtimeDirectory.Root); - - await secondHost.StartAsync(); - var secondClient = secondHost.Services.GetRequiredService(); - - var archiveBeforeResume = await secondClient.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); - var resumedResult = await secondClient.ResumeAsync( - new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), - CancellationToken.None); - var archiveAfterResume = await secondClient.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); - var grainFactory = secondHost.Services.GetRequiredService(); - - archiveBeforeResume.IsSuccess.Should().BeTrue(); - archiveBeforeResume.Value!.CheckpointId.Should().NotBeNullOrWhiteSpace(); - resumedResult.IsSuccess.Should().BeTrue(); - resumedResult.Value!.NextPhase.Should().Be(SessionPhase.Execute); - resumedResult.Value.ApprovalState.Should().Be(ApprovalState.Approved); - archiveAfterResume.IsSuccess.Should().BeTrue(); - archiveAfterResume.Value!.Replay.Select(entry => entry.Kind).Should().Contain(["approval-pending", "run-resumed", "run-completed"]); - (await grainFactory.GetGrain(request.SessionId.ToString()).GetAsync())!.Phase.Should().Be(SessionPhase.Execute); - } - } - - [Test] - public async Task ResumeAsyncPersistsRejectedApprovalAsFailedReplay() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); - - _ = await client.ExecuteAsync(request, CancellationToken.None); - var rejectedResult = await client.ResumeAsync( - new AgentTurnResumeRequest(request.SessionId, ApprovalState.Rejected, RejectedResumeSummary), - CancellationToken.None); - var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); - - rejectedResult.IsSuccess.Should().BeTrue(); - rejectedResult.Value!.NextPhase.Should().Be(SessionPhase.Failed); - rejectedResult.Value.ApprovalState.Should().Be(ApprovalState.Rejected); - archiveResult.IsSuccess.Should().BeTrue(); - archiveResult.Value!.Phase.Should().Be(SessionPhase.Failed); - archiveResult.Value.Replay.Should().Contain(entry => entry.Kind == ResumeRejectedKind && entry.Phase == SessionPhase.Failed); - archiveResult.Value.Replay.Should().Contain(entry => entry.Kind == "run-completed" && entry.Phase == SessionPhase.Failed); - } - - [Test] - public async Task ResumeAsyncRejectsArchivedSessionsThatAreNoLongerPausedForApproval() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var request = CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute); - - { - using var firstHost = CreateHost(runtimeDirectory.Root); - await firstHost.StartAsync(); - var firstClient = firstHost.Services.GetRequiredService(); - _ = await firstClient.ExecuteAsync(request, CancellationToken.None); - _ = await firstClient.ResumeAsync( - new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), - CancellationToken.None); - } - - { - using var secondHost = CreateHost(runtimeDirectory.Root); - await secondHost.StartAsync(); - var secondClient = secondHost.Services.GetRequiredService(); - - var result = await secondClient.ResumeAsync( - new AgentTurnResumeRequest(request.SessionId, ApprovalState.Approved, ApprovedResumeSummary), - CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.ResumeCheckpointMissing).Should().BeTrue(); - result.Problem.Detail.Should().Contain("cannot be resumed"); - } - } - - [Test] - public async Task GetSessionArchiveAsyncReturnsMissingProblemWhenNothingWasPersisted() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - var missingSessionId = SessionId.New(); - - var result = await client.GetSessionArchiveAsync(missingSessionId, CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveMissing).Should().BeTrue(); - } - - [Test] - public async Task GetSessionArchiveAsyncReturnsCorruptionProblemForInvalidArchivePayload() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var sessionId = SessionId.New(); - var sessionDirectory = Path.Combine(runtimeDirectory.Root, sessionId.ToString()); - Directory.CreateDirectory(sessionDirectory); - await File.WriteAllTextAsync(Path.Combine(sessionDirectory, ArchiveFileName), "{ invalid json", CancellationToken.None); - - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - - var result = await client.GetSessionArchiveAsync(sessionId, CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveCorrupted).Should().BeTrue(); - } - - [Test] - public async Task ResumeAsyncReturnsCorruptionProblemForInvalidArchivePayload() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var sessionId = SessionId.New(); - var sessionDirectory = Path.Combine(runtimeDirectory.Root, sessionId.ToString()); - Directory.CreateDirectory(sessionDirectory); - await File.WriteAllTextAsync(Path.Combine(sessionDirectory, ArchiveFileName), "{ invalid json", CancellationToken.None); - - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = host.Services.GetRequiredService(); - - var result = await client.ResumeAsync( - new AgentTurnResumeRequest(sessionId, ApprovalState.Approved, ApprovedResumeSummary), - CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveCorrupted).Should().BeTrue(); - } - - [Test] - public async Task AgentFrameworkRuntimeClientUsesTheInjectedTimeProviderForReplayArchiveAndSessionTimestamps() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - using var host = CreateHost(runtimeDirectory.Root); - await host.StartAsync(); - var client = CreateClient(host.Services, runtimeDirectory.Root, new FixedTimeProvider(FixedTimestamp)); - var request = CreateRequest(PlanPrompt, AgentExecutionMode.Plan); - - var result = await client.ExecuteAsync(request, CancellationToken.None); - var archiveResult = await client.GetSessionArchiveAsync(request.SessionId, CancellationToken.None); - var session = await host.Services - .GetRequiredService() - .GetGrain(request.SessionId.ToString()) - .GetAsync(); - - result.IsSuccess.Should().BeTrue(); - archiveResult.IsSuccess.Should().BeTrue(); - archiveResult.Value!.UpdatedAt.Should().Be(FixedTimestamp); - archiveResult.Value.Replay.Should().OnlyContain(entry => entry.RecordedAt == FixedTimestamp); - session.Should().NotBeNull(); - session!.CreatedAt.Should().Be(FixedTimestamp); - session.UpdatedAt.Should().Be(FixedTimestamp); - } - - [Test] - public async Task ExtractCheckpointReturnsNullWhenRunHasNoCheckpointData() - { - var workflow = CreateNoCheckpointWorkflow(); - - await using var run = await Microsoft.Agents.AI.Workflows.InProcessExecution.RunAsync( - workflow, - "no-checkpoint-input", - SessionId.New().ToString(), - CancellationToken.None); - - var checkpoint = InvokePrivateStatic("ExtractCheckpoint", run); - - checkpoint.Should().BeNull(); - } - - [Test] - public void TryCreateCheckpointInfoReturnsNullWhenTheCheckpointFilePrefixDoesNotMatchTheWorkflowSession() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var file = CreateCheckpointFile(runtimeDirectory.Root, "different-session_checkpoint-001.json"); - - var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); - - checkpoint.Should().BeNull(); - } - - [Test] - public void TryCreateCheckpointInfoReturnsNullWhenTheCheckpointFileHasNoIdentifierSuffix() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var file = CreateCheckpointFile(runtimeDirectory.Root, "expected-session_.json"); - - var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); - - checkpoint.Should().BeNull(); - } - - [Test] - public void TryCreateCheckpointInfoReturnsCheckpointMetadataForMatchingCheckpointFileNames() - { - using var runtimeDirectory = new TemporaryRuntimePersistenceDirectory(); - var file = CreateCheckpointFile(runtimeDirectory.Root, "expected-session_checkpoint-001.json"); - - var checkpoint = InvokePrivateStatic("TryCreateCheckpointInfo", "expected-session", file); - - checkpoint.Should().NotBeNull(); - checkpoint!.SessionId.Should().Be("expected-session"); - checkpoint.CheckpointId.Should().Be("checkpoint-001"); - } - - private static Microsoft.Extensions.Hosting.IHost CreateHost(string rootDirectory) - { - var options = CreateHostOptions(); - return Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder() - .UseDotPilotEmbeddedRuntime(options) - .ConfigureServices((_, services) => services.AddDesktopRuntimeFoundation(new RuntimePersistenceOptions - { - RootDirectoryPath = rootDirectory, - })) - .Build(); - } - - private static EmbeddedRuntimeHostOptions CreateHostOptions() - { - return new EmbeddedRuntimeHostOptions - { - ClusterId = $"dotpilot-runtime-{Guid.NewGuid():N}", - ServiceId = $"dotpilot-runtime-service-{Guid.NewGuid():N}", - SiloPort = GetFreeTcpPort(), - GatewayPort = GetFreeTcpPort(), - }; - } - - private static AgentTurnRequest CreateRequest(string prompt, AgentExecutionMode mode) - { - return new AgentTurnRequest(SessionId.New(), AgentProfileId.New(), prompt, mode, ProviderConnectionStatus.Available); - } - - private static AgentFrameworkRuntimeClient CreateClient(IServiceProvider services, string rootDirectory, TimeProvider timeProvider) - { - return (AgentFrameworkRuntimeClient)Activator.CreateInstance( - typeof(AgentFrameworkRuntimeClient), - BindingFlags.Instance | BindingFlags.NonPublic, - binder: null, - args: - [ - services.GetRequiredService(), - new RuntimeSessionArchiveStore(new RuntimePersistenceOptions - { - RootDirectoryPath = rootDirectory, - }), - timeProvider, - ], - culture: null)!; - } - - private static Microsoft.Agents.AI.Workflows.Workflow CreateNoCheckpointWorkflow() - { - var executor = new Microsoft.Agents.AI.Workflows.FunctionExecutor( - "no-checkpoint-executor", - static async (input, context, cancellationToken) => - { - ArgumentException.ThrowIfNullOrWhiteSpace(input); - cancellationToken.ThrowIfCancellationRequested(); - await context.RequestHaltAsync(); - }, - declareCrossRunShareable: true); - return new Microsoft.Agents.AI.Workflows.WorkflowBuilder(executor).Build(); - } - - private static FileInfo CreateCheckpointFile(string rootDirectory, string fileName) - { - var filePath = Path.Combine(rootDirectory, fileName); - File.WriteAllText(filePath, "{}"); - return new FileInfo(filePath); - } - - private static T? InvokePrivateStatic(string methodName, params object[] arguments) - { - var method = typeof(AgentFrameworkRuntimeClient).GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static); - method.Should().NotBeNull(); - return (T?)method!.Invoke(null, arguments); - } - - private static int GetFreeTcpPort() - { - using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); - listener.Start(); - return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; - } -} - -internal sealed class FixedTimeProvider(DateTimeOffset timestamp) : TimeProvider -{ - public override DateTimeOffset GetUtcNow() => timestamp; -} - -internal sealed class TemporaryRuntimePersistenceDirectory : IDisposable -{ - public TemporaryRuntimePersistenceDirectory() - { - Root = Path.Combine(Path.GetTempPath(), "dotpilot-runtime-tests", Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); - Directory.CreateDirectory(Root); - } - - public string Root { get; } - - public void Dispose() - { - if (Directory.Exists(Root)) - { - Directory.Delete(Root, recursive: true); - } - } -} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs deleted file mode 100644 index 0c870ee..0000000 --- a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeHostTests.cs +++ /dev/null @@ -1,288 +0,0 @@ -using System.Net; -using System.Net.Sockets; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace DotPilot.Tests.Features.RuntimeFoundation; - -public class EmbeddedRuntimeHostTests -{ - private static readonly DateTimeOffset Timestamp = new(2026, 3, 13, 12, 0, 0, TimeSpan.Zero); - - [Test] - public void CatalogStartsInStoppedStateBeforeTheHostRuns() - { - var options = CreateOptions(); - using var host = CreateHost(options); - - var snapshot = host.Services.GetRequiredService().GetSnapshot(); - - snapshot.State.Should().Be(EmbeddedRuntimeHostState.Stopped); - snapshot.ClusteringMode.Should().Be(EmbeddedRuntimeClusteringMode.Localhost); - snapshot.GrainStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); - snapshot.ReminderStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); - snapshot.ClusterId.Should().Be(options.ClusterId); - snapshot.ServiceId.Should().Be(options.ServiceId); - snapshot.SiloPort.Should().Be(options.SiloPort); - snapshot.GatewayPort.Should().Be(options.GatewayPort); - snapshot.Grains.Select(grain => grain.Name).Should().ContainInOrder("Session", "Workspace", "Fleet", "Policy", "Artifact"); - } - - [Test] - public void CatalogUsesDefaultLocalhostOptionsWhenTheCallerDoesNotProvideOverrides() - { - var defaults = new EmbeddedRuntimeHostOptions(); - using var host = Host.CreateDefaultBuilder() - .UseDotPilotEmbeddedRuntime() - .Build(); - - var snapshot = host.Services.GetRequiredService().GetSnapshot(); - - snapshot.State.Should().Be(EmbeddedRuntimeHostState.Stopped); - snapshot.ClusteringMode.Should().Be(EmbeddedRuntimeClusteringMode.Localhost); - snapshot.GrainStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); - snapshot.ReminderStorageMode.Should().Be(EmbeddedRuntimeStorageMode.InMemory); - snapshot.ClusterId.Should().Be(defaults.ClusterId); - snapshot.ServiceId.Should().Be(defaults.ServiceId); - snapshot.SiloPort.Should().Be(defaults.SiloPort); - snapshot.GatewayPort.Should().Be(defaults.GatewayPort); - } - - [Test] - public async Task CatalogTransitionsToRunningStateAfterHostStartAsync() - { - var options = CreateOptions(); - using var host = CreateHost(options); - - await host.StartAsync(); - var snapshot = host.Services.GetRequiredService().GetSnapshot(); - - snapshot.State.Should().Be(EmbeddedRuntimeHostState.Running); - } - - [Test] - public async Task LifecycleServiceDoesNotMarkTheCatalogRunningBeforeTheSiloStartupTaskRuns() - { - var options = CreateOptions(); - using var host = CreateHost(options); - var lifecycleService = host.Services - .GetServices() - .Single(service => service.GetType().Name == "EmbeddedRuntimeHostLifecycleService"); - - await lifecycleService.StartAsync(CancellationToken.None); - - host.Services - .GetRequiredService() - .GetSnapshot() - .State - .Should() - .Be(EmbeddedRuntimeHostState.Stopped); - } - - [Test] - public async Task InitialGrainsReturnNullBeforeTheirFirstWrite() - { - var options = CreateOptions(); - await RunWithStartedHostAsync( - options, - async host => - { - var grainFactory = host.Services.GetRequiredService(); - - (await grainFactory.GetGrain(SessionId.New().ToString()).GetAsync()).Should().BeNull(); - (await grainFactory.GetGrain(WorkspaceId.New().ToString()).GetAsync()).Should().BeNull(); - (await grainFactory.GetGrain(FleetId.New().ToString()).GetAsync()).Should().BeNull(); - (await grainFactory.GetGrain(PolicyId.New().ToString()).GetAsync()).Should().BeNull(); - (await grainFactory.GetGrain(ArtifactId.New().ToString()).GetAsync()).Should().BeNull(); - }); - } - - [Test] - public async Task InitialGrainsRoundTripTheirDescriptorState() - { - var workspace = CreateWorkspace(); - var firstAgentId = AgentProfileId.New(); - var secondAgentId = AgentProfileId.New(); - var fleet = CreateFleet(firstAgentId, secondAgentId); - var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); - var policy = CreatePolicy(); - var artifact = CreateArtifact(session.Id, firstAgentId); - var options = CreateOptions(); - await RunWithStartedHostAsync( - options, - async host => - { - var grainFactory = host.Services.GetRequiredService(); - - (await grainFactory.GetGrain(session.Id.ToString()).UpsertAsync(session)).Should().BeEquivalentTo(session); - (await grainFactory.GetGrain(workspace.Id.ToString()).UpsertAsync(workspace)).Should().BeEquivalentTo(workspace); - (await grainFactory.GetGrain(fleet.Id.ToString()).UpsertAsync(fleet)).Should().BeEquivalentTo(fleet); - (await grainFactory.GetGrain(policy.Id.ToString()).UpsertAsync(policy)).Should().BeEquivalentTo(policy); - (await grainFactory.GetGrain(artifact.Id.ToString()).UpsertAsync(artifact)).Should().BeEquivalentTo(artifact); - - (await grainFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeEquivalentTo(session); - (await grainFactory.GetGrain(workspace.Id.ToString()).GetAsync()).Should().BeEquivalentTo(workspace); - (await grainFactory.GetGrain(fleet.Id.ToString()).GetAsync()).Should().BeEquivalentTo(fleet); - (await grainFactory.GetGrain(policy.Id.ToString()).GetAsync()).Should().BeEquivalentTo(policy); - (await grainFactory.GetGrain(artifact.Id.ToString()).GetAsync()).Should().BeEquivalentTo(artifact); - }); - } - - [Test] - public async Task SessionGrainRejectsDescriptorIdsThatDoNotMatchThePrimaryKey() - { - var workspace = CreateWorkspace(); - var firstAgentId = AgentProfileId.New(); - var secondAgentId = AgentProfileId.New(); - var fleet = CreateFleet(firstAgentId, secondAgentId); - var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); - var options = CreateOptions(); - await RunWithStartedHostAsync( - options, - async host => - { - var grainFactory = host.Services.GetRequiredService(); - var mismatchedGrain = grainFactory.GetGrain(SessionId.New().ToString()); - - var action = async () => await mismatchedGrain.UpsertAsync(session); - - await action.Should().ThrowAsync(); - }); - } - - [Test] - public async Task SessionStateDoesNotSurviveHostRestartWhenUsingInMemoryStorage() - { - var workspace = CreateWorkspace(); - var firstAgentId = AgentProfileId.New(); - var secondAgentId = AgentProfileId.New(); - var fleet = CreateFleet(firstAgentId, secondAgentId); - var session = CreateSession(workspace.Id, fleet.Id, firstAgentId, secondAgentId); - - await RunWithStartedHostAsync( - CreateOptions(), - async firstHost => - { - var firstFactory = firstHost.Services.GetRequiredService(); - await firstFactory.GetGrain(session.Id.ToString()).UpsertAsync(session); - (await firstFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeEquivalentTo(session); - }); - - await RunWithStartedHostAsync( - CreateOptions(), - async secondHost => - { - var secondFactory = secondHost.Services.GetRequiredService(); - (await secondFactory.GetGrain(session.Id.ToString()).GetAsync()).Should().BeNull(); - }); - } - - private static IHost CreateHost(EmbeddedRuntimeHostOptions options) - { - return Host.CreateDefaultBuilder() - .UseDotPilotEmbeddedRuntime(options) - .Build(); - } - - private static async Task RunWithStartedHostAsync(EmbeddedRuntimeHostOptions options, Func assertion) - { - using var host = CreateHost(options); - await host.StartAsync(); - - try - { - await assertion(host); - } - finally - { - await host.StopAsync(); - } - } - - private static EmbeddedRuntimeHostOptions CreateOptions() - { - return new EmbeddedRuntimeHostOptions - { - ClusterId = $"dotpilot-local-{Guid.NewGuid():N}", - ServiceId = $"dotpilot-service-{Guid.NewGuid():N}", - SiloPort = GetFreeTcpPort(), - GatewayPort = GetFreeTcpPort(), - }; - } - - private static WorkspaceDescriptor CreateWorkspace() - { - return new WorkspaceDescriptor - { - Id = WorkspaceId.New(), - Name = "dotPilot", - RootPath = "/repo/dotPilot", - BranchName = "codex/issue-24-embedded-orleans-host", - }; - } - - private static FleetDescriptor CreateFleet(AgentProfileId firstAgentId, AgentProfileId secondAgentId) - { - return new FleetDescriptor - { - Id = FleetId.New(), - Name = "Local Runtime Fleet", - ExecutionMode = FleetExecutionMode.Orchestrated, - AgentProfileIds = [firstAgentId, secondAgentId], - }; - } - - private static SessionDescriptor CreateSession( - WorkspaceId workspaceId, - FleetId fleetId, - AgentProfileId firstAgentId, - AgentProfileId secondAgentId) - { - return new SessionDescriptor - { - Id = SessionId.New(), - WorkspaceId = workspaceId, - Title = "Embedded Orleans runtime host test", - Phase = SessionPhase.Execute, - ApprovalState = ApprovalState.Pending, - FleetId = fleetId, - AgentProfileIds = [firstAgentId, secondAgentId], - CreatedAt = Timestamp, - UpdatedAt = Timestamp, - }; - } - - private static PolicyDescriptor CreatePolicy() - { - return new PolicyDescriptor - { - Id = PolicyId.New(), - Name = "Desktop Local Policy", - DefaultApprovalState = ApprovalState.Pending, - AllowsNetworkAccess = false, - AllowsFileSystemWrites = true, - ProtectedScopes = [ApprovalScope.CommandExecution, ApprovalScope.FileWrite], - }; - } - - private static ArtifactDescriptor CreateArtifact(SessionId sessionId, AgentProfileId agentProfileId) - { - return new ArtifactDescriptor - { - Id = ArtifactId.New(), - SessionId = sessionId, - AgentProfileId = agentProfileId, - Name = "runtime-foundation.snapshot.json", - Kind = ArtifactKind.Snapshot, - RelativePath = "artifacts/runtime-foundation.snapshot.json", - CreatedAt = Timestamp, - }; - } - - private static int GetFreeTcpPort() - { - using var listener = new TcpListener(IPAddress.Loopback, 0); - listener.Start(); - return ((IPEndPoint)listener.LocalEndpoint).Port; - } -} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs deleted file mode 100644 index 45b6f45..0000000 --- a/DotPilot.Tests/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalogTests.cs +++ /dev/null @@ -1,69 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace DotPilot.Tests.Features.RuntimeFoundation; - -public sealed class EmbeddedRuntimeTrafficPolicyCatalogTests -{ - [Test] - public void TrafficPolicyCatalogExposesExplicitTransitionsAndMermaidDiagram() - { - using var host = CreateHost(); - - var snapshot = host.Services.GetRequiredService().GetSnapshot(); - - snapshot.IssueNumber.Should().Be(RuntimeFoundationIssues.GrainTrafficPolicy); - snapshot.AllowedTransitions.Should().Contain(transition => - transition.Source == "Session" && - transition.Target == "Artifact" && - transition.SourceMethods.Contains(nameof(ISessionGrain.UpsertAsync)) && - transition.TargetMethods.Contains(nameof(IArtifactGrain.UpsertAsync))); - snapshot.MermaidDiagram.Should().Contain("flowchart LR"); - snapshot.MermaidDiagram.Should().Contain("Session --> Artifact"); - } - - [Test] - public void TrafficPolicyCatalogAllowsConfiguredTransitionsAndRejectsUnsupportedHops() - { - using var host = CreateHost(); - var catalog = host.Services.GetRequiredService(); - - var allowedDecision = catalog.Evaluate( - new EmbeddedRuntimeTrafficProbe( - typeof(ISessionGrain), - nameof(ISessionGrain.UpsertAsync), - typeof(IArtifactGrain), - nameof(IArtifactGrain.UpsertAsync))); - var deniedDecision = catalog.Evaluate( - new EmbeddedRuntimeTrafficProbe( - typeof(IPolicyGrain), - nameof(IPolicyGrain.UpsertAsync), - typeof(ISessionGrain), - nameof(ISessionGrain.GetAsync))); - - allowedDecision.IsAllowed.Should().BeTrue(); - allowedDecision.MermaidDiagram.Should().Contain("Session ==> Artifact"); - deniedDecision.IsAllowed.Should().BeFalse(); - deniedDecision.MermaidDiagram.Should().Contain("Policy"); - } - - private static IHost CreateHost() - { - return Host.CreateDefaultBuilder() - .UseDotPilotEmbeddedRuntime(new EmbeddedRuntimeHostOptions - { - ClusterId = $"dotpilot-traffic-{Guid.NewGuid():N}", - ServiceId = $"dotpilot-traffic-service-{Guid.NewGuid():N}", - SiloPort = GetFreeTcpPort(), - GatewayPort = GetFreeTcpPort(), - }) - .Build(); - } - - private static int GetFreeTcpPort() - { - using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); - listener.Start(); - return ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; - } -} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs deleted file mode 100644 index d2bbf4d..0000000 --- a/DotPilot.Tests/Features/RuntimeFoundation/ProviderToolchainProbeTests.cs +++ /dev/null @@ -1,63 +0,0 @@ -using System.Reflection; - -namespace DotPilot.Tests.Features.RuntimeFoundation; - -public class ProviderToolchainProbeTests -{ - private const string DotnetCommandName = "dotnet"; - - [Test] - public void ProbeReturnsAvailableWhenTheCommandExistsOnPath() - { - var descriptor = Probe("Dotnet CLI", DotnetCommandName, requiresExternalToolchain: true); - - descriptor.Status.Should().Be(ProviderConnectionStatus.Available); - descriptor.CommandName.Should().Be(DotnetCommandName); - descriptor.RequiresExternalToolchain.Should().BeTrue(); - descriptor.StatusSummary.Should().Be("Dotnet CLI is available on PATH."); - } - - [Test] - public void ProbeReturnsUnavailableWhenTheCommandDoesNotExistOnPath() - { - var missingCommandName = $"missing-{Guid.NewGuid():N}"; - - var descriptor = Probe("Missing CLI", missingCommandName, requiresExternalToolchain: true); - - descriptor.Status.Should().Be(ProviderConnectionStatus.Unavailable); - descriptor.CommandName.Should().Be(missingCommandName); - descriptor.StatusSummary.Should().Be("Missing CLI is not on PATH."); - } - - [Test] - public void ResolveExecutablePathFindsExistingExecutablesOnPath() - { - var executablePath = ResolveExecutablePath(DotnetCommandName); - - executablePath.Should().NotBeNullOrWhiteSpace(); - File.Exists(executablePath).Should().BeTrue(); - } - - private static ProviderDescriptor Probe(string displayName, string commandName, bool requiresExternalToolchain) - { - return (ProviderDescriptor)(InvokeProbeMethod("Probe", displayName, commandName, requiresExternalToolchain) - ?? throw new InvalidOperationException("ProviderToolchainProbe.Probe returned null.")); - } - - private static string? ResolveExecutablePath(string commandName) - { - return (string?)InvokeProbeMethod("ResolveExecutablePath", commandName); - } - - private static object? InvokeProbeMethod(string methodName, params object[] arguments) - { - var probeType = typeof(RuntimeFoundationCatalog).Assembly.GetType( - "DotPilot.Runtime.Features.RuntimeFoundation.ProviderToolchainProbe", - throwOnError: true)!; - var method = probeType.GetMethod( - methodName, - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; - - return method.Invoke(null, arguments); - } -} diff --git a/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs b/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs deleted file mode 100644 index 891c8ea..0000000 --- a/DotPilot.Tests/Features/RuntimeFoundation/RuntimeFoundationCatalogTests.cs +++ /dev/null @@ -1,279 +0,0 @@ -namespace DotPilot.Tests.Features.RuntimeFoundation; - -public class RuntimeFoundationCatalogTests -{ - private const string ApprovalPrompt = "Please continue, but stop for approval before changing files."; - private const string BlankPrompt = " "; - private const string DeterministicClientStatusSummary = "Always available for in-repo and CI validation."; - private const string RuntimeEpicLabel = "LOCAL RUNTIME READINESS"; - private static readonly DateTimeOffset DeterministicArtifactCreatedAt = new(2026, 3, 13, 0, 0, 0, TimeSpan.Zero); - - [Test] - public void CatalogGroupsEpicTwelveIntoSixSequencedSlices() - { - var catalog = CreateCatalog(); - - var snapshot = catalog.GetSnapshot(); - - snapshot.EpicLabel.Should().Be(RuntimeEpicLabel); - snapshot.Slices.Should().HaveCount(6); - snapshot.Slices.Select(slice => slice.IssueLabel).Should().ContainInOrder( - "DOMAIN", - "CONTRACTS", - "HOST", - "ORCHESTRATION", - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.GrainTrafficPolicy), - RuntimeFoundationIssues.FormatIssueLabel(RuntimeFoundationIssues.SessionPersistence)); - snapshot.Slices.Select(slice => slice.IssueNumber).Should().ContainInOrder( - RuntimeFoundationIssues.DomainModel, - RuntimeFoundationIssues.CommunicationContracts, - RuntimeFoundationIssues.EmbeddedOrleansHost, - RuntimeFoundationIssues.AgentFrameworkRuntime, - RuntimeFoundationIssues.GrainTrafficPolicy, - RuntimeFoundationIssues.SessionPersistence); - snapshot.Slices.Single(slice => slice.IssueNumber == RuntimeFoundationIssues.GrainTrafficPolicy) - .Summary - .Should() - .Contain("Mermaid") - .And.NotContain("Orleans.Graph"); - } - - [Test] - public void CatalogAlwaysIncludesTheDeterministicClientForProviderIndependentCoverage() - { - var catalog = CreateCatalog(); - - var snapshot = catalog.GetSnapshot(); - - snapshot.Providers.Should().ContainSingle(provider => - provider.DisplayName == snapshot.DeterministicClientName && - provider.StatusSummary == DeterministicClientStatusSummary && - provider.RequiresExternalToolchain == false && - provider.Status == ProviderConnectionStatus.Available); - } - - [Test] - public async Task DeterministicClientReturnsPendingApprovalWhenPromptRequestsApproval() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest(ApprovalPrompt, AgentExecutionMode.Execute), CancellationToken.None); - var outcome = result.Value!; - - result.IsSuccess.Should().BeTrue(); - outcome.NextPhase.Should().Be(SessionPhase.Paused); - outcome.ApprovalState.Should().Be(ApprovalState.Pending); - outcome.ProducedArtifacts.Should().ContainSingle(artifact => - artifact.Name == "runtime-foundation.snapshot.json" && - artifact.Kind == ArtifactKind.Snapshot); - } - - [Test] - public async Task DeterministicClientReturnsPlanArtifactsForPlanMode() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest("Plan the runtime foundation rollout.", AgentExecutionMode.Plan), CancellationToken.None); - var outcome = result.Value!; - - result.IsSuccess.Should().BeTrue(); - outcome.NextPhase.Should().Be(SessionPhase.Plan); - outcome.ApprovalState.Should().Be(ApprovalState.NotRequired); - outcome.ProducedArtifacts.Should().ContainSingle(artifact => - artifact.Name == "runtime-foundation.plan.md" && - artifact.Kind == ArtifactKind.Plan); - } - - [Test] - public async Task DeterministicClientProducesStableArtifactsForIdenticalRequests() - { - var client = new DeterministicAgentRuntimeClient(); - var request = CreateRequest("Run the provider-independent runtime flow.", AgentExecutionMode.Execute); - - var firstResult = await client.ExecuteAsync(request, CancellationToken.None); - var secondResult = await client.ExecuteAsync(request, CancellationToken.None); - var firstArtifact = firstResult.Value!.ProducedArtifacts.Should().ContainSingle().Subject; - var secondArtifact = secondResult.Value!.ProducedArtifacts.Should().ContainSingle().Subject; - - firstResult.IsSuccess.Should().BeTrue(); - secondResult.IsSuccess.Should().BeTrue(); - firstArtifact.Id.Should().Be(secondArtifact.Id); - firstArtifact.CreatedAt.Should().Be(DeterministicArtifactCreatedAt); - secondArtifact.CreatedAt.Should().Be(DeterministicArtifactCreatedAt); - } - - [Test] - public async Task DeterministicClientReturnsExecuteResultsWhenApprovalIsNotRequested() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest("Run the provider-independent runtime flow.", AgentExecutionMode.Execute), CancellationToken.None); - var outcome = result.Value!; - - result.IsSuccess.Should().BeTrue(); - outcome.NextPhase.Should().Be(SessionPhase.Execute); - outcome.ApprovalState.Should().Be(ApprovalState.NotRequired); - outcome.ProducedArtifacts.Should().ContainSingle(artifact => - artifact.Name == "runtime-foundation.snapshot.json" && - artifact.Kind == ArtifactKind.Snapshot); - } - - [Test] - public async Task DeterministicClientReturnsValidationProblemForBlankPrompts() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest(BlankPrompt, AgentExecutionMode.Plan), CancellationToken.None); - var problem = result.Problem!; - - result.IsFailed.Should().BeTrue(); - result.HasProblem.Should().BeTrue(); - problem.HasErrorCode(RuntimeCommunicationProblemCode.PromptRequired).Should().BeTrue(); - problem.InvalidField("Prompt").Should().BeTrue(); - } - - [Test] - public async Task DeterministicClientHonorsCancellationBeforeProcessing() - { - var client = new DeterministicAgentRuntimeClient(); - using var cancellationSource = new CancellationTokenSource(); - cancellationSource.Cancel(); - - var action = async () => await client.ExecuteAsync(CreateRequest("Plan the runtime foundation rollout.", AgentExecutionMode.Plan), cancellationSource.Token); - - await action.Should().ThrowAsync(); - } - - [Test] - public async Task DeterministicClientReturnsApprovedReviewResults() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ExecuteAsync(CreateRequest("Review the runtime foundation output.", AgentExecutionMode.Review), CancellationToken.None); - var outcome = result.Value!; - - result.IsSuccess.Should().BeTrue(); - outcome.NextPhase.Should().Be(SessionPhase.Review); - outcome.ApprovalState.Should().Be(ApprovalState.Approved); - outcome.ProducedArtifacts.Should().ContainSingle(artifact => - artifact.Name == "runtime-foundation.review.md" && - artifact.Kind == ArtifactKind.Report); - } - - [Test] - public async Task DeterministicClientReturnsProviderUnavailableProblemWhenProviderIsNotReady() - { - var client = new DeterministicAgentRuntimeClient(); - var snapshot = CreateCatalog().GetSnapshot(); - - var result = await client.ExecuteAsync( - CreateRequest( - "Run the provider-independent runtime flow.", - AgentExecutionMode.Execute, - ProviderConnectionStatus.Unavailable), - CancellationToken.None); - var problem = result.Problem!; - - result.IsFailed.Should().BeTrue(); - result.HasProblem.Should().BeTrue(); - problem.HasErrorCode(RuntimeCommunicationProblemCode.ProviderUnavailable).Should().BeTrue(); - problem.StatusCode.Should().Be((int)System.Net.HttpStatusCode.ServiceUnavailable); - problem.Detail.Should().Contain(snapshot.DeterministicClientName); - } - - [Test] - public async Task DeterministicClientReturnsOrchestrationUnavailableForResume() - { - var client = new DeterministicAgentRuntimeClient(); - - var result = await client.ResumeAsync( - new AgentTurnResumeRequest(SessionId.New(), ApprovalState.Approved, "Approved."), - CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.OrchestrationUnavailable).Should().BeTrue(); - } - - [Test] - public async Task DeterministicClientReturnsMissingArchiveProblemForArchiveQueries() - { - var client = new DeterministicAgentRuntimeClient(); - var sessionId = SessionId.New(); - - var result = await client.GetSessionArchiveAsync(sessionId, CancellationToken.None); - - result.IsFailed.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.SessionArchiveMissing).Should().BeTrue(); - } - - [Test] - public void DeterministicClientRejectsUnexpectedExecutionModes() - { - var client = new DeterministicAgentRuntimeClient(); - var invalidRequest = CreateRequest("Plan the runtime foundation rollout.", (AgentExecutionMode)int.MaxValue); - - var result = client.ExecuteAsync(invalidRequest, CancellationToken.None).AsTask().GetAwaiter().GetResult(); - - result.IsFailed.Should().BeTrue(); - result.HasProblem.Should().BeTrue(); - result.Problem!.HasErrorCode(RuntimeCommunicationProblemCode.OrchestrationUnavailable).Should().BeTrue(); - } - - [Test] - public void CatalogPreservesProviderIdentityAcrossSnapshotRefreshes() - { - var catalog = CreateCatalog(); - - var firstSnapshot = catalog.GetSnapshot(); - var secondSnapshot = catalog.GetSnapshot(); - - firstSnapshot.Providers.Should().HaveSameCount(secondSnapshot.Providers); - foreach (var firstProvider in firstSnapshot.Providers) - { - var secondProvider = secondSnapshot.Providers.Single(provider => provider.CommandName == firstProvider.CommandName); - firstProvider.Id.Should().Be(secondProvider.Id); - } - } - - [Test] - public void TypedIdentifiersProduceStableNonEmptyRepresentations() - { - IReadOnlyList values = - [ - WorkspaceId.New().ToString(), - AgentProfileId.New().ToString(), - SessionId.New().ToString(), - FleetId.New().ToString(), - ProviderId.New().ToString(), - ModelRuntimeId.New().ToString(), - ]; - - values.Should().OnlyContain(value => !string.IsNullOrWhiteSpace(value)); - values.Should().OnlyHaveUniqueItems(); - } - - [Test] - public void CatalogCachesProviderListAcrossSnapshotReads() - { - var catalog = CreateCatalog(); - - var firstSnapshot = catalog.GetSnapshot(); - var secondSnapshot = catalog.GetSnapshot(); - - ReferenceEquals(firstSnapshot.Providers, secondSnapshot.Providers).Should().BeTrue(); - firstSnapshot.Providers.Should().NotBeAssignableTo(); - } - - private static RuntimeFoundationCatalog CreateCatalog() - { - return new RuntimeFoundationCatalog(); - } - - private static AgentTurnRequest CreateRequest( - string prompt, - AgentExecutionMode mode, - ProviderConnectionStatus providerStatus = ProviderConnectionStatus.Available) - { - return new AgentTurnRequest(SessionId.New(), AgentProfileId.New(), prompt, mode, providerStatus); - } -} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs deleted file mode 100644 index 2acd56d..0000000 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCenterCatalogTests.cs +++ /dev/null @@ -1,131 +0,0 @@ -namespace DotPilot.Tests.Features.ToolchainCenter; - -public class ToolchainCenterCatalogTests -{ - private const string ToolchainEpicLabel = "PRE-SESSION READINESS"; - - [Test] - public void CatalogIncludesEpicIssueCoverageAndAllExternalProviders() - { - using var catalog = CreateCatalog(); - - var snapshot = catalog.GetSnapshot(); - var coveredIssues = snapshot.Workstreams - .Select(workstream => workstream.IssueNumber) - .Concat(snapshot.Providers.Select(provider => provider.IssueNumber)) - .Order() - .ToArray(); - - snapshot.EpicLabel.Should().Be(ToolchainEpicLabel); - snapshot.Summary.Should().NotContain("Issue #"); - snapshot.Workstreams.Select(workstream => workstream.SectionLabel).Should().Equal("SURFACE", "DIAGNOSTICS", "CONFIGURATION", "POLLING"); - coveredIssues.Should().Equal( - ToolchainCenterIssues.ToolchainCenterUi, - ToolchainCenterIssues.CodexReadiness, - ToolchainCenterIssues.ClaudeCodeReadiness, - ToolchainCenterIssues.GitHubCopilotReadiness, - ToolchainCenterIssues.ConnectionDiagnostics, - ToolchainCenterIssues.ProviderConfiguration, - ToolchainCenterIssues.BackgroundPolling); - snapshot.Providers.Select(provider => provider.Provider.CommandName).Should().ContainInOrder("codex", "claude", "gh"); - } - - [Test] - public void CatalogSurfacesDiagnosticsConfigurationAndPollingForEachProvider() - { - using var catalog = CreateCatalog(); - - var snapshot = catalog.GetSnapshot(); - - snapshot.BackgroundPolling.RefreshInterval.Should().Be(TimeSpan.FromMinutes(5)); - snapshot.Providers.Should().OnlyContain(provider => - provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Launch") && - provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Connection test") && - provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Resume test") && - provider.Configuration.Any(entry => entry.Kind == ToolchainConfigurationKind.Secret) && - provider.Configuration.Any(entry => entry.Name == $"{provider.Provider.CommandName} path") && - provider.Polling.RefreshInterval == TimeSpan.FromMinutes(5)); - } - - [Test] - public void CatalogCanStartAndDisposeBackgroundPolling() - { - using var catalog = new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: true); - - var snapshot = catalog.GetSnapshot(); - - snapshot.BackgroundPolling.RefreshInterval.Should().Be(TimeSpan.FromMinutes(5)); - snapshot.Providers.Should().NotBeEmpty(); - } - - [Test] - public void CatalogDisposeIsIdempotentAfterBackgroundPollingStarts() - { - var catalog = new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: true); - - catalog.Dispose(); - - catalog.Invoking(item => item.Dispose()).Should().NotThrow(); - } - - [Test] - [NonParallelizable] - public void CatalogMarksProvidersMissingWhenPathAndAuthenticationSignalsAreCleared() - { - using var path = new EnvironmentVariableScope("PATH", string.Empty); - using var openAi = new EnvironmentVariableScope("OPENAI_API_KEY", null); - using var anthropic = new EnvironmentVariableScope("ANTHROPIC_API_KEY", null); - using var githubToken = new EnvironmentVariableScope("GITHUB_TOKEN", null); - using var githubHostToken = new EnvironmentVariableScope("GH_TOKEN", null); - using var catalog = CreateCatalog(); - - var providers = catalog.GetSnapshot().Providers; - - providers.Should().OnlyContain(provider => - provider.ReadinessState == ToolchainReadinessState.Missing && - provider.Provider.Status == ProviderConnectionStatus.Unavailable && - provider.AuthStatus == ToolchainAuthStatus.Missing && - provider.Diagnostics.Any(diagnostic => diagnostic.Name == "Launch" && diagnostic.Status == ToolchainDiagnosticStatus.Failed)); - } - - [TestCase("codex")] - [TestCase("claude")] - [TestCase("gh")] - public void AvailableProvidersExposeVersionAndConnectionReadinessWhenInstalled(string commandName) - { - using var catalog = CreateCatalog(); - var provider = catalog.GetSnapshot().Providers.Single(item => item.Provider.CommandName == commandName); - - Assume.That( - provider.Provider.Status, - Is.EqualTo(ProviderConnectionStatus.Available), - $"The '{commandName}' toolchain is not available in this environment."); - - provider.ExecutablePath.Should().NotBe("Not detected"); - provider.Diagnostics.Should().Contain(diagnostic => diagnostic.Name == "Launch" && diagnostic.Status == ToolchainDiagnosticStatus.Passed); - provider.VersionStatus.Should().NotBe(ToolchainVersionStatus.Missing); - } - - private static ToolchainCenterCatalog CreateCatalog() - { - return new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: false); - } - - private sealed class EnvironmentVariableScope : IDisposable - { - private readonly string _variableName; - private readonly string? _originalValue; - - public EnvironmentVariableScope(string variableName, string? value) - { - _variableName = variableName; - _originalValue = Environment.GetEnvironmentVariable(variableName); - Environment.SetEnvironmentVariable(variableName, value); - } - - public void Dispose() - { - Environment.SetEnvironmentVariable(_variableName, _originalValue); - } - } -} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs deleted file mode 100644 index 86c0112..0000000 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainCommandProbeTests.cs +++ /dev/null @@ -1,146 +0,0 @@ -using System.Reflection; - -namespace DotPilot.Tests.Features.ToolchainCenter; - -public class ToolchainCommandProbeTests -{ - private const string NonExecutableContents = "not an executable"; - - [Test] - public void ReadVersionUsesStandardErrorWhenStandardOutputIsEmpty() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "echo Claude Code version: 2.3.4 1>&2" - : "printf 'Claude Code version: 2.3.4\\n' >&2"); - - var version = ReadVersion(executablePath, arguments); - - version.Should().Be("2.3.4"); - } - - [Test] - public void ReadVersionReturnsTheTrimmedFirstLineWhenNoVersionSeparatorExists() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "(echo v9.8.7) & (echo ignored)" - : "printf 'v9.8.7\\nignored\\n'"); - - var version = ReadVersion(executablePath, arguments); - - version.Should().Be("v9.8.7"); - } - - [Test] - public void ReadVersionReturnsEmptyWhenTheCommandFails() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "echo boom 1>&2 & exit /b 1" - : "printf 'boom\\n' >&2; exit 1"); - - var version = ReadVersion(executablePath, arguments); - - version.Should().BeEmpty(); - } - - [Test] - public void CanExecuteReturnsFalseWhenTheCommandFails() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "exit /b 1" - : "exit 1"); - - var canExecute = CanExecute(executablePath, arguments); - - canExecute.Should().BeFalse(); - } - - [Test] - public void CanExecuteReturnsTrueWhenTheCommandSucceeds() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "exit /b 0" - : "exit 0"); - - var canExecute = CanExecute(executablePath, arguments); - - canExecute.Should().BeTrue(); - } - - [Test] - public void ReadVersionReturnsEmptyWhenTheCommandTimesOut() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "ping 127.0.0.1 -n 4 >nul" - : "sleep 3"); - - var version = ReadVersion(executablePath, arguments); - - version.Should().BeEmpty(); - } - - [Test] - public void CanExecuteReturnsFalseWhenTheResolvedPathCannotBeLaunched() - { - var nonExecutablePath = Path.GetTempFileName(); - - try - { - File.WriteAllText(nonExecutablePath, NonExecutableContents); - - CanExecute(nonExecutablePath, []).Should().BeFalse(); - ReadVersion(nonExecutablePath, []).Should().BeEmpty(); - } - finally - { - File.Delete(nonExecutablePath); - } - } - - [Test] - public void CanExecuteReturnsTrueWhenTheCommandProducesLargeRedirectedOutput() - { - var (executablePath, arguments) = CreateShellCommand( - OperatingSystem.IsWindows() - ? "for /L %i in (1,1,3000) do @echo output-line-%i" - : "i=1; while [ $i -le 3000 ]; do printf 'output-line-%s\\n' \"$i\"; i=$((i+1)); done"); - - var canExecute = CanExecute(executablePath, arguments); - - canExecute.Should().BeTrue(); - } - - private static string ReadVersion(string executablePath, IReadOnlyList arguments) - { - return (string)InvokeProbeMethod("ReadVersion", executablePath, arguments); - } - - private static bool CanExecute(string executablePath, IReadOnlyList arguments) - { - return (bool)InvokeProbeMethod("CanExecute", executablePath, arguments); - } - - private static object InvokeProbeMethod(string methodName, string executablePath, IReadOnlyList arguments) - { - var probeType = typeof(ToolchainCenterCatalog).Assembly.GetType( - "DotPilot.Runtime.Features.ToolchainCenter.ToolchainCommandProbe", - throwOnError: true)!; - var method = probeType.GetMethod( - methodName, - BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)!; - - return method.Invoke(null, [executablePath, arguments])!; - } - - private static (string ExecutablePath, string[] Arguments) CreateShellCommand(string command) - { - return OperatingSystem.IsWindows() - ? ("cmd.exe", ["/d", "/c", command]) - : ("/bin/sh", ["-c", command]); - } -} diff --git a/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs b/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs deleted file mode 100644 index 62b1a61..0000000 --- a/DotPilot.Tests/Features/ToolchainCenter/ToolchainProviderSnapshotFactoryTests.cs +++ /dev/null @@ -1,193 +0,0 @@ -using System.Reflection; - -namespace DotPilot.Tests.Features.ToolchainCenter; - -public class ToolchainProviderSnapshotFactoryTests -{ - [Test] - public void ResolveProviderStatusCoversUnavailableAuthenticationAndMisconfiguredBranches() - { - ResolveProviderStatus(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false) - .Should().Be(ProviderConnectionStatus.Unavailable); - ResolveProviderStatus(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: false) - .Should().Be(ProviderConnectionStatus.Unavailable); - ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: false) - .Should().Be(ProviderConnectionStatus.RequiresAuthentication); - ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false) - .Should().Be(ProviderConnectionStatus.Misconfigured); - ResolveProviderStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true) - .Should().Be(ProviderConnectionStatus.Available); - } - - [Test] - public void ResolveReadinessStateCoversMissingActionRequiredLimitedAndReady() - { - ResolveReadinessState(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) - .Should().Be(ToolchainReadinessState.Missing); - ResolveReadinessState(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainReadinessState.Missing); - ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainReadinessState.ActionRequired); - ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") - .Should().Be(ToolchainReadinessState.Limited); - ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) - .Should().Be(ToolchainReadinessState.Limited); - ResolveReadinessState(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainReadinessState.Ready); - } - - [Test] - public void ResolveHealthStatusCoversBlockedWarningAndHealthy() - { - ResolveHealthStatus(isInstalled: false, launchAvailable: false, authConfigured: false, toolAccessAvailable: false, installedVersion: string.Empty) - .Should().Be(ToolchainHealthStatus.Blocked); - ResolveHealthStatus(isInstalled: true, launchAvailable: false, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainHealthStatus.Blocked); - ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: false, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainHealthStatus.Blocked); - ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: false, installedVersion: "1.0.0") - .Should().Be(ToolchainHealthStatus.Warning); - ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: string.Empty) - .Should().Be(ToolchainHealthStatus.Warning); - ResolveHealthStatus(isInstalled: true, launchAvailable: true, authConfigured: true, toolAccessAvailable: true, installedVersion: "1.0.0") - .Should().Be(ToolchainHealthStatus.Healthy); - } - - [Test] - public void ResolveReadinessSummaryDistinguishesMissingInstallFromBrokenLaunch() - { - ResolveReadinessSummary("Codex CLI", isInstalled: false, launchAvailable: false, ToolchainReadinessState.Missing) - .Should().Contain("not installed"); - ResolveReadinessSummary("Codex CLI", isInstalled: true, launchAvailable: false, ToolchainReadinessState.Missing) - .Should().Contain("could not launch"); - } - - [Test] - public void ResolveHealthSummaryPrefersInstallAndLaunchGuidanceBeforeAuth() - { - ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: false, launchAvailable: false, authConfigured: false) - .Should().Contain("installed"); - ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: true, launchAvailable: false, authConfigured: false) - .Should().Contain("start the CLI"); - ResolveHealthSummary("Codex CLI", ToolchainHealthStatus.Blocked, isInstalled: true, launchAvailable: true, authConfigured: false) - .Should().Contain("authentication"); - } - - [Test] - public void ResolveConfigurationStatusDistinguishesRequiredAndOptionalSignals() - { - var requiredSignal = CreateSignal(name: "REQUIRED_TOKEN", isRequiredForReadiness: true); - var optionalSignal = CreateSignal(name: "OPTIONAL_ENDPOINT", isRequiredForReadiness: false); - - ResolveConfigurationStatus(requiredSignal, isConfigured: false) - .Should().Be(ToolchainConfigurationStatus.Missing); - ResolveConfigurationStatus(optionalSignal, isConfigured: false) - .Should().Be(ToolchainConfigurationStatus.Partial); - ResolveConfigurationStatus(optionalSignal, isConfigured: true) - .Should().Be(ToolchainConfigurationStatus.Configured); - } - - private static ProviderConnectionStatus ResolveProviderStatus(bool isInstalled, bool launchAvailable, bool authConfigured, bool toolAccessAvailable) - { - return (ProviderConnectionStatus)InvokeFactoryMethod( - "ResolveProviderStatus", - isInstalled, - launchAvailable, - authConfigured, - toolAccessAvailable)!; - } - - private static ToolchainReadinessState ResolveReadinessState( - bool isInstalled, - bool launchAvailable, - bool authConfigured, - bool toolAccessAvailable, - string installedVersion) - { - return (ToolchainReadinessState)InvokeFactoryMethod( - "ResolveReadinessState", - isInstalled, - launchAvailable, - authConfigured, - toolAccessAvailable, - installedVersion)!; - } - - private static ToolchainHealthStatus ResolveHealthStatus( - bool isInstalled, - bool launchAvailable, - bool authConfigured, - bool toolAccessAvailable, - string installedVersion) - { - return (ToolchainHealthStatus)InvokeFactoryMethod( - "ResolveHealthStatus", - isInstalled, - launchAvailable, - authConfigured, - toolAccessAvailable, - installedVersion)!; - } - - private static string ResolveReadinessSummary( - string displayName, - bool isInstalled, - bool launchAvailable, - ToolchainReadinessState readinessState) - { - return (string)InvokeFactoryMethod( - "ResolveReadinessSummary", - displayName, - isInstalled, - launchAvailable, - readinessState)!; - } - - private static string ResolveHealthSummary( - string displayName, - ToolchainHealthStatus healthStatus, - bool isInstalled, - bool launchAvailable, - bool authConfigured) - { - return (string)InvokeFactoryMethod( - "ResolveHealthSummary", - displayName, - healthStatus, - isInstalled, - launchAvailable, - authConfigured)!; - } - - private static ToolchainConfigurationStatus ResolveConfigurationStatus(object signal, bool isConfigured) - { - return (ToolchainConfigurationStatus)InvokeFactoryMethod("ResolveConfigurationStatus", signal, isConfigured)!; - } - - private static object CreateSignal(string name, bool isRequiredForReadiness) - { - var signalType = typeof(ToolchainCenterCatalog).Assembly.GetType( - "DotPilot.Runtime.Features.ToolchainCenter.ToolchainConfigurationSignal", - throwOnError: true)!; - - return Activator.CreateInstance( - signalType, - name, - "summary", - ToolchainConfigurationKind.Secret, - true, - isRequiredForReadiness)!; - } - - private static object? InvokeFactoryMethod(string methodName, params object[] arguments) - { - var factoryType = typeof(ToolchainCenterCatalog).Assembly.GetType( - "DotPilot.Runtime.Features.ToolchainCenter.ToolchainProviderSnapshotFactory", - throwOnError: true)!; - var method = factoryType.GetMethod( - methodName, - BindingFlags.Static | BindingFlags.NonPublic)!; - - return method.Invoke(null, arguments); - } -} diff --git a/DotPilot.Tests/Features/Workbench/PresentationViewModelTests.cs b/DotPilot.Tests/Features/Workbench/PresentationViewModelTests.cs deleted file mode 100644 index 5252235..0000000 --- a/DotPilot.Tests/Features/Workbench/PresentationViewModelTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using DotPilot.Presentation; -using DotPilot.Runtime.Features.Workbench; - -namespace DotPilot.Tests.Features.Workbench; - -public class PresentationViewModelTests -{ - [Test] - public void MainViewModelExposesWorkbenchShellState() - { - using var workspace = TemporaryWorkbenchDirectory.Create(); - var runtimeFoundationCatalog = CreateRuntimeFoundationCatalog(); - var viewModel = new MainViewModel( - new WorkbenchCatalog(runtimeFoundationCatalog, workspace.Root), - runtimeFoundationCatalog); - - viewModel.EpicLabel.Should().Be(WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.DesktopWorkbenchEpic)); - viewModel.WorkspaceRoot.Should().Be(workspace.Root); - viewModel.FilteredRepositoryNodes.Should().NotBeEmpty(); - viewModel.SelectedDocumentTitle.Should().NotBeEmpty(); - viewModel.IsPreviewMode.Should().BeTrue(); - viewModel.RepositorySearchText = "SettingsPage"; - viewModel.FilteredRepositoryNodes.Should().ContainSingle(node => node.RelativePath == "src/SettingsPage.xaml"); - viewModel.SelectedDocumentTitle.Should().Be("SettingsPage.xaml"); - viewModel.IsDiffReviewMode = true; - viewModel.IsPreviewMode.Should().BeFalse(); - viewModel.IsLogConsoleVisible = true; - viewModel.IsArtifactsVisible.Should().BeFalse(); - viewModel.RuntimeFoundation.EpicLabel.Should().Be("LOCAL RUNTIME READINESS"); - viewModel.RuntimeFoundation.Providers.Should().Contain(provider => !provider.RequiresExternalToolchain); - } - - [Test] - public void SettingsViewModelExposesUnifiedSettingsShellState() - { - using var workspace = TemporaryWorkbenchDirectory.Create(); - var runtimeFoundationCatalog = CreateRuntimeFoundationCatalog(); - var toolchainCenterCatalog = CreateToolchainCenterCatalog(); - var viewModel = new SettingsViewModel( - new WorkbenchCatalog(runtimeFoundationCatalog, workspace.Root), - runtimeFoundationCatalog, - toolchainCenterCatalog); - - viewModel.SettingsIssueLabel.Should().Be(WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell)); - viewModel.Categories.Should().HaveCountGreaterOrEqualTo(4); - viewModel.SelectedCategory?.Key.Should().Be(WorkbenchSettingsCategoryKeys.Toolchains); - viewModel.IsToolchainCenterVisible.Should().BeTrue(); - viewModel.ToolchainProviders.Should().HaveCount(3); - viewModel.SelectedToolchainProviderSnapshot.Should().NotBeNull(); - viewModel.ToolchainWorkstreams.Should().NotBeEmpty(); - viewModel.ProviderSummary.Should().Contain("ready"); - } - - [Test] - public void SecondViewModelExposesAgentBuilderState() - { - var viewModel = new SecondViewModel(CreateRuntimeFoundationCatalog()); - - viewModel.PageTitle.Should().Be("Create New Agent"); - viewModel.PageSubtitle.Should().Contain("AI agent"); - viewModel.SystemPrompt.Should().Contain("helpful AI assistant"); - viewModel.TokenSummary.Should().Be("0 / 4,096 tokens"); - viewModel.ExistingAgents.Should().HaveCount(3); - viewModel.AgentTypes.Should().HaveCount(4); - viewModel.AgentTypes.Should().ContainSingle(option => option.IsSelected); - viewModel.AvatarOptions.Should().HaveCount(6); - viewModel.PromptTemplates.Should().HaveCount(3); - viewModel.Skills.Should().HaveCount(5); - viewModel.Skills.Should().Contain(skill => skill.IsEnabled); - viewModel.Skills.Should().Contain(skill => !skill.IsEnabled); - viewModel.RuntimeFoundation.DeterministicClientName.Should().Be("In-Repo Test Client"); - viewModel.RuntimeFoundation.Providers.Should().ContainSingle(); - } - - private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() - { - return new RuntimeFoundationCatalog(); - } - - private static ToolchainCenterCatalog CreateToolchainCenterCatalog() - { - return new ToolchainCenterCatalog(TimeProvider.System, startBackgroundPolling: false); - } -} diff --git a/DotPilot.Tests/Features/Workbench/TemporaryWorkbenchDirectory.cs b/DotPilot.Tests/Features/Workbench/TemporaryWorkbenchDirectory.cs deleted file mode 100644 index c772e26..0000000 --- a/DotPilot.Tests/Features/Workbench/TemporaryWorkbenchDirectory.cs +++ /dev/null @@ -1,52 +0,0 @@ -namespace DotPilot.Tests.Features.Workbench; - -internal sealed class TemporaryWorkbenchDirectory : IDisposable -{ - private const string GitIgnoreFileName = ".gitignore"; - private const string GitIgnoreContent = - """ - ignored/ - *.tmp - """; - - private TemporaryWorkbenchDirectory(string root) - { - Root = root; - } - - public string Root { get; } - - public static TemporaryWorkbenchDirectory Create(bool includeSupportedFiles = true) - { - var root = Path.Combine( - Path.GetTempPath(), - "dotpilot-workbench-tests", - Guid.NewGuid().ToString("N")); - - Directory.CreateDirectory(root); - File.WriteAllText(Path.Combine(root, GitIgnoreFileName), GitIgnoreContent); - - if (includeSupportedFiles) - { - Directory.CreateDirectory(Path.Combine(root, "docs")); - Directory.CreateDirectory(Path.Combine(root, "src")); - Directory.CreateDirectory(Path.Combine(root, "ignored")); - - File.WriteAllText(Path.Combine(root, "docs", "Architecture.md"), "# Architecture"); - File.WriteAllText(Path.Combine(root, "src", "MainPage.xaml"), ""); - File.WriteAllText(Path.Combine(root, "src", "SettingsPage.xaml"), ""); - File.WriteAllText(Path.Combine(root, "ignored", "Secret.cs"), "internal sealed class Secret {}"); - File.WriteAllText(Path.Combine(root, "notes.tmp"), "ignored"); - } - - return new(root); - } - - public void Dispose() - { - if (Directory.Exists(Root)) - { - Directory.Delete(Root, recursive: true); - } - } -} diff --git a/DotPilot.Tests/Features/Workbench/WorkbenchCatalogTests.cs b/DotPilot.Tests/Features/Workbench/WorkbenchCatalogTests.cs deleted file mode 100644 index 75f34ad..0000000 --- a/DotPilot.Tests/Features/Workbench/WorkbenchCatalogTests.cs +++ /dev/null @@ -1,45 +0,0 @@ -using DotPilot.Runtime.Features.Workbench; - -namespace DotPilot.Tests.Features.Workbench; - -public class WorkbenchCatalogTests -{ - [Test] - public void GetSnapshotUsesLiveWorkspaceAndRespectsIgnoreRules() - { - using var workspace = TemporaryWorkbenchDirectory.Create(); - - var snapshot = CreateWorkbenchCatalog(workspace.Root).GetSnapshot(); - - snapshot.WorkspaceRoot.Should().Be(workspace.Root); - snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "src/MainPage.xaml"); - snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "src/SettingsPage.xaml"); - snapshot.RepositoryNodes.Should().NotContain(node => node.RelativePath.Contains("ignored", StringComparison.OrdinalIgnoreCase)); - snapshot.RepositoryNodes.Should().NotContain(node => node.RelativePath.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase)); - snapshot.Documents.Should().Contain(document => document.RelativePath == "src/MainPage.xaml"); - snapshot.SettingsCategories.Should().Contain(category => category.Key == "providers"); - snapshot.Logs.Should().HaveCount(4); - } - - [Test] - public void GetSnapshotFallsBackToSeededDataWhenWorkspaceHasNoSupportedDocuments() - { - using var workspace = TemporaryWorkbenchDirectory.Create(includeSupportedFiles: false); - - var snapshot = CreateWorkbenchCatalog(workspace.Root).GetSnapshot(); - - snapshot.WorkspaceName.Should().Be("Browser sandbox"); - snapshot.Documents.Should().NotBeEmpty(); - snapshot.RepositoryNodes.Should().Contain(node => node.RelativePath == "DotPilot/Presentation/MainPage.xaml"); - } - - private static WorkbenchCatalog CreateWorkbenchCatalog(string workspaceRoot) - { - return new WorkbenchCatalog(CreateRuntimeFoundationCatalog(), workspaceRoot); - } - - private static RuntimeFoundationCatalog CreateRuntimeFoundationCatalog() - { - return new RuntimeFoundationCatalog(); - } -} diff --git a/DotPilot.Tests/GlobalUsings.cs b/DotPilot.Tests/GlobalUsings.cs index 62128f3..41aff7a 100644 --- a/DotPilot.Tests/GlobalUsings.cs +++ b/DotPilot.Tests/GlobalUsings.cs @@ -1,11 +1,4 @@ global using DotPilot.Core.Features.ApplicationShell; global using DotPilot.Core.Features.ControlPlaneDomain; -global using DotPilot.Core.Features.RuntimeCommunication; -global using DotPilot.Core.Features.RuntimeFoundation; -global using DotPilot.Core.Features.ToolchainCenter; -global using DotPilot.Core.Features.Workbench; -global using DotPilot.Runtime.Features.RuntimeFoundation; -global using DotPilot.Runtime.Features.ToolchainCenter; -global using DotPilot.Runtime.Host.Features.RuntimeFoundation; global using FluentAssertions; global using NUnit.Framework; diff --git a/DotPilot.UITests/AGENTS.md b/DotPilot.UITests/AGENTS.md index be7fa1e..8e8ae83 100644 --- a/DotPilot.UITests/AGENTS.md +++ b/DotPilot.UITests/AGENTS.md @@ -13,7 +13,7 @@ Stack: `.NET 10`, `NUnit`, `Uno.UITest`, browser-driven UI tests - `DotPilot.UITests.csproj` - `Harness/Constants.cs` - `Harness/TestBase.cs` -- `Features/Workbench/GivenWorkbenchShell.cs` +- `Features/AgentSessions/GivenChatSessionsShell.cs` ## Boundaries diff --git a/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs new file mode 100644 index 0000000..bb2775f --- /dev/null +++ b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs @@ -0,0 +1,176 @@ +using DotPilot.UITests.Harness; +using OpenQA.Selenium; + +namespace DotPilot.UITests.Features.AgentSessions; + +[NonParallelizable] +public sealed class GivenChatSessionsShell : TestBase +{ + private static readonly TimeSpan InitialScreenProbeTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan ScreenTransitionTimeout = TimeSpan.FromSeconds(60); + private static readonly TimeSpan QueryRetryFrequency = TimeSpan.FromMilliseconds(250); + + private const string ChatScreenAutomationId = "ChatScreen"; + private const string SettingsScreenAutomationId = "SettingsScreen"; + private const string AgentBuilderScreenAutomationId = "AgentBuilderScreen"; + private const string ProvidersNavButtonAutomationId = "ProvidersNavButton"; + private const string SettingsSidebarAgentsButtonAutomationId = "SettingsSidebarAgentsButton"; + private const string SettingsSidebarChatButtonAutomationId = "SettingsSidebarChatButton"; + private const string AgentSidebarChatButtonAutomationId = "AgentSidebarChatButton"; + private const string ProviderListAutomationId = "ProviderList"; + private const string SelectedProviderTitleAutomationId = "SelectedProviderTitle"; + private const string ToggleProviderButtonAutomationId = "ToggleProviderButton"; + private const string AgentNameInputAutomationId = "AgentNameInput"; + private const string AgentModelInputAutomationId = "AgentModelInput"; + private const string AgentCreateStatusMessageAutomationId = "AgentCreateStatusMessage"; + private const string CreateAgentButtonAutomationId = "CreateAgentButton"; + private const string ChatComposerInputAutomationId = "ChatComposerInput"; + private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton"; + private const string ChatStartNewButtonAutomationId = "ChatStartNewButton"; + private const string ChatTitleTextAutomationId = "ChatTitleText"; + private const string ChatMessageTextAutomationId = "ChatMessageText"; + private const string ChatRecentChatItemAutomationId = "ChatRecentChatItem"; + private const string DebugProviderName = "Debug Provider"; + private const string CreatedAgentName = "Debug Agent UI"; + private const string SessionTitle = "Session with Debug Agent UI"; + private const string UserPrompt = "hello from ui"; + private const string ReadyToCreateDebugAgentText = "Ready to create an agent with Debug Provider."; + private const string DebugResponsePrefix = "Debug provider received: hello from ui"; + private const string DebugToolFinishedText = "Debug workflow finished."; + + [Test] + public async Task WhenOpeningTheAppThenChatNavigationAndComposerAreVisible() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + WaitForElement(ChatTitleTextAutomationId); + WaitForElement(ChatComposerInputAutomationId); + WaitForElement(ChatStartNewButtonAutomationId); + + TakeScreenshot("chat_shell_visible"); + } + + [Test] + public async Task WhenEnablingDebugCreatingAgentAndSendingMessageThenStreamedTranscriptIsVisible() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForElement(ProviderListAutomationId); + WaitForTextContains(SelectedProviderTitleAutomationId, DebugProviderName, ScreenTransitionTimeout); + TapAutomationElement(ToggleProviderButtonAutomationId); + WaitForTextContains(ToggleProviderButtonAutomationId, "Disable provider", ScreenTransitionTimeout); + + TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(AgentNameInputAutomationId); + WaitForElement(AgentModelInputAutomationId); + WaitForTextContains(AgentCreateStatusMessageAutomationId, ReadyToCreateDebugAgentText, ScreenTransitionTimeout); + ReplaceTextAutomationElement(AgentNameInputAutomationId, CreatedAgentName); + TapAutomationElement(CreateAgentButtonAutomationId); + WaitForTextContains(AgentCreateStatusMessageAutomationId, "Created Debug Agent UI using Debug Provider.", ScreenTransitionTimeout); + + TapAutomationElement(AgentSidebarChatButtonAutomationId); + EnsureOnChatScreen(); + TapAutomationElement(ChatStartNewButtonAutomationId); + WaitForElement(ChatRecentChatItemAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, SessionTitle, ScreenTransitionTimeout); + + App.EnterText(ChatComposerInputAutomationId, UserPrompt); + TapAutomationElement(ChatComposerSendButtonAutomationId); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + + TakeScreenshot("chat_debug_session_flow"); + } + + private void EnsureOnChatScreen() + { + if (TryWaitForElement(ChatScreenAutomationId, InitialScreenProbeTimeout)) + { + return; + } + + if (TryWaitForElement(AgentSidebarChatButtonAutomationId, InitialScreenProbeTimeout)) + { + TapAutomationElement(AgentSidebarChatButtonAutomationId); + } + else if (TryWaitForElement(SettingsSidebarChatButtonAutomationId, InitialScreenProbeTimeout)) + { + TapAutomationElement(SettingsSidebarChatButtonAutomationId); + } + + WaitForElement(ChatScreenAutomationId, "Timed out returning to the chat screen.", ScreenTransitionTimeout); + WaitForElement(ChatComposerInputAutomationId); + } + + private bool TryWaitForElement(string automationId, TimeSpan timeout) + { + try + { + WaitForElement(automationId, "Element probe timed out.", timeout); + return true; + } + catch (TimeoutException) + { + return false; + } + } + + private void WaitForTextContains(string automationId, string expectedText, TimeSpan timeout) + { + var timeoutAt = DateTimeOffset.UtcNow.Add(timeout); + while (DateTimeOffset.UtcNow < timeoutAt) + { + string[] texts; + try + { + texts = App.Query(automationId) + .Select(result => NormalizeText(result.Text)) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + } + catch (StaleElementReferenceException) + { + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + continue; + } + catch (InvalidOperationException) + { + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + continue; + } + + if (texts.Any(text => text.Contains(expectedText, StringComparison.Ordinal))) + { + return; + } + + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + } + + WriteBrowserSystemLogs($"text-timeout:{automationId}"); + WriteBrowserDomSnapshot($"text-timeout:{automationId}", automationId); + throw new TimeoutException($"Timed out waiting for text '{expectedText}' in automation id '{automationId}'."); + } + + private IAppResult[] WaitForElement(string automationId, string? timeoutMessage = null, TimeSpan? timeout = null) + { + return App.WaitForElement( + automationId, + timeoutMessage ?? $"Timed out waiting for automation id '{automationId}'.", + timeout ?? ScreenTransitionTimeout, + QueryRetryFrequency, + null); + } + + private static string NormalizeText(string value) + { + var segments = value + .Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return string.Join(' ', segments); + } +} diff --git a/DotPilot.UITests/Features/Workbench/GivenWorkbenchShell.cs b/DotPilot.UITests/Features/Workbench/GivenWorkbenchShell.cs deleted file mode 100644 index ddbf033..0000000 --- a/DotPilot.UITests/Features/Workbench/GivenWorkbenchShell.cs +++ /dev/null @@ -1,449 +0,0 @@ -using DotPilot.UITests.Harness; - -namespace DotPilot.UITests.Features.Workbench; - -[NonParallelizable] -public class GivenWorkbenchShell : TestBase -{ - private static readonly TimeSpan InitialScreenProbeTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan ScreenTransitionTimeout = TimeSpan.FromSeconds(60); - private static readonly TimeSpan QueryRetryFrequency = TimeSpan.FromMilliseconds(250); - private static readonly TimeSpan ShortProbeTimeout = TimeSpan.FromSeconds(3); - private const string WorkbenchScreenAutomationId = "WorkbenchScreen"; - private const string SettingsScreenAutomationId = "SettingsScreen"; - private const string AgentBuilderScreenAutomationId = "AgentBuilderScreen"; - private const string WorkbenchSessionTitleAutomationId = "WorkbenchSessionTitle"; - private const string WorkbenchPreviewEditorAutomationId = "WorkbenchPreviewEditor"; - private const string RepositoryNodesListAutomationId = "RepositoryNodesList"; - private const string WorkbenchSearchInputAutomationId = "WorkbenchSearchInput"; - private const string SelectedDocumentTitleAutomationId = "SelectedDocumentTitle"; - private const string DocumentViewModeToggleAutomationId = "DocumentViewModeToggle"; - private const string WorkbenchDiffLinesListAutomationId = "WorkbenchDiffLinesList"; - private const string WorkbenchDiffLineItemAutomationId = "WorkbenchDiffLineItem"; - private const string InspectorModeToggleAutomationId = "InspectorModeToggle"; - private const string ArtifactDockListAutomationId = "ArtifactDockList"; - private const string ArtifactDockItemAutomationId = "ArtifactDockItem"; - private const string RuntimeLogListAutomationId = "RuntimeLogList"; - private const string RuntimeLogItemAutomationId = "RuntimeLogItem"; - private const string WorkbenchNavButtonAutomationId = "WorkbenchNavButton"; - private const string AgentSidebarWorkbenchButtonAutomationId = "AgentSidebarWorkbenchButton"; - private const string SettingsSidebarWorkbenchButtonAutomationId = "SettingsSidebarWorkbenchButton"; - private const string WorkbenchSidebarAgentsButtonAutomationId = "WorkbenchSidebarAgentsButton"; - private const string SettingsSidebarAgentsButtonAutomationId = "SettingsSidebarAgentsButton"; - private const string BackToWorkbenchButtonAutomationId = "BackToWorkbenchButton"; - private const string WorkbenchSidebarSettingsButtonAutomationId = "WorkbenchSidebarSettingsButton"; - private const string SettingsCategoryListAutomationId = "SettingsCategoryList"; - private const string SettingsEntriesListAutomationId = "SettingsEntriesList"; - private const string SelectedSettingsCategoryTitleAutomationId = "SelectedSettingsCategoryTitle"; - private const string StorageSettingsCategoryAutomationId = "SettingsCategory-storage"; - private const string ToolchainSettingsCategoryAutomationId = "SettingsCategory-toolchains"; - private const string ToolchainCenterPanelAutomationId = "ToolchainCenterPanel"; - private const string ToolchainProviderListAutomationId = "ToolchainProviderList"; - private const string SelectedToolchainProviderTitleAutomationId = "SelectedToolchainProviderTitle"; - private const string ToolchainDiagnosticsListAutomationId = "ToolchainDiagnosticsList"; - private const string ToolchainConfigurationListAutomationId = "ToolchainConfigurationList"; - private const string ToolchainActionsListAutomationId = "ToolchainActionsList"; - private const string ToolchainBackgroundPollingAutomationId = "ToolchainBackgroundPolling"; - private const string ClaudeToolchainProviderAutomationId = "ToolchainProvider-claude"; - private const string SettingsPageSearchText = "SettingsPage"; - private const string SettingsPageDocumentTitle = "SettingsPage.xaml"; - private const string SettingsPageRepositoryNodeAutomationId = "RepositoryNodeTap-dotpilot-presentation-settingspage-xaml"; - private const string RuntimeFoundationPanelAutomationId = "RuntimeFoundationPanel"; - - [Test] - public async Task WhenOpeningTheAppThenWorkbenchSectionsAreVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - EnsureArtifactDockVisible(); - WaitForElement(WorkbenchNavButtonAutomationId); - WaitForElement(WorkbenchSessionTitleAutomationId); - WaitForElement(WorkbenchPreviewEditorAutomationId); - WaitForElement(RepositoryNodesListAutomationId); - WaitForElement(ArtifactDockListAutomationId); - WaitForElement(ArtifactDockItemAutomationId); - WaitForElement(RuntimeFoundationPanelAutomationId); - TakeScreenshot("workbench_shell_visible"); - } - - [Test] - public async Task WhenFilteringTheRepositoryThenTheMatchingFileOpens() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - OpenSettingsPageDocumentFromRepositorySearch(); - - TakeScreenshot("repository_search_open_file"); - } - - [Test] - public async Task WhenSwitchingToDiffReviewThenDiffSurfaceIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - EnsureDiffReviewVisible(); - WaitForElement(WorkbenchDiffLinesListAutomationId); - WaitForElement(WorkbenchDiffLineItemAutomationId); - - TakeScreenshot("diff_review_visible"); - } - - [Test] - public async Task WhenSwitchingInspectorModeThenRuntimeLogConsoleIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - EnsureRuntimeLogVisible(); - WaitForElement(RuntimeLogListAutomationId); - WaitForElement(RuntimeLogItemAutomationId); - - TakeScreenshot("runtime_log_console_visible"); - } - - [Test] - public async Task WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - WaitForElement(SettingsCategoryListAutomationId); - WaitForElement(ToolchainCenterPanelAutomationId); - WaitForElement(ToolchainProviderListAutomationId); - TapAutomationElement(StorageSettingsCategoryAutomationId); - WaitForElement(SettingsEntriesListAutomationId); - - var categoryTitle = GetSingleTextContent(SelectedSettingsCategoryTitleAutomationId); - Assert.That(categoryTitle, Is.EqualTo("Storage")); - - TakeScreenshot("settings_shell_visible"); - } - - [Test] - public async Task WhenNavigatingFromSettingsToAgentsThenAgentBuilderIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - WaitForElement(ToolchainCenterPanelAutomationId); - TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); - WaitForElement(AgentBuilderScreenAutomationId); - WaitForElement(BackToWorkbenchButtonAutomationId); - - TakeScreenshot("settings_to_agents_navigation"); - } - - [Test] - public async Task WhenReturningFromSettingsToWorkbenchThenWorkbenchScreenIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - TapAutomationElement(SettingsSidebarWorkbenchButtonAutomationId); - EnsureOnWorkbenchScreen(); - WaitForElement(RuntimeFoundationPanelAutomationId); - - TakeScreenshot("settings_to_workbench_navigation"); - } - - [Test] - public async Task WhenNavigatingToSettingsThenToolchainCenterProviderDetailsAreVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - WaitForElement(ToolchainSettingsCategoryAutomationId); - WaitForElement(ToolchainCenterPanelAutomationId); - WaitForElement(ToolchainProviderListAutomationId); - WaitForElement(SelectedToolchainProviderTitleAutomationId); - WaitForElement(ToolchainDiagnosticsListAutomationId); - WaitForElement(ToolchainConfigurationListAutomationId); - WaitForElement(ToolchainActionsListAutomationId); - WaitForElement(ToolchainBackgroundPollingAutomationId); - - TakeScreenshot("toolchain_center_visible"); - } - - [Test] - public async Task WhenSwitchingToolchainProvidersThenProviderSpecificDetailsAreVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - WaitForElement(ClaudeToolchainProviderAutomationId); - TapAutomationElement(ClaudeToolchainProviderAutomationId); - WaitForElement(SelectedToolchainProviderTitleAutomationId); - - var providerTitle = GetSingleTextContent(SelectedToolchainProviderTitleAutomationId); - Assert.That(providerTitle, Is.EqualTo("Claude Code")); - - WaitForElement(ToolchainDiagnosticsListAutomationId); - WaitForElement(ToolchainConfigurationListAutomationId); - WaitForElement(ToolchainActionsListAutomationId); - - TakeScreenshot("toolchain_center_claude_details"); - } - - [Test] - public async Task WhenNavigatingToSettingsAfterOpeningADocumentThenSettingsScreenIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - OpenSettingsPageDocumentFromRepositorySearch(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - - TakeScreenshot("document_to_settings_navigation"); - } - - [Test] - public async Task WhenNavigatingToAgentsAfterOpeningADocumentThenAgentBuilderIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - OpenSettingsPageDocumentFromRepositorySearch(); - TapAutomationElement(WorkbenchSidebarAgentsButtonAutomationId); - WaitForElement(AgentBuilderScreenAutomationId); - - TakeScreenshot("document_to_agents_navigation"); - } - - [Test] - public async Task WhenNavigatingToSettingsAfterChangingWorkbenchModesThenSettingsScreenIsVisible() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - OpenSettingsPageDocumentFromRepositorySearch(); - EnsureDiffReviewVisible(); - EnsureRuntimeLogVisible(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - - TakeScreenshot("workbench_modes_to_settings_navigation"); - } - - [Test] - public async Task WhenRunningAWorkbenchRoundTripThenTheMainShellCanBeRestored() - { - await Task.CompletedTask; - - EnsureOnWorkbenchScreen(); - OpenSettingsPageDocumentFromRepositorySearch(); - EnsureDiffReviewVisible(); - EnsureRuntimeLogVisible(); - TapAutomationElement(WorkbenchSidebarSettingsButtonAutomationId); - WaitForElement(SettingsScreenAutomationId); - TapAutomationElement(StorageSettingsCategoryAutomationId); - TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); - WaitForElement(AgentBuilderScreenAutomationId); - TapAutomationElement(AgentSidebarWorkbenchButtonAutomationId); - EnsureOnWorkbenchScreen(); - WaitForElement(RuntimeFoundationPanelAutomationId); - - TakeScreenshot("workbench_roundtrip_restored"); - } - - private void EnsureOnWorkbenchScreen() - { - if (TryWaitForWorkbenchSurface(InitialScreenProbeTimeout)) - { - return; - } - - if (TryWaitForElement(AgentSidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) - { - TapAutomationElement(AgentSidebarWorkbenchButtonAutomationId); - } - else if (TryWaitForElement(BackToWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) - { - TapAutomationElement(BackToWorkbenchButtonAutomationId); - } - else if (TryWaitForElement(SettingsSidebarWorkbenchButtonAutomationId, InitialScreenProbeTimeout)) - { - TapAutomationElement(SettingsSidebarWorkbenchButtonAutomationId); - } - - WaitForElement(WorkbenchScreenAutomationId, "Timed out returning to the workbench screen.", ScreenTransitionTimeout); - WaitForElement(WorkbenchSearchInputAutomationId); - WaitForElement(SelectedDocumentTitleAutomationId); - } - - private bool TryWaitForWorkbenchSurface(TimeSpan timeout) - { - if (!TryWaitForElement(WorkbenchScreenAutomationId, timeout)) - { - return false; - } - - if (!TryWaitForElement(WorkbenchNavButtonAutomationId, timeout)) - { - return false; - } - - if (!TryWaitForElement(WorkbenchSearchInputAutomationId, timeout)) - { - return false; - } - - return TryWaitForElement(SelectedDocumentTitleAutomationId, timeout); - } - - private void EnsureArtifactDockVisible() - { - if (TryWaitForElement(ArtifactDockListAutomationId, ShortProbeTimeout)) - { - return; - } - - if (TryWaitForElement(RuntimeLogListAutomationId, ShortProbeTimeout)) - { - App.Tap(InspectorModeToggleAutomationId); - } - - WaitForElement(ArtifactDockListAutomationId); - } - - private void EnsureRuntimeLogVisible() - { - if (TryWaitForElement(RuntimeLogListAutomationId, ShortProbeTimeout)) - { - return; - } - - App.Tap(InspectorModeToggleAutomationId); - WaitForElement(RuntimeLogListAutomationId); - } - - private void OpenSettingsPageDocumentFromRepositorySearch() - { - App.ClearText(WorkbenchSearchInputAutomationId); - App.EnterText(WorkbenchSearchInputAutomationId, SettingsPageSearchText); - WaitForElement(SettingsPageRepositoryNodeAutomationId); - TapAutomationElement(SettingsPageRepositoryNodeAutomationId); - WaitForElement(SelectedDocumentTitleAutomationId); - - var title = GetSingleTextContent(SelectedDocumentTitleAutomationId); - Assert.That(title, Is.EqualTo(SettingsPageDocumentTitle)); - } - - private void EnsureDiffReviewVisible() - { - if (TryWaitForElement(WorkbenchDiffLinesListAutomationId, ShortProbeTimeout)) - { - return; - } - - App.Tap(DocumentViewModeToggleAutomationId); - WaitForElement(WorkbenchDiffLinesListAutomationId); - } - - private bool TryWaitForElement(string automationId, TimeSpan timeout) - { - try - { - WaitForElement(automationId, "Element probe timed out.", timeout); - return true; - } - catch (TimeoutException) - { - return false; - } - } - - private string GetSingleTextContent(string automationId) - { - var results = App.Query(automationId); - Assert.That(results, Has.Length.EqualTo(1), $"Expected a single result for automation id '{automationId}'."); - return NormalizeTextContent(results[0].Text); - } - - private static string NormalizeTextContent(string value) - { - var segments = value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries); - return string.Join(' ', segments); - } - - private IAppResult[] WaitForElement(string automationId, string? timeoutMessage = null, TimeSpan? timeout = null) - { - try - { - return App.WaitForElement( - automationId, - timeoutMessage ?? $"Timed out waiting for automation id '{automationId}'.", - timeout ?? ScreenTransitionTimeout, - QueryRetryFrequency, - null); - } - catch (TimeoutException) - { - WriteTimeoutDiagnostics(automationId); - throw; - } - } - - private void WriteTimeoutDiagnostics(string automationId) - { - WriteBrowserSystemLogs($"timeout:{automationId}"); - WriteBrowserDomSnapshot($"timeout:{automationId}", automationId); - WriteSelectorDiagnostics(automationId); - - try - { - TakeScreenshot($"timeout_{automationId}"); - } - catch (Exception exception) - { - HarnessLog.Write($"Timeout screenshot capture failed for '{automationId}': {exception.Message}"); - } - } - - private void WriteSelectorDiagnostics(string timedOutAutomationId) - { - var automationIds = new[] - { - timedOutAutomationId, - WorkbenchScreenAutomationId, - SettingsScreenAutomationId, - AgentBuilderScreenAutomationId, - WorkbenchNavButtonAutomationId, - AgentSidebarWorkbenchButtonAutomationId, - SettingsSidebarWorkbenchButtonAutomationId, - WorkbenchSidebarAgentsButtonAutomationId, - SettingsSidebarAgentsButtonAutomationId, - WorkbenchSidebarSettingsButtonAutomationId, - WorkbenchSearchInputAutomationId, - SelectedDocumentTitleAutomationId, - RuntimeFoundationPanelAutomationId, - BackToWorkbenchButtonAutomationId, - }; - - foreach (var automationId in automationIds.Distinct(StringComparer.Ordinal)) - { - try - { - var matches = App.Query(automationId); - HarnessLog.Write($"Selector diagnostic '{automationId}' returned {matches.Length} matches."); - } - catch (Exception exception) - { - HarnessLog.Write($"Selector diagnostic '{automationId}' failed: {exception.Message}"); - } - } - } -} diff --git a/DotPilot.UITests/Harness/TestBase.cs b/DotPilot.UITests/Harness/TestBase.cs index 64d3ab5..9c11dec 100644 --- a/DotPilot.UITests/Harness/TestBase.cs +++ b/DotPilot.UITests/Harness/TestBase.cs @@ -317,6 +317,20 @@ protected void TapAutomationElement(string automationId) ArgumentException.ThrowIfNullOrWhiteSpace(automationId); try { + var matches = App.Query(automationId); + if (matches.Length > 0) + { + var target = matches[0]; + HarnessLog.Write( + $"Tap target '{automationId}' enabled='{target.Enabled}' rect='{target.Rect}' text='{target.Text}' label='{target.Label}'."); + + if (Constants.CurrentPlatform == Platform.Browser) + { + App.TapCoordinates(target.Rect.CenterX, target.Rect.CenterY); + return; + } + } + App.Tap(automationId); } catch (InvalidOperationException exception) @@ -340,13 +354,237 @@ protected void TapAutomationElement(string automationId) HarnessLog.Write($"Tap selector diagnostics failed for '{automationId}': {diagnosticException.Message}"); } + if (Constants.CurrentPlatform == Platform.Browser) + { + var fallbackMatches = App.Query(automationId); + if (fallbackMatches.Length > 0) + { + var fallbackTarget = fallbackMatches[0]; + HarnessLog.Write( + $"Falling back to coordinate tap for '{automationId}' at '{fallbackTarget.Rect.CenterX},{fallbackTarget.Rect.CenterY}'."); + App.TapCoordinates(fallbackTarget.Rect.CenterX, fallbackTarget.Rect.CenterY); + return; + } + } + WriteBrowserAutomationDiagnostics(automationId); WriteBrowserDomSnapshot($"tap:{automationId}", automationId); throw; } } - private void WriteBrowserAutomationDiagnostics(string automationId) + protected void ReplaceTextAutomationElement(string automationId, string text) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + ArgumentNullException.ThrowIfNull(text); + + if (TrySetBrowserInputValue(automationId, text)) + { + return; + } + + App.ClearText(automationId); + App.EnterText(automationId, text); + } + + protected void ClickActionAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (TryClickBrowserAutomationElement(automationId)) + { + return; + } + + TapAutomationElement(automationId); + } + + private bool TryClickBrowserAutomationElement(string automationId) + { + if (Constants.CurrentPlatform != Platform.Browser || _app is null) + { + return false; + } + + try + { + var driver = _app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app); + + if (driver is null) + { + return false; + } + + var executeScriptMethod = driver.GetType().GetMethod( + "ExecuteScript", + [typeof(string), typeof(object[])]); + + if (executeScriptMethod is null) + { + return false; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + var outcome = executeScriptMethod.Invoke( + driver, + [ + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const host = document.querySelector(selector); + if (!host) { + return 'missing'; + } + + const element = host.matches('button, [role="button"], input[type="button"], input[type="submit"]') + ? host + : host.querySelector('button, [role="button"], input[type="button"], input[type="submit"]') ?? host; + + element.scrollIntoView({ block: 'center', inline: 'center' }); + const rect = element.getBoundingClientRect(); + const eventInit = { + bubbles: true, + cancelable: true, + composed: true, + view: window, + clientX: rect.left + (rect.width / 2), + clientY: rect.top + (rect.height / 2), + button: 0 + }; + + element.dispatchEvent(new PointerEvent('pointerover', eventInit)); + element.dispatchEvent(new PointerEvent('pointerenter', eventInit)); + element.dispatchEvent(new MouseEvent('mouseover', eventInit)); + element.dispatchEvent(new MouseEvent('mouseenter', eventInit)); + element.dispatchEvent(new PointerEvent('pointerdown', eventInit)); + element.dispatchEvent(new MouseEvent('mousedown', eventInit)); + element.dispatchEvent(new PointerEvent('pointerup', eventInit)); + element.dispatchEvent(new MouseEvent('mouseup', eventInit)); + element.dispatchEvent(new MouseEvent('click', eventInit)); + return 'clicked'; + })(); + """), + Array.Empty(), + ]); + + HarnessLog.Write($"Browser action click outcome for '{automationId}': {outcome}"); + return string.Equals( + Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), + "clicked", + StringComparison.Ordinal); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser action click failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private bool TrySetBrowserInputValue(string automationId, string text) + { + if (Constants.CurrentPlatform != Platform.Browser || _app is null) + { + return false; + } + + try + { + var driver = _app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app); + + if (driver is null) + { + return false; + } + + var executeScriptMethod = driver.GetType().GetMethod( + "ExecuteScript", + [typeof(string), typeof(object[])]); + + if (executeScriptMethod is null) + { + return false; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + var escapedText = text + .Replace("\\", "\\\\", StringComparison.Ordinal) + .Replace("'", "\\'", StringComparison.Ordinal) + .Replace("\r", "\\r", StringComparison.Ordinal) + .Replace("\n", "\\n", StringComparison.Ordinal); + var outcome = executeScriptMethod.Invoke( + driver, + [ + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const value = ' + """, + escapedText, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const host = document.querySelector(selector); + if (!host) { + return 'missing'; + } + + const element = host.matches('input, textarea, [contenteditable="true"]') + ? host + : host.querySelector('input, textarea, [contenteditable="true"]'); + if (!element) { + return 'not-an-input'; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus(); + + if ('value' in element) { + element.value = value; + } else { + element.textContent = value; + } + + const options = { bubbles: true, cancelable: true, composed: true }; + element.dispatchEvent(new Event('input', options)); + element.dispatchEvent(new Event('change', options)); + element.blur(); + return 'set'; + })(); + """), + Array.Empty(), + ]); + + HarnessLog.Write($"Browser input replacement outcome for '{automationId}': {outcome}"); + return string.Equals( + Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), + "set", + StringComparison.Ordinal); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser input replacement failed for '{automationId}': {exception.Message}"); + return false; + } + } + + protected void WriteBrowserAutomationDiagnostics(string automationId) { if (Constants.CurrentPlatform != Platform.Browser || _app is null) { diff --git a/DotPilot.slnx b/DotPilot.slnx index da26d5e..abb5587 100644 --- a/DotPilot.slnx +++ b/DotPilot.slnx @@ -13,7 +13,6 @@ - diff --git a/DotPilot/AGENTS.md b/DotPilot/AGENTS.md index 40c152d..6e76973 100644 --- a/DotPilot/AGENTS.md +++ b/DotPilot/AGENTS.md @@ -6,8 +6,8 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de ## Purpose - This project contains the production `Uno Platform` application shell and presentation layer. -- It owns app startup, route registration, desktop window behavior, shared styling resources, and the current static desktop screens. -- It is evolving into the desktop control plane for local-first agent operations across coding, research, orchestration, and operator workflows. +- It owns app startup, route registration, desktop window behavior, shared styling resources, and the desktop chat experience for agent sessions. +- It is evolving into a chat-first desktop control plane for local-first agent operations across coding, research, orchestration, and operator workflows. - It must remain the presentation host for the product, while feature logic lives in separate vertical-slice class libraries. ## Entry Points @@ -25,16 +25,19 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - Keep this project focused on app composition, presentation, routing, and platform startup concerns. - Keep feature/domain/runtime code out of this project; reference it through slice-owned contracts and application services from separate DLLs. -- Reuse the current desktop workbench direction: left navigation, central session surface, right-side inspector, and the agent-builder flow should evolve into real runtime-backed features instead of being replaced with a different shell concept. +- Build the visible product around a desktop chat shell: session list, active transcript, terminal-like activity pane, agent/profile controls, and provider settings are the primary surfaces. +- Do not use workbench, issue-center, domain-browser, or other backlog-driven IA labels as the product shell. +- Do not preserve legacy prototype pages or controls once the replacement chat/session surface is underway; remove obsolete UI paths instead of carrying both shells. - Prefer declarative `Uno.Extensions.Navigation` in XAML via `uen:Navigation.Request` over page code-behind navigation calls. - Keep business logic, persistence, networking workflows, and non-UI orchestration out of page code-behind. - Build presentation with `MVVM`-friendly view models and separate reusable XAML components instead of large monolithic pages. - Organize non-UI work by feature-aligned vertical slices so each slice can evolve and ship without creating a shared dump of cross-cutting services in the app project. -- Replace scaffold sample data with real runtime-backed state as product features arrive; do not throw away the shell structure unless a later documented decision explicitly requires it. +- Replace scaffold sample data with real runtime-backed state as product features arrive; the shell should converge on the real chat/session workflow instead of preserving prototype-only concepts. - Reuse shared resources and small XAML components instead of duplicating large visual sections across pages. - Treat desktop window sizing and positioning as an app-startup responsibility in `App.xaml.cs`. - For local UI debugging on this machine, run the real desktop head and prefer local `Uno` app tooling or MCP inspection over `browserwasm` reproduction unless the task is specifically about `DotPilot.UITests`. - Prefer `Microsoft Agent Framework` for orchestration, sessions, workflows, HITL, MCP-aware runtime features, and OpenTelemetry-based observability hooks. +- Persist durable chat/session/operator state outside the UI layer, using `EF Core` with `SQLite` for the local desktop store when data must survive restarts. - Prefer official `.NET` AI evaluation libraries under `Microsoft.Extensions.AI.Evaluation*` for quality and safety evaluation features. - Do not plan or wire `MLXSharp` into the first product wave for this project. @@ -63,4 +66,4 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - `Presentation/*Page.xaml` files can grow quickly; split repeated sections before they violate maintainability limits. - This project is currently the visible product surface, so every visual change should preserve desktop responsiveness and accessibility-minded structure. - `DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so Roslyn `IDE0005` stays active in CI across desktop, core, and browserwasm targets; do not remove that exception unless full XML documentation becomes part of the enforced quality bar. -- The current screens already imply the future product IA, so backlog and implementation work should map onto the existing shell concepts instead of inventing unrelated pages. +- Product wording and navigation here set the real user expectation; avoid leaking architecture slice names, issue numbers, or backlog jargon into the visible shell. diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index bf621be..77a90e8 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -1,9 +1,9 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using DotPilot.Runtime.Features.RuntimeFoundation; +using DotPilot.Runtime.Features.AgentSessions; #if !__WASM__ -using DotPilot.Runtime.Host.Features.RuntimeFoundation; +using DotPilot.Runtime.Host.Features.AgentSessions; #endif namespace DotPilot; @@ -57,7 +57,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) .UseEnvironment(Environments.Development) #endif #if !__WASM__ - .UseDotPilotEmbeddedRuntime() + .UseDotPilotAgentSessions() #endif .UseLogging(configure: (context, logBuilder) => { @@ -108,18 +108,8 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) .ConfigureServices((context, services) => { Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.Workbench.IWorkbenchCatalog, - DotPilot.Runtime.Features.Workbench.WorkbenchCatalog>(services); -#if !__WASM__ - services.AddDesktopRuntimeFoundation(); -#else - services.AddBrowserRuntimeFoundation(); -#endif - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddSingleton< - DotPilot.Core.Features.ToolchainCenter.IToolchainCenterCatalog, - DotPilot.Runtime.Features.ToolchainCenter.ToolchainCenterCatalog>(services); + .AddSingleton(services, TimeProvider.System); + services.AddAgentSessions(); Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions .AddTransient(services); Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions diff --git a/DotPilot/DotPilot.csproj b/DotPilot/DotPilot.csproj index 042ac3b..bfc38fc 100644 --- a/DotPilot/DotPilot.csproj +++ b/DotPilot/DotPilot.csproj @@ -54,6 +54,15 @@ true + + + + + + True $(DefineConstants);USE_UITESTS diff --git a/DotPilot/GlobalUsings.cs b/DotPilot/GlobalUsings.cs index 287e966..f7be021 100644 --- a/DotPilot/GlobalUsings.cs +++ b/DotPilot/GlobalUsings.cs @@ -1,4 +1,2 @@ global using DotPilot.Core.Features.ApplicationShell; -global using DotPilot.Core.Features.RuntimeFoundation; -global using DotPilot.Core.Features.Workbench; global using DotPilot.Presentation; diff --git a/DotPilot/Presentation/AgentBuilderModels.cs b/DotPilot/Presentation/AgentBuilderModels.cs index 70fafa5..a74eff4 100644 --- a/DotPilot/Presentation/AgentBuilderModels.cs +++ b/DotPilot/Presentation/AgentBuilderModels.cs @@ -1,9 +1,48 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; + namespace DotPilot.Presentation; -public sealed record AgentMenuItem(string Name, string ModelSummary, string Initial, Brush? AvatarBrush); +public sealed partial record AgentProviderOption( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string StatusSummary, + string? InstalledVersion, + bool CanCreateAgents); + +public sealed class CapabilityOption( + string name, + string description, + bool isEnabled) : ObservableObject +{ + private bool _isEnabled = isEnabled; + + public string Name { get; } = name; + + public string Description { get; } = description; + + public bool IsEnabled + { + get => _isEnabled; + set => SetProperty(ref _isEnabled, value); + } +} + +public sealed class RoleOption( + string label, + AgentRoleKind role, + bool isSelected) : ObservableObject +{ + private bool _isSelected = isSelected; -public sealed record AgentTypeOption(string Label, bool IsSelected); + public string Label { get; } = label; -public sealed record AvatarOption(string Initial, Brush? AvatarBrush); + public AgentRoleKind Role { get; } = role; -public sealed record SkillToggleItem(string Name, string Description, string IconGlyph, bool IsEnabled); + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } +} diff --git a/DotPilot/Presentation/AsyncCommand.cs b/DotPilot/Presentation/AsyncCommand.cs new file mode 100644 index 0000000..830c242 --- /dev/null +++ b/DotPilot/Presentation/AsyncCommand.cs @@ -0,0 +1,48 @@ +namespace DotPilot.Presentation; + +public sealed class AsyncCommand( + Func executeAsync, + Func? canExecute = null) : ICommand +{ + private bool _isExecuting; + + public AsyncCommand(Func executeAsync, Func? canExecute = null) + : this( + _ => executeAsync(), + canExecute is null ? null : _ => canExecute()) + { + } + + public event EventHandler? CanExecuteChanged; + + public bool CanExecute(object? parameter) + { + return !_isExecuting && (canExecute?.Invoke(parameter) ?? true); + } + + public async void Execute(object? parameter) + { + if (!CanExecute(parameter)) + { + return; + } + + _isExecuting = true; + RaiseCanExecuteChanged(); + + try + { + await executeAsync(parameter); + } + finally + { + _isExecuting = false; + RaiseCanExecuteChanged(); + } + } + + public void RaiseCanExecuteChanged() + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + } +} diff --git a/DotPilot/Presentation/ChatDesignModels.cs b/DotPilot/Presentation/ChatDesignModels.cs index e12faba..ba0d570 100644 --- a/DotPilot/Presentation/ChatDesignModels.cs +++ b/DotPilot/Presentation/ChatDesignModels.cs @@ -1,19 +1,44 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; + namespace DotPilot.Presentation; -public sealed record SidebarChatItem(string Title, string Preview, bool IsSelected); +public sealed partial record SessionSidebarItem( + SessionId Id, + string Title, + string Preview); -public sealed record ChatMessageItem( +public sealed partial record ChatTimelineItem( + string Id, + SessionStreamEntryKind Kind, string Author, string Timestamp, string Content, string Initial, Brush? AvatarBrush, - bool IsCurrentUser); + bool IsCurrentUser, + string? AccentLabel = null); -public sealed record ParticipantItem( +public sealed partial record ParticipantItem( string Name, string SecondaryText, string Initial, Brush? AvatarBrush, string? BadgeText = null, Brush? BadgeBrush = null); + +public sealed partial record ProviderStatusItem( + AgentProviderKind Kind, + string DisplayName, + string CommandName, + string StatusSummary, + string? InstalledVersion, + bool IsEnabled, + bool CanCreateAgents, + string InstallCommand, + IReadOnlyList Actions); + +public sealed partial record ProviderActionItem( + string Label, + string Summary, + string Command); diff --git a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml index 50080a6..2ff93f0 100644 --- a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml +++ b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml @@ -1,7 +1,7 @@ @@ -27,131 +27,111 @@ VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold" - Text="Basic Information" /> + Text="Basic information" /> - - + + - - + + + PlaceholderText="e.g. Code Reviewer" + Text="{Binding AgentName, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> - + - - - - - - - - - - - - - - - - - - - - - + Text="Provider" /> + + + - - - - - - + + + + + - - - - + + + + + + + + + + + Spacing="10" /> - - - - + + - - + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/AgentPromptSection.xaml b/DotPilot/Presentation/Controls/AgentPromptSection.xaml index c845100..7c21f6f 100644 --- a/DotPilot/Presentation/Controls/AgentPromptSection.xaml +++ b/DotPilot/Presentation/Controls/AgentPromptSection.xaml @@ -1,5 +1,4 @@ @@ -27,77 +26,48 @@ VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold" - Text="System Prompt" /> + Text="System prompt" /> + + + + + + + + + - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentSidebar.xaml b/DotPilot/Presentation/Controls/AgentSidebar.xaml index ed01d8a..fa24cd7 100644 --- a/DotPilot/Presentation/Controls/AgentSidebar.xaml +++ b/DotPilot/Presentation/Controls/AgentSidebar.xaml @@ -1,6 +1,7 @@ @@ -91,64 +92,47 @@ FontSize="12" FontWeight="Medium" Foreground="{StaticResource AppMutedTextBrush}" - Text="Agents" /> + Text="PROVIDERS" /> - - - - - - + - + - - - - - - - - - + + + + + + + + + + + - - - - - - - + Text="{x:Bind CommandName}" /> + + @@ -175,22 +159,22 @@ FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource AppInputSurfaceBrush}" - Text="J" /> + Text="A" /> + Text="Agent designer" /> + Text="Local profile builder" /> + Text="..." /> diff --git a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml index f67bf0b..1372cbb 100644 --- a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml +++ b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml @@ -1,6 +1,7 @@ @@ -26,13 +27,18 @@ VerticalAlignment="Center" FontSize="14" FontWeight="SemiBold" - Text="Skills" /> + Text="Capabilities" /> + + + ItemsSource="{Binding Capabilities}"> - + @@ -49,22 +55,22 @@ + Text="*" /> + Text="{x:Bind Name}" /> diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml b/DotPilot/Presentation/Controls/ChatComposer.xaml index fabfaa5..a1212f8 100644 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml +++ b/DotPilot/Presentation/Controls/ChatComposer.xaml @@ -2,79 +2,73 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" AutomationProperties.AutomationId="ChatComposer"> - - - - - - - - + + - + + + + + + + - + - + - - - + + + + diff --git a/DotPilot/Presentation/Controls/ChatConversationView.xaml b/DotPilot/Presentation/Controls/ChatConversationView.xaml index ef3bced..7053439 100644 --- a/DotPilot/Presentation/Controls/ChatConversationView.xaml +++ b/DotPilot/Presentation/Controls/ChatConversationView.xaml @@ -6,9 +6,10 @@ AutomationProperties.AutomationId="ChatConversationView"> + x:DataType="presentation:ChatTimelineItem"> + MaxWidth="660" + AutomationProperties.AutomationId="ChatIncomingMessageItem"> @@ -47,22 +48,30 @@ BorderThickness="1" CornerRadius="18,18,18,6" Padding="16,14"> - + + + + + x:DataType="presentation:ChatTimelineItem"> + Spacing="6" + AutomationProperties.AutomationId="ChatOutgoingMessageItem"> @@ -79,6 +88,7 @@ CornerRadius="28,28,8,28" Padding="16"> + Text="S" /> - @@ -130,26 +141,28 @@ - @@ -159,12 +172,25 @@ VerticalScrollBarVisibility="Auto"> - + + + + + + @@ -109,7 +108,7 @@ + Text="Session details" /> - - @@ -216,20 +191,16 @@ Text="MEMBERS" /> @@ -279,7 +250,24 @@ - + + + + + + diff --git a/DotPilot/Presentation/Controls/ChatSidebar.xaml b/DotPilot/Presentation/Controls/ChatSidebar.xaml index bc2b450..53ad876 100644 --- a/DotPilot/Presentation/Controls/ChatSidebar.xaml +++ b/DotPilot/Presentation/Controls/ChatSidebar.xaml @@ -68,6 +68,18 @@ Text="Agents" /> + + Text="SESSIONS" /> - + - - - - - - - - - - - - - + + + + + + + - - - + TextTrimming="CharacterEllipsis" + TextWrapping="WrapWholeWords" /> + + - - + + @@ -163,17 +180,17 @@ FontSize="12" FontWeight="SemiBold" Foreground="White" - Text="J" /> + Text="L" /> + Text="Local operator" /> + Text="Desktop session host" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml.cs b/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml.cs deleted file mode 100644 index a8d066a..0000000 --- a/DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class RuntimeFoundationPanel : UserControl -{ - public RuntimeFoundationPanel() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/SettingsShell.xaml b/DotPilot/Presentation/Controls/SettingsShell.xaml index 18121b8..89cc551 100644 --- a/DotPilot/Presentation/Controls/SettingsShell.xaml +++ b/DotPilot/Presentation/Controls/SettingsShell.xaml @@ -2,8 +2,7 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:presentation="using:DotPilot.Presentation" - xmlns:controls="using:DotPilot.Presentation.Controls" - xmlns:workbench="using:DotPilot.Core.Features.Workbench" + x:Name="Root" AutomationProperties.AutomationId="SettingsShell"> @@ -13,11 +12,6 @@ - @@ -41,30 +35,30 @@ FontSize="11" FontWeight="Medium" Foreground="{StaticResource AppMutedTextBrush}" - Text="CATEGORIES" /> - + - + + AutomationProperties.AutomationId="ProviderEntryItem"> + Text="{x:Bind DisplayName}" /> @@ -83,13 +77,13 @@ - + Text="{Binding SelectedProviderTitle}" /> + Text="{Binding ToggleActionLabel}" /> - + + + + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/DotPilot/Presentation/Controls/SettingsSidebar.xaml b/DotPilot/Presentation/Controls/SettingsSidebar.xaml index af38a27..c3e2b27 100644 --- a/DotPilot/Presentation/Controls/SettingsSidebar.xaml +++ b/DotPilot/Presentation/Controls/SettingsSidebar.xaml @@ -44,16 +44,16 @@ - @@ -89,24 +89,19 @@ Margin="12,20,12,20" Spacing="16"> - + Text="Provider readiness" /> - + - + + Text="{x:Bind DisplayName}" /> @@ -149,22 +144,22 @@ FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource AppInputSurfaceBrush}" - Text="J" /> + Text="P" /> + Text="Provider operator" /> + Text="local readiness" /> + Text="..." /> diff --git a/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml b/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml deleted file mode 100644 index 9e09cbd..0000000 --- a/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml +++ /dev/null @@ -1,431 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml.cs b/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml.cs deleted file mode 100644 index 6b28193..0000000 --- a/DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class ToolchainCenterPanel : UserControl -{ - public ToolchainCenterPanel() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml deleted file mode 100644 index 5c545fe..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs deleted file mode 100644 index 30a929e..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchActivityPanel.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class WorkbenchActivityPanel : UserControl -{ - public WorkbenchActivityPanel() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml deleted file mode 100644 index 9bb252e..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs deleted file mode 100644 index df0f00f..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchDocumentSurface.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class WorkbenchDocumentSurface : UserControl -{ - public WorkbenchDocumentSurface() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml deleted file mode 100644 index e6fbd23..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs deleted file mode 100644 index 5ea55b6..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchInspectorPanel.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class WorkbenchInspectorPanel : UserControl -{ - public WorkbenchInspectorPanel() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml deleted file mode 100644 index b5d19a2..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml +++ /dev/null @@ -1,217 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs b/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs deleted file mode 100644 index cb0b023..0000000 --- a/DotPilot/Presentation/Controls/WorkbenchSidebar.xaml.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace DotPilot.Presentation.Controls; - -public sealed partial class WorkbenchSidebar : UserControl -{ - public WorkbenchSidebar() - { - InitializeComponent(); - } -} diff --git a/DotPilot/Presentation/MainPage.xaml b/DotPilot/Presentation/MainPage.xaml index 7c2e004..0182df3 100644 --- a/DotPilot/Presentation/MainPage.xaml +++ b/DotPilot/Presentation/MainPage.xaml @@ -6,7 +6,7 @@ Background="{StaticResource AppShellBackgroundBrush}" FontFamily="{StaticResource AppBodyFontFamily}"> + AutomationProperties.AutomationId="ChatScreen"> - + - + - + - - - + + - + diff --git a/DotPilot/Presentation/MainViewModel.cs b/DotPilot/Presentation/MainViewModel.cs index dc60147..488e8ac 100644 --- a/DotPilot/Presentation/MainViewModel.cs +++ b/DotPilot/Presentation/MainViewModel.cs @@ -1,281 +1,338 @@ -using System.Collections.Frozen; +using System.Collections.ObjectModel; +using System.Globalization; +using DotPilot.Core.Features.AgentSessions; namespace DotPilot.Presentation; public sealed class MainViewModel : ObservableObject { - private const double IndentSize = 16d; - private const string DefaultDocumentTitle = "Select a file"; - private const string DefaultDocumentPath = "Choose a repository item from the left sidebar."; - private const string DefaultDocumentStatus = "The file surface becomes active after you open a file."; - private const string DefaultInspectorArtifactsTitle = "Artifacts"; - private const string DefaultInspectorLogsTitle = "Runtime log console"; - private const string DefaultInspectorArtifactsSummary = "Generated files, plans, screenshots, and session outputs stay attached to the current workbench."; - private const string DefaultInspectorLogsSummary = "Runtime logs remain visible without leaving the main workbench."; - private const string DefaultLanguageLabel = "No document"; - private const string DefaultRendererLabel = "Select a repository item"; - private const string DefaultPreviewContent = "Open a file from the repository tree to inspect it here."; - - private readonly FrozenDictionary _documentsByPath; - private readonly IReadOnlyList _allRepositoryNodes; - private IReadOnlyList _filteredRepositoryNodes; - private string _repositorySearchText = string.Empty; - private WorkbenchRepositoryNodeItem? _selectedRepositoryNode; - private WorkbenchDocumentDescriptor? _selectedDocument; - private string _editablePreviewContent = DefaultPreviewContent; - private bool _isDiffReviewMode; - private bool _isLogConsoleVisible; - - public MainViewModel( - IWorkbenchCatalog workbenchCatalog, - IRuntimeFoundationCatalog runtimeFoundationCatalog) + private const string EmptyTitleValue = "No active session"; + private const string EmptyStatusValue = "Create an agent, start a session, then chat from here."; + private const string DefaultComposerPlaceholder = "Message your local agent session"; + private const string SendInProgressMessage = "Sending message to the local workflow..."; + private const string LocalMemberName = "Local operator"; + private const string LocalMemberSummary = "This desktop instance"; + private readonly IAgentSessionService _agentSessionService; + private readonly AsyncCommand _sendMessageCommand; + private readonly AsyncCommand _startNewSessionCommand; + private readonly AsyncCommand _refreshCommand; + private readonly Dictionary _timelineIndexById = new(StringComparer.Ordinal); + private IReadOnlyList _agents = []; + private SessionSidebarItem? _selectedChat; + private string _title = EmptyTitleValue; + private string _statusSummary = EmptyStatusValue; + private string _composerText = string.Empty; + private string _feedbackMessage = string.Empty; + + public MainViewModel(IAgentSessionService agentSessionService) { - try - { - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainViewModel constructor started."); - ArgumentNullException.ThrowIfNull(workbenchCatalog); - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - - Snapshot = workbenchCatalog.GetSnapshot(); - BrowserConsoleDiagnostics.Info( - $"[DotPilot.Startup] MainViewModel workbench snapshot loaded. Nodes={Snapshot.RepositoryNodes.Count}, Documents={Snapshot.Documents.Count}."); - RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); - BrowserConsoleDiagnostics.Info( - $"[DotPilot.Startup] MainViewModel runtime foundation snapshot loaded. Providers={RuntimeFoundation.Providers.Count}."); - EpicLabel = WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.DesktopWorkbenchEpic); - _documentsByPath = Snapshot.Documents.ToFrozenDictionary(document => document.RelativePath, StringComparer.OrdinalIgnoreCase); - _allRepositoryNodes = Snapshot.RepositoryNodes - .Select(MapRepositoryNode) - .ToArray(); - _filteredRepositoryNodes = _allRepositoryNodes; - - _selectedDocument = Snapshot.Documents.Count > 0 ? Snapshot.Documents[0] : null; - _editablePreviewContent = _selectedDocument?.PreviewContent ?? DefaultPreviewContent; - - var initialNode = _selectedDocument is null - ? FindFirstOpenableNode(_allRepositoryNodes) - : FindNodeByRelativePath(_allRepositoryNodes, _selectedDocument.RelativePath); - - if (initialNode is not null) - { - SetSelectedRepositoryNode(initialNode); - } - - BrowserConsoleDiagnostics.Info("[DotPilot.Startup] MainViewModel constructor completed."); - } - catch (Exception exception) - { - BrowserConsoleDiagnostics.Error($"[DotPilot.Startup] MainViewModel constructor failed: {exception}"); - throw; - } - } + _agentSessionService = agentSessionService; + RecentChats = []; + Messages = []; + Members = [new ParticipantItem(LocalMemberName, LocalMemberSummary, "L", DesignBrushPalette.UserAvatarBrush)]; + Agents = []; - public WorkbenchSnapshot Snapshot { get; } + _sendMessageCommand = new AsyncCommand(SendMessageAsync, CanSendMessage); + _startNewSessionCommand = new AsyncCommand(StartNewSessionAsync, CanStartNewSession); + _refreshCommand = new AsyncCommand(LoadWorkspaceAsync); - public RuntimeFoundationSnapshot RuntimeFoundation { get; } + _ = LoadWorkspaceAsync(); + } - public string EpicLabel { get; } + public ObservableCollection RecentChats { get; } - public string PageTitle => Snapshot.SessionTitle; + public ObservableCollection Messages { get; } - public string WorkspaceName => Snapshot.WorkspaceName; + public ObservableCollection Members { get; } - public string WorkspaceRoot => Snapshot.WorkspaceRoot; + public ObservableCollection Agents { get; } - public string SearchPlaceholder => Snapshot.SearchPlaceholder; + public ICommand SendMessageCommand => _sendMessageCommand; - public string SessionStage => Snapshot.SessionStage; + public ICommand StartNewSessionCommand => _startNewSessionCommand; - public string SessionSummary => Snapshot.SessionSummary; + public ICommand RefreshCommand => _refreshCommand; - public IReadOnlyList SessionEntries => Snapshot.SessionEntries; + public string Title + { + get => _title; + private set => SetProperty(ref _title, value); + } - public IReadOnlyList Artifacts => Snapshot.Artifacts; + public string StatusSummary + { + get => _statusSummary; + private set => SetProperty(ref _statusSummary, value); + } - public IReadOnlyList Logs => Snapshot.Logs; + public string ComposerPlaceholder => DefaultComposerPlaceholder; - public IReadOnlyList FilteredRepositoryNodes + public string ComposerText { - get => _filteredRepositoryNodes; - private set + get => _composerText; + set { - if (ReferenceEquals(_filteredRepositoryNodes, value)) + if (!SetProperty(ref _composerText, value)) { return; } - _filteredRepositoryNodes = value; - RaisePropertyChanged(); - RaisePropertyChanged(nameof(RepositoryResultSummary)); + _sendMessageCommand.RaiseCanExecuteChanged(); } } - public string RepositoryResultSummary => $"{FilteredRepositoryNodes.Count} items"; + public string FeedbackMessage + { + get => _feedbackMessage; + private set => SetProperty(ref _feedbackMessage, value); + } - public string RepositorySearchText + public bool HasActiveSession => _selectedChat is not null; + + public bool HasAgents => _agents.Count > 0; + + public SessionSidebarItem? SelectedChat { - get => _repositorySearchText; + get => _selectedChat; set { - if (!SetProperty(ref _repositorySearchText, value)) + if (!SetProperty(ref _selectedChat, value)) { return; } - UpdateFilteredRepositoryNodes(); + RaisePropertyChanged(nameof(HasActiveSession)); + _ = LoadSelectedSessionAsync(); } } - public WorkbenchRepositoryNodeItem? SelectedRepositoryNode + private async Task LoadWorkspaceAsync() { - get => _selectedRepositoryNode; - set => SetSelectedRepositoryNode(value); - } - - public string SelectedDocumentTitle => _selectedDocument?.Title ?? DefaultDocumentTitle; - - public string SelectedDocumentPath => _selectedDocument?.RelativePath ?? DefaultDocumentPath; - - public string SelectedDocumentStatus => _selectedDocument?.StatusSummary ?? DefaultDocumentStatus; + try + { + var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); + _agents = workspace.Agents; + RebuildRecentChats(workspace.Sessions); + _startNewSessionCommand.RaiseCanExecuteChanged(); + _sendMessageCommand.RaiseCanExecuteChanged(); - public string SelectedDocumentLanguage => _selectedDocument?.LanguageLabel ?? DefaultLanguageLabel; + if (workspace.SelectedSessionId is { } selectedSessionId) + { + SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == selectedSessionId); + } + else if (RecentChats.Count > 0) + { + SelectedChat = RecentChats[0]; + } + else + { + ClearTimeline(); + RebuildAgentPanel([]); + Title = EmptyTitleValue; + StatusSummary = HasAgents + ? "Start a new session from the sidebar or send the first message." + : EmptyStatusValue; + } + } + catch (Exception exception) + { + FeedbackMessage = exception.Message; + } + } - public string SelectedDocumentRenderer => _selectedDocument?.RendererLabel ?? DefaultRendererLabel; + private async Task LoadSelectedSessionAsync() + { + if (_selectedChat is null) + { + ClearTimeline(); + RebuildAgentPanel([]); + Title = EmptyTitleValue; + StatusSummary = HasAgents + ? "Start a new session from the sidebar or send the first message." + : EmptyStatusValue; + _sendMessageCommand.RaiseCanExecuteChanged(); + return; + } - public bool SelectedDocumentIsReadOnly => _selectedDocument?.IsReadOnly ?? true; + var snapshot = await _agentSessionService.GetSessionAsync(_selectedChat.Id, CancellationToken.None); + if (snapshot is null) + { + return; + } - public IReadOnlyList SelectedDocumentDiffLines => _selectedDocument?.DiffLines ?? []; + Title = snapshot.Session.Title; + StatusSummary = $"{snapshot.Session.PrimaryAgentName} · {snapshot.Session.ProviderDisplayName}"; + RebuildTimeline(snapshot.Entries); + RebuildAgentPanel(snapshot.Participants); + } - public string EditablePreviewContent + private async Task StartNewSessionAsync() { - get => _editablePreviewContent; - set => SetProperty(ref _editablePreviewContent, value); + if (_agents.Count == 0) + { + FeedbackMessage = "Create at least one agent before starting a session."; + return; + } + + var agent = _agents[0]; + var session = await _agentSessionService.CreateSessionAsync( + new CreateSessionCommand($"Session with {agent.Name}", agent.Id), + CancellationToken.None); + await LoadWorkspaceAsync(); + SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == session.Session.Id); } - public bool IsDiffReviewMode + private async Task SendMessageAsync() { - get => _isDiffReviewMode; - set + var message = ComposerText.Trim(); + if (string.IsNullOrWhiteSpace(message)) { - if (!SetProperty(ref _isDiffReviewMode, value)) - { - return; - } - - RaisePropertyChanged(nameof(IsPreviewMode)); + return; } - } - public bool IsPreviewMode => !IsDiffReviewMode; + ComposerText = string.Empty; + FeedbackMessage = SendInProgressMessage; - public bool IsLogConsoleVisible - { - get => _isLogConsoleVisible; - set + try { - if (!SetProperty(ref _isLogConsoleVisible, value)) + if (SelectedChat is null) { - return; + await StartNewSessionAsync(); + if (SelectedChat is null) + { + return; + } + } + + await foreach (var entry in _agentSessionService.SendMessageAsync( + new SendSessionMessageCommand(SelectedChat.Id, message), + CancellationToken.None)) + { + ApplyTimelineEntry(entry); } - RaisePropertyChanged(nameof(IsArtifactsVisible)); - RaisePropertyChanged(nameof(InspectorTitle)); - RaisePropertyChanged(nameof(InspectorSummary)); + await LoadWorkspaceAsync(); + SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == SelectedChat?.Id); + FeedbackMessage = string.Empty; + } + catch (Exception exception) + { + FeedbackMessage = exception.Message; } } - public bool IsArtifactsVisible => !IsLogConsoleVisible; - - public string InspectorTitle => IsLogConsoleVisible ? DefaultInspectorLogsTitle : DefaultInspectorArtifactsTitle; - - public string InspectorSummary => IsLogConsoleVisible ? DefaultInspectorLogsSummary : DefaultInspectorArtifactsSummary; + private bool CanSendMessage() + { + return !string.IsNullOrWhiteSpace(ComposerText) && HasAgents; + } - private static WorkbenchRepositoryNodeItem MapRepositoryNode(WorkbenchRepositoryNode node) + private bool CanStartNewSession() { - var kindGlyph = node.IsDirectory ? "▾" : "•"; - var indentMargin = new Thickness(node.Depth * IndentSize, 0d, 0d, 0d); - var automationId = PresentationAutomationIds.RepositoryNode(node.RelativePath); - var tapAutomationId = PresentationAutomationIds.RepositoryNodeTap(node.RelativePath); - - return new( - node.RelativePath, - node.Name, - node.DisplayLabel, - node.IsDirectory, - node.CanOpen, - kindGlyph, - indentMargin, - automationId, - tapAutomationId); + return HasAgents; } - private void UpdateFilteredRepositoryNodes() + private void RebuildRecentChats(IReadOnlyList sessions) { - var searchTerms = RepositorySearchText.Split(' ', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries); - FilteredRepositoryNodes = searchTerms.Length is 0 - ? _allRepositoryNodes - : _allRepositoryNodes - .Where(node => searchTerms.All(term => - node.DisplayLabel.Contains(term, StringComparison.OrdinalIgnoreCase) || - node.Name.Contains(term, StringComparison.OrdinalIgnoreCase))) - .ToArray(); - - if (_selectedRepositoryNode is null || - !FilteredRepositoryNodes.Contains(_selectedRepositoryNode)) + RecentChats.Clear(); + foreach (var session in sessions) { - SetSelectedRepositoryNode(FindFirstOpenableNode(FilteredRepositoryNodes)); + RecentChats.Add(new SessionSidebarItem(session.Id, session.Title, session.Preview)); } } - private static WorkbenchRepositoryNodeItem? FindFirstOpenableNode(IReadOnlyList nodes) + private void RebuildTimeline(IReadOnlyList entries) { - for (var index = 0; index < nodes.Count; index++) + Messages.Clear(); + _timelineIndexById.Clear(); + foreach (var entry in entries) { - if (nodes[index].CanOpen) - { - return nodes[index]; - } + ApplyTimelineEntry(entry); } + } - return nodes.Count > 0 ? nodes[0] : null; + private void ClearTimeline() + { + Messages.Clear(); + _timelineIndexById.Clear(); } - private static WorkbenchRepositoryNodeItem? FindNodeByRelativePath( - IReadOnlyList nodes, - string relativePath) + private void ApplyTimelineEntry(SessionStreamEntry entry) { - for (var index = 0; index < nodes.Count; index++) + var timelineItem = MapTimelineItem(entry); + if (_timelineIndexById.TryGetValue(entry.Id, out var existingIndex)) { - if (nodes[index].RelativePath.Equals(relativePath, StringComparison.OrdinalIgnoreCase)) - { - return nodes[index]; - } + Messages[existingIndex] = timelineItem; + return; } - return null; + _timelineIndexById[entry.Id] = Messages.Count; + Messages.Add(timelineItem); } - private void SetSelectedRepositoryNode(WorkbenchRepositoryNodeItem? value) + private void RebuildAgentPanel(IReadOnlyList agents) { - if (!SetProperty(ref _selectedRepositoryNode, value, nameof(SelectedRepositoryNode))) + Agents.Clear(); + foreach (var agent in agents) { - return; + Agents.Add( + new ParticipantItem( + agent.Name, + $"{agent.ProviderDisplayName} · {agent.ModelName}", + GetInitial(agent.Name), + ResolveAgentBrush(agent.ProviderKind), + agent.Role.ToString(), + DesignBrushPalette.BadgeSurfaceBrush)); } + } + + private static ChatTimelineItem MapTimelineItem(SessionStreamEntry entry) + { + var isCurrentUser = entry.Kind == SessionStreamEntryKind.UserMessage; + var author = entry.Author; + var initial = GetInitial(author); + var avatarBrush = ResolveTimelineBrush(entry); + var accentLabel = entry.AccentLabel; + + return new ChatTimelineItem( + entry.Id, + entry.Kind, + author, + entry.Timestamp.ToLocalTime().ToString("HH:mm", CultureInfo.InvariantCulture), + entry.Text, + initial, + avatarBrush, + isCurrentUser, + accentLabel); + } + + private static string GetInitial(string value) + { + return string.IsNullOrWhiteSpace(value) + ? "?" + : value.Trim()[0].ToString(CultureInfo.InvariantCulture).ToUpperInvariant(); + } - if (value?.CanOpen != true || - !_documentsByPath.TryGetValue(value.RelativePath, out var selectedDocument)) + private static Brush? ResolveTimelineBrush(SessionStreamEntry entry) + { + return entry.Kind switch { - return; - } + SessionStreamEntryKind.UserMessage => DesignBrushPalette.UserAvatarBrush, + SessionStreamEntryKind.ToolStarted or SessionStreamEntryKind.ToolCompleted => DesignBrushPalette.AnalyticsAvatarBrush, + SessionStreamEntryKind.Status => DesignBrushPalette.AvatarVariantEmilyBrush, + SessionStreamEntryKind.Error => DesignBrushPalette.AvatarVariantFrankBrush, + _ => DesignBrushPalette.CodeAvatarBrush, + }; + } - _selectedDocument = selectedDocument; - EditablePreviewContent = selectedDocument.PreviewContent; - RaisePropertyChanged(nameof(SelectedDocumentTitle)); - RaisePropertyChanged(nameof(SelectedDocumentPath)); - RaisePropertyChanged(nameof(SelectedDocumentStatus)); - RaisePropertyChanged(nameof(SelectedDocumentLanguage)); - RaisePropertyChanged(nameof(SelectedDocumentRenderer)); - RaisePropertyChanged(nameof(SelectedDocumentIsReadOnly)); - RaisePropertyChanged(nameof(SelectedDocumentDiffLines)); + private static Brush? ResolveAgentBrush(AgentProviderKind providerKind) + { + return providerKind switch + { + AgentProviderKind.Debug => DesignBrushPalette.DesignAvatarBrush, + AgentProviderKind.Codex => DesignBrushPalette.CodeAvatarBrush, + AgentProviderKind.ClaudeCode => DesignBrushPalette.AnalyticsAvatarBrush, + AgentProviderKind.GitHubCopilot => DesignBrushPalette.AvatarVariantDanishBrush, + _ => DesignBrushPalette.CodeAvatarBrush, + }; } } diff --git a/DotPilot/Presentation/SecondPage.xaml b/DotPilot/Presentation/SecondPage.xaml index bf8e211..8e0f24b 100644 --- a/DotPilot/Presentation/SecondPage.xaml +++ b/DotPilot/Presentation/SecondPage.xaml @@ -1,9 +1,7 @@ @@ -40,56 +38,15 @@ - - - - - - - - - - - - - - - - - - + + + + _roleOptions; + private readonly ObservableCollection _capabilityOptions; + private readonly ObservableCollection _providerOptions; + private string _agentName = "Debug Agent"; + private string _modelName = "debug-echo"; + private string _systemPrompt = + "You are a helpful local desktop agent. Be explicit about what you are doing and stream visible progress."; + private string _statusMessage = "Loading provider readiness..."; + private AgentProviderOption? _selectedProvider; + + public SecondViewModel(IAgentSessionService agentSessionService) + { + _agentSessionService = agentSessionService; + _providerOptions = []; + _roleOptions = + [ + new RoleOption("Assistant", AgentRoleKind.Operator, true), + new RoleOption("Coder", AgentRoleKind.Coding, false), + new RoleOption("Researcher", AgentRoleKind.Research, false), + new RoleOption("Reviewer", AgentRoleKind.Reviewer, false), + ]; + _capabilityOptions = + [ + new CapabilityOption("Web", "Web research and browsing workflows.", true), + new CapabilityOption("Shell", "Terminal-style command execution.", true), + new CapabilityOption("Git", "Repository status, diff, and branch operations.", true), + new CapabilityOption("Files", "Read and update local files.", true), + ]; + + _createAgentCommand = new AsyncCommand(CreateAgentAsync); + _ = LoadProvidersAsync(); + } + + public ObservableCollection Providers => _providerOptions; + + public ObservableCollection Roles => _roleOptions; + + public ObservableCollection Capabilities => _capabilityOptions; + + public ICommand CreateAgentCommand => _createAgentCommand; + + public string PageTitle => "Create agent"; + + public string PageSubtitle => "Choose a provider, role, model, and capabilities for a local agent profile."; + + public string AgentName { - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); + get => _agentName; + set + { + if (!SetProperty(ref _agentName, value)) + { + return; + } + + _createAgentCommand.RaiseCanExecuteChanged(); + } + } + + public string ModelName + { + get => _modelName; + set + { + if (!SetProperty(ref _modelName, value)) + { + return; + } + + _createAgentCommand.RaiseCanExecuteChanged(); + } + } + + public string SystemPrompt + { + get => _systemPrompt; + set => SetProperty(ref _systemPrompt, value); + } + + public string StatusMessage + { + get => _statusMessage; + private set => SetProperty(ref _statusMessage, value); } - public string PageTitle { get; } = "Create New Agent"; - - public string PageSubtitle { get; } = "Configure your AI agent's capabilities, model, and behavior"; - - public RuntimeFoundationSnapshot RuntimeFoundation { get; } - - public string SystemPrompt { get; } = - """ - You are a helpful AI assistant. Your role is to... - - Key behaviors: - • Be concise, clear, and accurate - • Ask clarifying questions when requirements are ambiguous - • Always cite sources when providing facts or statistics - • Format responses using markdown when appropriate - """; - - public string TokenSummary { get; } = "0 / 4,096 tokens"; - - public IReadOnlyList ExistingAgents { get; } = - [ - new("Design Agent", "Claude 3.5 · v1.0", "D", DesignBrushPalette.DesignAvatarBrush), - new("Code Agent", "GPT-4o", "C", DesignBrushPalette.CodeAvatarBrush), - new("Analytics Agent", "Claude 3.5 · v1.0", "A", DesignBrushPalette.AnalyticsAvatarBrush), - ]; - - public IReadOnlyList AgentTypes { get; } = - [ - new("Assistant", true), - new("Analyst", false), - new("Executor", false), - new("Orchestrator", false), - ]; - - public IReadOnlyList AvatarOptions { get; } = - [ - new("A", DesignBrushPalette.DesignAvatarBrush), - new("B", DesignBrushPalette.CodeAvatarBrush), - new("C", DesignBrushPalette.AnalyticsAvatarBrush), - new("D", DesignBrushPalette.AvatarVariantDanishBrush), - new("E", DesignBrushPalette.AvatarVariantEmilyBrush), - new("F", DesignBrushPalette.AvatarVariantFrankBrush), - ]; - - public IReadOnlyList PromptTemplates { get; } = - [ - "Research assistant", - "Customer support specialist", - "Code review expert", - ]; - - public IReadOnlyList Skills { get; } = - [ - new("Web Search", "Search the internet for current information and news", "⌘", true), - new("Code Execution", "Run Python, JavaScript, and shell scripts in sandbox", "", true), - new("File Analysis", "Read, parse, and summarize uploaded documents", "▣", false), - new("Database Access", "Query and modify SQL/NoSQL database records", "◫", false), - new("API Calls", "Connect to external REST APIs and webhooks", "⇄", true), - ]; + public AgentProviderOption? SelectedProvider + { + get => _selectedProvider; + set + { + if (!SetProperty(ref _selectedProvider, value)) + { + return; + } + + if (value is not null) + { + ModelName = string.IsNullOrWhiteSpace(value.InstalledVersion) + ? ResolveDefaultModel(value.Kind) + : ModelName; + + StatusMessage = value.CanCreateAgents + ? $"Ready to create an agent with {value.DisplayName}." + : value.StatusSummary; + } + + _createAgentCommand.RaiseCanExecuteChanged(); + } + } + + private async Task LoadProvidersAsync() + { + var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); + _providerOptions.Clear(); + + foreach (var provider in workspace.Providers) + { + _providerOptions.Add( + new AgentProviderOption( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.InstalledVersion, + provider.CanCreateAgents)); + } + + SelectedProvider = _providerOptions.FirstOrDefault(option => option.CanCreateAgents) ?? + _providerOptions.FirstOrDefault(); + } + + private async Task CreateAgentAsync() + { + if (SelectedProvider is null) + { + StatusMessage = "Wait for provider readiness before creating an agent."; + return; + } + + if (!SelectedProvider.CanCreateAgents) + { + StatusMessage = SelectedProvider.StatusSummary; + return; + } + + if (string.IsNullOrWhiteSpace(AgentName) || string.IsNullOrWhiteSpace(ModelName)) + { + StatusMessage = AgentValidationMessage; + return; + } + + StatusMessage = AgentCreationProgressMessage; + + try + { + var created = await _agentSessionService.CreateAgentAsync( + new CreateAgentProfileCommand( + AgentName.Trim(), + ResolveSelectedRole(), + SelectedProvider.Kind, + ModelName.Trim(), + SystemPrompt.Trim(), + _capabilityOptions + .Where(option => option.IsEnabled) + .Select(option => option.Name) + .ToArray()), + CancellationToken.None); + + StatusMessage = $"Created {created.Name} using {created.ProviderDisplayName}."; + AgentName = $"{created.Name} Copy"; + } + catch (Exception exception) + { + StatusMessage = exception.Message; + } + } + + private AgentRoleKind ResolveSelectedRole() + { + return _roleOptions.FirstOrDefault(option => option.IsSelected)?.Role ?? AgentRoleKind.Operator; + } + + private static string ResolveDefaultModel(AgentProviderKind kind) + { + return kind switch + { + AgentProviderKind.Codex => "gpt-5", + AgentProviderKind.ClaudeCode => "claude-sonnet-4-5", + AgentProviderKind.GitHubCopilot => "gpt-5", + _ => "debug-echo", + }; + } } diff --git a/DotPilot/Presentation/SettingsViewModel.cs b/DotPilot/Presentation/SettingsViewModel.cs index 2afd493..8341d05 100644 --- a/DotPilot/Presentation/SettingsViewModel.cs +++ b/DotPilot/Presentation/SettingsViewModel.cs @@ -1,122 +1,156 @@ -using DotPilot.Core.Features.ToolchainCenter; +using System.Collections.ObjectModel; +using DotPilot.Core.Features.AgentSessions; +using Windows.ApplicationModel.DataTransfer; namespace DotPilot.Presentation; public sealed class SettingsViewModel : ObservableObject { - private const string PageTitleValue = "Unified settings shell"; - private const string PageSubtitleValue = - "Toolchains, provider readiness, policies, and storage stay visible from one operator-oriented surface."; - private const string DefaultCategoryTitle = "Select a settings category"; - private const string DefaultCategorySummary = "Choose a category to inspect its current entries."; - private const string ToolchainProviderSummaryFormat = "{0} ready • {1} need attention"; - private static readonly System.Text.CompositeFormat ToolchainProviderSummaryCompositeFormat = - System.Text.CompositeFormat.Parse(ToolchainProviderSummaryFormat); - - private WorkbenchSettingsCategoryItem? _selectedCategory; - private ToolchainProviderItem? _selectedToolchainProvider; - - public SettingsViewModel( - IWorkbenchCatalog workbenchCatalog, - IRuntimeFoundationCatalog runtimeFoundationCatalog, - IToolchainCenterCatalog toolchainCenterCatalog) + private readonly IAgentSessionService _agentSessionService; + private readonly AsyncCommand _refreshCommand; + private readonly AsyncCommand _toggleProviderCommand; + private readonly AsyncCommand _providerActionCommand; + private readonly ObservableCollection _providers; + private ProviderStatusItem? _selectedProvider; + private string _statusMessage = string.Empty; + + public SettingsViewModel(IAgentSessionService agentSessionService) { - ArgumentNullException.ThrowIfNull(workbenchCatalog); - ArgumentNullException.ThrowIfNull(runtimeFoundationCatalog); - ArgumentNullException.ThrowIfNull(toolchainCenterCatalog); - - Snapshot = workbenchCatalog.GetSnapshot(); - RuntimeFoundation = runtimeFoundationCatalog.GetSnapshot(); - ToolchainCenter = toolchainCenterCatalog.GetSnapshot(); - Categories = Snapshot.SettingsCategories - .Select(category => new WorkbenchSettingsCategoryItem( - category.Key, - category.Title, - category.Summary, - PresentationAutomationIds.SettingsCategory(category.Key), - category.Entries)) - .ToArray(); - ToolchainProviders = ToolchainCenter.Providers - .Select(provider => new ToolchainProviderItem( - provider, - PresentationAutomationIds.ToolchainProvider(provider.Provider.CommandName))) - .ToArray(); - ToolchainWorkstreams = ToolchainCenter.Workstreams - .Select(workstream => new ToolchainWorkstreamItem( - workstream, - PresentationAutomationIds.ToolchainWorkstream(workstream.IssueNumber))) - .ToArray(); - _selectedCategory = Categories.FirstOrDefault(category => category.Key == WorkbenchSettingsCategoryKeys.Toolchains) ?? - (Categories.Count > 0 ? Categories[0] : null); - _selectedToolchainProvider = ToolchainProviders.Count > 0 ? ToolchainProviders[0] : null; - SettingsIssueLabel = WorkbenchIssues.FormatIssueLabel(WorkbenchIssues.SettingsShell); + _agentSessionService = agentSessionService; + _providers = []; + _refreshCommand = new AsyncCommand(LoadProvidersAsync); + _toggleProviderCommand = new AsyncCommand(ToggleSelectedProviderAsync, CanToggleSelectedProvider); + _providerActionCommand = new AsyncCommand(ExecuteProviderActionAsync, CanExecuteProviderAction); + _ = LoadProvidersAsync(); } - public WorkbenchSnapshot Snapshot { get; } + public ObservableCollection Providers => _providers; - public RuntimeFoundationSnapshot RuntimeFoundation { get; } + public ICommand RefreshCommand => _refreshCommand; - public ToolchainCenterSnapshot ToolchainCenter { get; } + public ICommand ToggleProviderCommand => _toggleProviderCommand; - public string SettingsIssueLabel { get; } + public ICommand ProviderActionCommand => _providerActionCommand; - public string PageTitle => PageTitleValue; + public string PageTitle => "Providers"; - public string PageSubtitle => PageSubtitleValue; + public string PageSubtitle => "Enable the built-in debug client or connect local CLI providers before creating agents."; - public IReadOnlyList Categories { get; } - - public IReadOnlyList ToolchainProviders { get; } - - public IReadOnlyList ToolchainWorkstreams { get; } - - public WorkbenchSettingsCategoryItem? SelectedCategory + public ProviderStatusItem? SelectedProvider { - get => _selectedCategory; + get => _selectedProvider; set { - if (!SetProperty(ref _selectedCategory, value)) + if (!SetProperty(ref _selectedProvider, value)) { return; } - RaisePropertyChanged(nameof(SelectedCategoryTitle)); - RaisePropertyChanged(nameof(SelectedCategorySummary)); - RaisePropertyChanged(nameof(VisibleEntries)); - RaisePropertyChanged(nameof(IsToolchainCenterVisible)); - RaisePropertyChanged(nameof(AreGenericSettingsVisible)); + RaisePropertyChanged(nameof(SelectedProviderTitle)); + RaisePropertyChanged(nameof(SelectedProviderSummary)); + RaisePropertyChanged(nameof(SelectedProviderInstallCommand)); + RaisePropertyChanged(nameof(SelectedProviderActions)); + RaisePropertyChanged(nameof(HasSelectedProviderActions)); + RaisePropertyChanged(nameof(ToggleActionLabel)); + _toggleProviderCommand.RaiseCanExecuteChanged(); + _providerActionCommand.RaiseCanExecuteChanged(); } } - public ToolchainProviderItem? SelectedToolchainProvider + public string SelectedProviderTitle => SelectedProvider?.DisplayName ?? "Select a provider"; + + public string SelectedProviderSummary => SelectedProvider?.StatusSummary ?? "Choose a provider to inspect readiness and install guidance."; + + public string SelectedProviderInstallCommand => SelectedProvider?.InstallCommand ?? string.Empty; + + public IReadOnlyList SelectedProviderActions => SelectedProvider?.Actions ?? []; + + public bool HasSelectedProviderActions => SelectedProviderActions.Count > 0; + + public string ToggleActionLabel => SelectedProvider?.IsEnabled == true ? "Disable provider" : "Enable provider"; + + public string StatusMessage { - get => _selectedToolchainProvider; - set - { - if (!SetProperty(ref _selectedToolchainProvider, value)) - { - return; - } + get => _statusMessage; + private set => SetProperty(ref _statusMessage, value); + } - RaisePropertyChanged(nameof(SelectedToolchainProviderSnapshot)); + private async Task LoadProvidersAsync() + { + var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); + _providers.Clear(); + + foreach (var provider in workspace.Providers) + { + _providers.Add( + new ProviderStatusItem( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.InstalledVersion, + provider.IsEnabled, + provider.CanCreateAgents, + provider.Actions.Select(action => action.Command).FirstOrDefault(command => !string.IsNullOrWhiteSpace(command)) ?? string.Empty, + provider.Actions + .Select(action => new ProviderActionItem(action.Label, action.Summary, action.Command)) + .ToArray())); } + + SelectedProvider = _providers.FirstOrDefault(provider => provider.IsEnabled) ?? + _providers.FirstOrDefault(); } - public string SelectedCategoryTitle => SelectedCategory?.Title ?? DefaultCategoryTitle; + private async Task ToggleSelectedProviderAsync() + { + if (SelectedProvider is null) + { + return; + } - public string SelectedCategorySummary => SelectedCategory?.Summary ?? DefaultCategorySummary; + await _agentSessionService.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(SelectedProvider.Kind, !SelectedProvider.IsEnabled), + CancellationToken.None); + await LoadProvidersAsync(); + StatusMessage = $"{SelectedProviderTitle} updated."; + } - public bool IsToolchainCenterVisible => SelectedCategory?.Key == WorkbenchSettingsCategoryKeys.Toolchains; + private bool CanToggleSelectedProvider() + { + return SelectedProvider is not null; + } - public bool AreGenericSettingsVisible => !IsToolchainCenterVisible; + private Task ExecuteProviderActionAsync(object? parameter) + { + if (parameter is not ProviderActionItem action) + { + return Task.CompletedTask; + } - public IReadOnlyList VisibleEntries => SelectedCategory?.Entries ?? []; + if (string.IsNullOrWhiteSpace(action.Command)) + { + StatusMessage = action.Summary; + return Task.CompletedTask; + } - public ToolchainProviderSnapshot? SelectedToolchainProviderSnapshot => SelectedToolchainProvider?.Snapshot; + try + { + var dataPackage = new DataPackage(); + dataPackage.SetText(action.Command); + Clipboard.SetContent(dataPackage); + Clipboard.Flush(); + StatusMessage = $"Copied command: {action.Command}"; + } + catch (Exception) + { + StatusMessage = $"Run this command in your terminal: {action.Command}"; + } - public string ProviderSummary => string.Format( - System.Globalization.CultureInfo.InvariantCulture, - ToolchainProviderSummaryCompositeFormat, - ToolchainCenter.ReadyProviderCount, - ToolchainCenter.AttentionRequiredProviderCount); + return Task.CompletedTask; + } + + private static bool CanExecuteProviderAction(object? parameter) + { + return parameter is ProviderActionItem; + } } diff --git a/DotPilot/Presentation/WorkbenchPresentationModels.cs b/DotPilot/Presentation/WorkbenchPresentationModels.cs deleted file mode 100644 index 85d69a1..0000000 --- a/DotPilot/Presentation/WorkbenchPresentationModels.cs +++ /dev/null @@ -1,38 +0,0 @@ -using DotPilot.Core.Features.ToolchainCenter; - -namespace DotPilot.Presentation; - -public sealed record WorkbenchRepositoryNodeItem( - string RelativePath, - string Name, - string DisplayLabel, - bool IsDirectory, - bool CanOpen, - string KindGlyph, - Thickness IndentMargin, - string AutomationId, - string TapAutomationId); - -public sealed partial record WorkbenchSettingsCategoryItem( - string Key, - string Title, - string Summary, - string AutomationId, - IReadOnlyList Entries); - -public sealed record ToolchainProviderItem( - ToolchainProviderSnapshot Snapshot, - string AutomationId) -{ - public string DisplayName => Snapshot.Provider.DisplayName; - - public string SectionLabel => Snapshot.SectionLabel; - - public string ReadinessLabel => Snapshot.ReadinessState.ToString(); - - public string ReadinessSummary => Snapshot.ReadinessSummary; -} - -public sealed record ToolchainWorkstreamItem( - ToolchainCenterWorkstreamDescriptor Workstream, - string AutomationId); diff --git a/docs/ADR/ADR-0001-agent-control-plane-architecture.md b/docs/ADR/ADR-0001-agent-control-plane-architecture.md index 2de713e..f9664be 100644 --- a/docs/ADR/ADR-0001-agent-control-plane-architecture.md +++ b/docs/ADR/ADR-0001-agent-control-plane-architecture.md @@ -10,7 +10,7 @@ Accepted ## Context -`dotPilot` currently ships as a desktop-first `Uno Platform` shell with a three-pane chat screen and a separate agent-builder view. The repository already expresses the future product IA through this shell, but the application still uses static sample data and has no durable runtime contracts for providers, sessions, agent orchestration, or evaluation. +`dotPilot` currently ships as a desktop-first `Uno Platform` shell with a chat screen, agent-builder view, and provider settings path. The application is moving away from static sample data toward durable runtime contracts for providers, sessions, agent orchestration, and persistence. The approved product direction is broader than a coding-only assistant: @@ -25,9 +25,9 @@ The main architectural choice is how to shape the long-term product platform wit We will treat `dotPilot` as a **local-first desktop agent control plane** with these architectural defaults: -1. The desktop app remains the primary operator surface and keeps the existing left navigation, central chat/session pane, right inspector pane, and agent-builder concepts. +1. The desktop app remains the primary operator surface and is shaped as a session-first chat client with session list, active transcript, streaming activity, provider settings, and agent-builder concepts. 2. The v1 runtime is built around an **embedded Orleans silo** hosted inside the desktop app. -3. Each operator session is modeled as a durable **session grain**, with related grains for workspace, fleet, artifact, and policy state. +3. Each operator session is modeled as a durable **session grain**, and each durable agent profile is modeled as an **agent-profile grain**. 4. **Microsoft Agent Framework** is the preferred orchestration layer for agent sessions, workflows, HITL, MCP-aware tool use, and OpenTelemetry-friendly observability. 5. Provider integrations are **SDK-first**: - `ManagedCode.CodexSharpSDK` @@ -42,10 +42,10 @@ We will treat `dotPilot` as a **local-first desktop agent control plane** with t ```mermaid flowchart LR - Workbench["dotPilot desktop workbench"] + Client["dotPilot desktop chat client"] Silo["Embedded Orleans silo"] Session["Session grains"] - Fleet["Fleet / policy / artifact grains"] + AgentProfiles["Agent-profile grains"] MAF["Microsoft Agent Framework"] Providers["Provider adapters"] Tools["MCPGateway + built-in tools + RagSharp"] @@ -53,9 +53,9 @@ flowchart LR Eval["Microsoft.Extensions.AI.Evaluation*"] OTel["OpenTelemetry-first observability"] - Workbench --> Silo + Client --> Silo Silo --> Session - Silo --> Fleet + Silo --> AgentProfiles Session --> MAF MAF --> Providers MAF --> Tools @@ -72,11 +72,11 @@ Rejected. This would underserve the approved product scope and force future non-coding agent scenarios into an architecture that already assumed the wrong domain boundaries. -### 2. Replace the current Uno shell with a wholly new navigation and workbench concept +### 2. Replace the current Uno shell with a wholly new product layout Rejected. -The current shell already encodes the future product information architecture. Throwing it away would create churn in planning artifacts and disconnect the backlog from the repository’s visible surface. +The current shell already encodes the future chat/session product direction. Throwing it away would create churn in planning artifacts and disconnect the backlog from the repository’s visible surface. ### 3. Use provider-specific process wrappers instead of typed SDKs where SDKs already exist diff --git a/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md b/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md index 2205bc3..999e8dd 100644 --- a/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md +++ b/docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md @@ -33,7 +33,7 @@ We will use these architectural defaults for implementation work going forward: - `DotPilot.Runtime` for provider-independent runtime implementations and future host integration seams - `DotPilot.Runtime.Host` for the embedded Orleans silo and desktop-only runtime-host lifecycle 3. Feature code must be organized as vertical slices under `Features//...`, not as shared horizontal `Services`, `Models`, or `Helpers` buckets. -4. Epic `#11` establishes the shared `ControlPlaneDomain` and `RuntimeCommunication` slices, and epic `#12` builds on that foundation through the `RuntimeFoundation` slice. Issue `#24` is implemented through a desktop-only `DotPilot.Runtime.Host` project that uses localhost clustering plus in-memory storage/reminders before any remote or durable topology is introduced. +4. The active runtime slice is `AgentSessions`, built around provider readiness, durable agent profiles, durable sessions, transcript streaming, and local persistence. `DotPilot.Runtime.Host` stays desktop-only and uses localhost clustering plus in-memory Orleans storage/reminders before any remote or durable topology is introduced. 5. CI-safe agent-flow verification must use a deterministic in-repo runtime client as a first-class implementation of the same public contracts, not a mock or hand-wired test double. 6. Tests that require real `Codex`, `Claude Code`, or `GitHub Copilot` toolchains may run only when the corresponding toolchain is available; their absence must not weaken the provider-independent baseline. @@ -45,9 +45,9 @@ flowchart LR Core["DotPilot.Core"] Runtime["DotPilot.Runtime"] Host["DotPilot.Runtime.Host"] - TestClient["Deterministic test client"] + TestClient["Deterministic debug provider"] ProviderChecks["Conditional provider checks"] - Future["Future Orleans + Agent Framework slices"] + Future["Future multi-agent session slices"] Ui --> Core Ui --> Runtime @@ -86,7 +86,7 @@ CI does not guarantee those toolchains, so the repo would lose an honest agent-f - The Uno app gets cleaner and stays focused on operator-facing concerns. - Future slices can land without merging unrelated feature logic into shared buckets. -- Contracts from epic `#11` become reusable across UI, runtime, and tests before epic `#12` begins live runtime integration. +- Contracts from the shared domain and `AgentSessions` slice become reusable across UI, runtime, and tests before broader live-provider integration expands. - CI keeps a real provider-independent verification path through the deterministic runtime client. - The embedded Orleans host can evolve without leaking server-only dependencies into browserwasm or the presentation project. @@ -99,10 +99,10 @@ CI does not guarantee those toolchains, so the repo would lose an honest agent-f ## Implementation Impact - Add `DotPilot.Core` and `DotPilot.Runtime` with local `AGENTS.md` files. -- Update `docs/Architecture.md` to show the new module map and runtime-foundation slice. -- Surface the runtime-foundation slice in the UI so the new boundary is visible and testable. -- Add API-style tests for contracts and the deterministic client. -- Add UI tests for the runtime-foundation elements and full workbench flow. +- Update `docs/Architecture.md` to show the new module map and `AgentSessions` slice. +- Surface the session/settings/chat flow in the UI so the new boundary is visible and testable. +- Add API-style tests for contracts and the deterministic debug provider. +- Add UI tests for the provider-settings, agent-creation, and streaming session flow. ## References diff --git a/docs/Architecture.md b/docs/Architecture.md index 019239c..13897e1 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -1,27 +1,23 @@ # Architecture Overview -Goal: give humans and agents a fast map of the active `DotPilot` solution, the current `Uno Platform` shell, the foundation contracts from epic `#11`, the workbench foundation for epic `#13`, the Toolchain Center for epic `#14`, and the local-first runtime foundation for epic `#12`. +Goal: give humans and agents a fast map of the shipped `DotPilot` direction: a local-first desktop chat app for agent sessions. This file is the required start-here architecture map for non-trivial tasks. ## Summary -- **System:** `DotPilot` is a `.NET 10` `Uno Platform` desktop-first application that is evolving from a static prototype into a local-first control plane for agent operations. -- **Presentation boundary:** [../DotPilot/](../DotPilot/) is now the presentation host only. It owns XAML, routing, desktop startup, and UI composition, while non-UI feature logic moves into separate DLLs. -- **Workbench boundary:** epic [#13](https://github.com/managedcode/dotPilot/issues/13) is landing as a `Workbench` slice that will provide repository navigation, file inspection, artifact and log inspection, and a unified settings shell without moving that behavior into page code-behind. -- **Toolchain Center boundary:** epic [#14](https://github.com/managedcode/dotPilot/issues/14) now lives as a `ToolchainCenter` slice. [../DotPilot.Core/Features/ToolchainCenter](../DotPilot.Core/Features/ToolchainCenter) defines the readiness, diagnostics, configuration, action, and polling contracts; [../DotPilot.Runtime/Features/ToolchainCenter](../DotPilot.Runtime/Features/ToolchainCenter) probes local provider CLIs for `Codex`, `Claude Code`, and `GitHub Copilot`; the Uno app surfaces the slice through the settings shell. -- **Foundation contract boundary:** epic [#11](https://github.com/managedcode/dotPilot/issues/11) is represented through [../DotPilot.Core/Features/ControlPlaneDomain](../DotPilot.Core/Features/ControlPlaneDomain) and [../DotPilot.Core/Features/RuntimeCommunication](../DotPilot.Core/Features/RuntimeCommunication). These slices define the shared agent/session/tool model and the `ManagedCode.Communication` result/problem language that later runtime work reuses. -- **Runtime foundation boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns issue-aligned contracts, typed identifiers, grain interfaces, traffic-policy snapshots, and session-archive contracts; [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider-independent runtime implementations such as the deterministic turn engine, `Microsoft Agent Framework` orchestration client, and local archive persistence; [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host, explicit grain traffic policy, and initial grain implementations for desktop targets. -- **Domain slice boundary:** issue [#22](https://github.com/managedcode/dotPilot/issues/22) now lives in `DotPilot.Core/Features/ControlPlaneDomain`, which defines the shared agent, session, fleet, provider, runtime, approval, artifact, telemetry, and evaluation model that later slices reuse. -- **Communication slice boundary:** issue [#23](https://github.com/managedcode/dotPilot/issues/23) lives in `DotPilot.Core/Features/RuntimeCommunication`, which defines the shared `ManagedCode.Communication` result/problem language for runtime public boundaries. -- **Runtime-host slice boundary:** epic [#12](https://github.com/managedcode/dotPilot/issues/12) now builds on the epic `#11` foundation contracts through the `RuntimeFoundation` slice, which sequences issues `#22`, `#23`, `#24`, `#25`, `#26`, and `#27` behind a stable contract surface instead of mixing runtime work into the Uno app. -- **Automated verification:** [../DotPilot.Tests/](../DotPilot.Tests/) covers API-style and contract flows through the new DLL boundaries; [../DotPilot.UITests/](../DotPilot.UITests/) covers the visible workbench flow, Toolchain Center, and runtime-foundation UI surface. Provider-independent flows must pass in CI through deterministic or environment-agnostic checks, while provider-specific checks can run only when the matching toolchain is available. +- **Product shape:** `DotPilot` is a desktop chat client for local agent sessions. The default operator flow is: open settings, verify providers, create an agent, start or resume a session, send a message, and watch streaming status/tool output in the transcript. +- **Presentation boundary:** [../DotPilot/](../DotPilot/) is the `Uno Platform` shell only. It owns desktop startup, routes, XAML composition, and visible operator flows such as session list, transcript, agent creation, and provider settings. +- **Contracts boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns the durable non-UI contracts for provider readiness, agent profiles, session lists, transcript entries, commands, and Orleans grain interfaces. +- **Runtime boundary:** [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider catalogs, CLI readiness checks, deterministic debug-provider behavior, `EF Core` + `SQLite` persistence, and the `IAgentSessionService` implementation. +- **Embedded host boundary:** [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host and the grains that represent session and agent-profile state. The first product wave stays local-first with `UseLocalhostClustering` plus in-memory Orleans storage/reminders, while durable product state lives in the local `SQLite` store. +- **Verification boundary:** [../DotPilot.Tests/](../DotPilot.Tests/) covers caller-visible runtime, persistence, contract, and view-model flows through public boundaries. [../DotPilot.UITests/](../DotPilot.UITests/) covers the desktop operator journey from provider setup to streaming chat. ## Scoping -- **In scope for the current repository state:** the Uno workbench shell, the `DotPilot.Core`, `DotPilot.Runtime`, and `DotPilot.Runtime.Host` libraries, the epic `#11` foundation-contract slices, the embedded Orleans host for local desktop runtime state, and the automated validation boundaries around them. -- **In scope for future implementation:** provider adapters, durable persistence beyond the current local session archive, telemetry, evaluation, Git tooling, and local runtimes. -- **Out of scope in the current slice:** remote workers, remote clustering, external durable storage providers, and cloud-only control-plane services. +- **In scope for the active rewrite:** chat-first session UX, provider readiness/settings, agent creation, Orleans-backed session and agent state, local persistence via `SQLite`, deterministic debug provider, transcript/tool streaming, and optional repo/git utilities inside a session. +- **In scope for later slices:** multi-agent sessions, richer workflow composition, provider-specific live execution, session export/replay, and deeper git/worktree utilities. +- **Out of scope in the current repository slice:** remote workers, remote Orleans clustering, cloud persistence, multi-user identity, and external durable stores. ## Diagrams @@ -32,23 +28,15 @@ flowchart LR Root["dotPilot repository root"] Governance["AGENTS.md"] Architecture["docs/Architecture.md"] - Adr1["ADR-0001 control-plane direction"] - Adr3["ADR-0003 vertical slices + UI-only app"] - Feature["agent-control-plane-experience.md"] - Toolchains["toolchain-center.md"] - Ui["DotPilot Uno UI host"] + Ui["DotPilot Uno desktop shell"] Core["DotPilot.Core contracts"] - Runtime["DotPilot.Runtime services"] - Host["DotPilot.Runtime.Host Orleans silo"] + Runtime["DotPilot.Runtime runtime + SQLite"] + Host["DotPilot.Runtime.Host Orleans host + grains"] Unit["DotPilot.Tests"] UiTests["DotPilot.UITests"] Root --> Governance Root --> Architecture - Root --> Adr1 - Root --> Adr3 - Root --> Feature - Root --> Toolchains Root --> Ui Root --> Core Root --> Runtime @@ -58,265 +46,123 @@ flowchart LR Ui --> Core Ui --> Runtime Ui --> Host + Runtime --> Core Host --> Core + Host --> Runtime Unit --> Ui Unit --> Core Unit --> Runtime Unit --> Host + UiTests --> Ui ``` -### Workbench foundation slice for epic #13 +### Operator flow ```mermaid -flowchart TD - Epic["#13 Desktop workbench"] - Shell["#28 Primary workbench shell"] - Tree["#29 Repository tree"] - File["#30 File surface + diff review"] - Dock["#31 Artifact dock + runtime console"] - Settings["#32 Settings shell"] - CoreSlice["DotPilot.Core/Features/Workbench"] - RuntimeSlice["DotPilot.Runtime/Features/Workbench"] - UiSlice["MainPage + SettingsPage + workbench controls"] - - Epic --> Shell - Epic --> Tree - Epic --> File - Epic --> Dock - Epic --> Settings - Shell --> CoreSlice - Tree --> CoreSlice - File --> CoreSlice - Dock --> CoreSlice - Settings --> CoreSlice - CoreSlice --> RuntimeSlice - RuntimeSlice --> UiSlice -``` - -### Toolchain Center slice for epic #14 - -```mermaid -flowchart TD - Epic["#14 Provider toolchain center"] - UiIssue["#33 Toolchain Center UI"] - Codex["#34 Codex readiness"] - Claude["#35 Claude Code readiness"] - Copilot["#36 GitHub Copilot readiness"] - Diagnostics["#37 Connection diagnostics"] - Config["#38 Provider configuration"] - Polling["#39 Background polling"] - CoreSlice["DotPilot.Core/Features/ToolchainCenter"] - RuntimeSlice["DotPilot.Runtime/Features/ToolchainCenter"] - UiSlice["SettingsViewModel + ToolchainCenterPanel"] - - Epic --> UiIssue - Epic --> Codex - Epic --> Claude - Epic --> Copilot - Epic --> Diagnostics - Epic --> Config - Epic --> Polling - UiIssue --> CoreSlice - Codex --> CoreSlice - Claude --> CoreSlice - Copilot --> CoreSlice - Diagnostics --> CoreSlice - Config --> CoreSlice - Polling --> CoreSlice - CoreSlice --> RuntimeSlice - RuntimeSlice --> UiSlice -``` - -### Foundation contract slices for epic #11 - -```mermaid -flowchart TD - Epic["#11 Desktop control-plane foundation"] - Domain["#22 Domain contracts"] - Comm["#23 Communication contracts"] - DomainSlice["DotPilot.Core/Features/ControlPlaneDomain"] - CommunicationSlice["DotPilot.Core/Features/RuntimeCommunication"] - RuntimeContracts["DotPilot.Core/Features/RuntimeFoundation"] - DeterministicClient["DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient"] - Tests["DotPilot.Tests contract coverage"] - - Epic --> Domain - Epic --> Comm - Domain --> DomainSlice - Comm --> CommunicationSlice - DomainSlice --> RuntimeContracts - CommunicationSlice --> RuntimeContracts - CommunicationSlice --> DeterministicClient - DomainSlice --> DeterministicClient - DeterministicClient --> Tests - RuntimeContracts --> Tests +flowchart LR + Settings["Settings"] + Providers["Provider readiness + install actions"] + AgentCreate["Create agent"] + SessionList["Session list"] + Session["Active session"] + Stream["Streaming transcript + status + tool activity"] + Git["Optional repo/git actions"] + + Settings --> Providers + Providers --> AgentCreate + AgentCreate --> SessionList + SessionList --> Session + Session --> Stream + Session --> Git ``` -### Runtime foundation slice for epic #12 +### Runtime flow ```mermaid flowchart TD - Epic["#12 Embedded agent runtime host"] - Foundation["#11 Foundation contracts"] - Domain["#22 Domain contracts"] - Comm["#23 Communication contracts"] - Host["#24 Embedded Orleans host"] - MAF["#25 Agent Framework runtime"] - Policy["#26 Grain traffic policy"] - Sessions["#27 Session persistence and resume"] - DomainSlice["DotPilot.Core/Features/ControlPlaneDomain"] - CommunicationSlice["DotPilot.Core/Features/RuntimeCommunication"] - CoreSlice["DotPilot.Core/Features/RuntimeFoundation"] - RuntimeSlice["DotPilot.Runtime/Features/RuntimeFoundation"] - HostSlice["DotPilot.Runtime.Host/Features/RuntimeFoundation"] - UiSlice["DotPilot runtime panel + banner"] - - Foundation --> Domain - Foundation --> Comm - Domain --> DomainSlice - Comm --> CommunicationSlice - DomainSlice --> CommunicationSlice - CommunicationSlice --> CoreSlice - Epic --> Host - Epic --> MAF - Epic --> Policy - Epic --> Sessions - Host --> HostSlice - Policy --> HostSlice - Policy --> CoreSlice - HostSlice --> CoreSlice - MAF --> RuntimeSlice - Sessions --> RuntimeSlice - Sessions --> CoreSlice - RuntimeSlice --> HostSlice - CoreSlice --> UiSlice - HostSlice --> UiSlice - RuntimeSlice --> UiSlice + Ui["Uno shell"] + ViewModels["Session / agent / settings view models"] + Service["IAgentSessionService"] + Store["EF Core + SQLite"] + SessionGrain["ISessionGrain"] + AgentGrain["IAgentProfileGrain"] + ProviderCatalog["Provider catalog + readiness probe"] + ProviderClient["Provider SDK / IChatClient or debug client"] + Stream["SessionStreamEntry updates"] + + Ui --> ViewModels + ViewModels --> Service + Service --> Store + Service --> SessionGrain + Service --> AgentGrain + Service --> ProviderCatalog + ProviderCatalog --> ProviderClient + Service --> ProviderClient + ProviderClient --> Stream + Stream --> ViewModels ``` -### Current composition flow +### Persistence and resume shape ```mermaid -flowchart LR - App["DotPilot/App.xaml.cs"] - Views["MainPage + SecondPage + SettingsShell + RuntimeFoundationPanel + ToolchainCenterPanel"] - ViewModels["MainViewModel + SecondViewModel + SettingsViewModel"] - Catalog["RuntimeFoundationCatalog"] - Toolchains["ToolchainCenterCatalog"] - BrowserClient["DeterministicAgentRuntimeClient"] - DesktopClient["AgentFrameworkRuntimeClient"] - Archive["RuntimeSessionArchiveStore"] - Traffic["EmbeddedRuntimeTrafficPolicyCatalog"] - ToolchainProbe["ToolchainCommandProbe + provider profiles"] - EmbeddedHost["UseDotPilotEmbeddedRuntime + Orleans silo"] - Contracts["Typed IDs + contracts"] - Grains["Session / Workspace / Fleet / Policy / Artifact grains"] - - App --> ViewModels - Views --> ViewModels - ViewModels --> Catalog - ViewModels --> Toolchains - Catalog --> BrowserClient - Catalog --> DesktopClient - Catalog --> Contracts - Toolchains --> ToolchainProbe - Toolchains --> Contracts - App --> EmbeddedHost - DesktopClient --> Archive - DesktopClient --> EmbeddedHost - EmbeddedHost --> Traffic - EmbeddedHost --> Grains - EmbeddedHost --> Contracts - Traffic --> Contracts +sequenceDiagram + participant UI as Uno UI + participant Service as AgentSessionService + participant DB as SQLite + participant SG as SessionGrain + participant AG as AgentProfileGrain + participant Provider as Provider SDK / Debug Client + + UI->>Service: CreateAgentAsync(...) + Service->>DB: Save agent profile + Service->>AG: UpsertAsync(agent profile) + UI->>Service: CreateSessionAsync(...) + Service->>DB: Save session + initial status entry + Service->>SG: UpsertAsync(session) + UI->>Service: SendMessageAsync(...) + Service->>DB: Save user message + Service->>Provider: Run / stream + Provider-->>Service: Streaming updates + Service->>DB: Persist transcript entries + Service-->>UI: SessionStreamEntry updates ``` ## Navigation Index -### Planning and decision docs +### Planning and governance - `Solution governance` — [../AGENTS.md](../AGENTS.md) -- `Primary architecture decision` — [ADR-0001](./ADR/ADR-0001-agent-control-plane-architecture.md) -- `Vertical-slice solution decision` — [ADR-0003](./ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) -- `Feature spec` — [Agent Control Plane Experience](./Features/agent-control-plane-experience.md) -- `Issue #13 feature doc` — [Workbench Foundation](./Features/workbench-foundation.md) -- `Issue #14 feature doc` — [Toolchain Center](./Features/toolchain-center.md) -- `Issue #22 feature doc` — [Control Plane Domain Model](./Features/control-plane-domain-model.md) -- `Issue #23 feature doc` — [Runtime Communication Contracts](./Features/runtime-communication-contracts.md) -- `Issue #24 feature doc` — [Embedded Orleans Host](./Features/embedded-orleans-host.md) -- `Issues #25-#27 feature doc` — [Embedded Runtime Orchestration](./Features/embedded-runtime-orchestration.md) +- `Uno app rules` — [../DotPilot/AGENTS.md](../DotPilot/AGENTS.md) +- `Core contracts rules` — [../DotPilot.Core/AGENTS.md](../DotPilot.Core/AGENTS.md) +- `Runtime rules` — [../DotPilot.Runtime/AGENTS.md](../DotPilot.Runtime/AGENTS.md) +- `Embedded host rules` — [../DotPilot.Runtime.Host/AGENTS.md](../DotPilot.Runtime.Host/AGENTS.md) +- `Test rules` — [../DotPilot.Tests/AGENTS.md](../DotPilot.Tests/AGENTS.md), [../DotPilot.UITests/AGENTS.md](../DotPilot.UITests/AGENTS.md) ### Modules - `Production Uno app` — [../DotPilot/](../DotPilot/) - `Contracts and typed identifiers` — [../DotPilot.Core/](../DotPilot.Core/) -- `Provider-independent runtime services` — [../DotPilot.Runtime/](../DotPilot.Runtime/) -- `Embedded Orleans runtime host` — [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) -- `Unit and API-style tests` — [../DotPilot.Tests/](../DotPilot.Tests/) +- `Runtime services and provider adapters` — [../DotPilot.Runtime/](../DotPilot.Runtime/) +- `Embedded Orleans host` — [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) +- `Unit and integration-style tests` — [../DotPilot.Tests/](../DotPilot.Tests/) - `UI tests` — [../DotPilot.UITests/](../DotPilot.UITests/) -- `Shared build and analyzer policy` — [../Directory.Build.props](../Directory.Build.props), [../Directory.Packages.props](../Directory.Packages.props), [../global.json](../global.json), and [../.editorconfig](../.editorconfig) ### High-signal code paths -- `Application startup and composition` — [../DotPilot/App.xaml.cs](../DotPilot/App.xaml.cs) -- `Chat workbench view model` — [../DotPilot/Presentation/MainViewModel.cs](../DotPilot/Presentation/MainViewModel.cs) -- `Settings view model` — [../DotPilot/Presentation/SettingsViewModel.cs](../DotPilot/Presentation/SettingsViewModel.cs) -- `Agent builder view model` — [../DotPilot/Presentation/SecondViewModel.cs](../DotPilot/Presentation/SecondViewModel.cs) -- `Toolchain Center panel` — [../DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml](../DotPilot/Presentation/Controls/ToolchainCenterPanel.xaml) -- `Reusable runtime panel` — [../DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml](../DotPilot/Presentation/Controls/RuntimeFoundationPanel.xaml) -- `Toolchain Center contracts` — [../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs](../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterContracts.cs) -- `Toolchain Center issue catalog` — [../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs](../DotPilot.Core/Features/ToolchainCenter/ToolchainCenterIssues.cs) -- `Shell configuration contract` — [../DotPilot.Core/Features/ApplicationShell/AppConfig.cs](../DotPilot.Core/Features/ApplicationShell/AppConfig.cs) -- `Runtime foundation contracts` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationContracts.cs) -- `Embedded runtime host contracts` — [../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeHostContracts.cs) -- `Traffic policy contracts` — [../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyContracts.cs) -- `Session archive contracts` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeSessionArchiveContracts.cs) -- `Runtime communication problems` — [../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs](../DotPilot.Core/Features/RuntimeCommunication/RuntimeCommunicationProblems.cs) -- `Control-plane domain contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/SessionExecutionContracts.cs) -- `Provider and tool contracts` — [../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs](../DotPilot.Core/Features/ControlPlaneDomain/ProviderAndToolContracts.cs) -- `Runtime issue catalog` — [../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs](../DotPilot.Core/Features/RuntimeFoundation/RuntimeFoundationIssues.cs) -- `Toolchain Center catalog implementation` — [../DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs](../DotPilot.Runtime/Features/ToolchainCenter/ToolchainCenterCatalog.cs) -- `Toolchain snapshot factory` — [../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs](../DotPilot.Runtime/Features/ToolchainCenter/ToolchainProviderSnapshotFactory.cs) -- `Runtime catalog implementation` — [../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs](../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeFoundationCatalog.cs) -- `Deterministic test client` — [../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs](../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentRuntimeClient.cs) -- `Agent Framework client` — [../DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs](../DotPilot.Runtime/Features/RuntimeFoundation/AgentFrameworkRuntimeClient.cs) -- `Deterministic turn engine` — [../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs](../DotPilot.Runtime/Features/RuntimeFoundation/DeterministicAgentTurnEngine.cs) -- `Session archive store` — [../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs](../DotPilot.Runtime/Features/RuntimeFoundation/RuntimeSessionArchiveStore.cs) -- `Provider toolchain probing` — [../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs](../DotPilot.Runtime/Features/RuntimeFoundation/ProviderToolchainProbe.cs) -- `Embedded host builder` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeHostBuilderExtensions.cs) -- `Embedded traffic policy` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicy.cs) -- `Embedded traffic-policy catalog` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/EmbeddedRuntimeTrafficPolicyCatalog.cs) -- `Initial Orleans grains` — [../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs](../DotPilot.Runtime.Host/Features/RuntimeFoundation/SessionGrain.cs) - -## Dependency Rules - -- `DotPilot` owns XAML, routing, and startup composition only. -- `DotPilot.Core` owns non-UI contracts and typed identifiers arranged by feature slice. -- `DotPilot.Runtime` owns provider-independent runtime implementations and future integration seams, but not XAML or page logic. -- `DotPilot.Runtime.Host` owns the embedded Orleans silo, localhost clustering, in-memory runtime state, and initial grain implementations for desktop targets only. -- `DotPilot.Tests` validates contracts, composition, deterministic runtime behavior, and conditional provider-availability checks through public boundaries. -- `DotPilot.UITests` validates the visible workbench shell, runtime-foundation panel, and agent-builder flow through the browser-hosted UI. - -## Key Decisions - -- The Uno app must remain a presentation-only host instead of becoming a dump for runtime logic. -- Feature work should land as vertical slices with isolated contracts and implementations, not as shared horizontal folders. -- Epic `#11` establishes the reusable contract and communication foundation before epic `#12` begins embedded runtime-host work. -- Epic `#12` now has a first local-first Orleans host cut in `DotPilot.Runtime.Host`, and it intentionally uses localhost clustering plus in-memory storage/reminders before any remote or durable runtime topology is introduced. -- The desktop runtime path now uses `Microsoft Agent Framework` for orchestration, while the browser path keeps the deterministic in-repo client for CI-safe coverage. -- `#26` currently uses an explicit traffic-policy catalog plus Mermaid graph output instead of `ManagedCode.Orleans.Graph`, because the public `ManagedCode.Orleans.Graph` package is pinned to Orleans `9.x` and is not compatible with this repository's Orleans `10.0.1` baseline. -- Epic `#14` makes external-provider toolchain readiness explicit before session creation, so install, auth, diagnostics, and configuration state stays visible instead of being inferred later. -- CI must stay meaningful without external provider CLIs by using the in-repo deterministic runtime client. -- Real provider checks may run only when the corresponding toolchain is present and discoverable. - -## Known Repository Risks - -- Provider-dependent validation for real `Codex`, `Claude Code`, and `GitHub Copilot` toolchains is intentionally environment-gated; the deterministic runtime client is the mandatory CI baseline for agent-flow verification. - -## Where To Go Next - -- Editing the Uno app shell: [../DotPilot/AGENTS.md](../DotPilot/AGENTS.md) -- Editing contracts: [../DotPilot.Core/AGENTS.md](../DotPilot.Core/AGENTS.md) -- Editing runtime services: [../DotPilot.Runtime/AGENTS.md](../DotPilot.Runtime/AGENTS.md) -- Editing the embedded runtime host: [../DotPilot.Runtime.Host/AGENTS.md](../DotPilot.Runtime.Host/AGENTS.md) -- Editing unit and API-style tests: [../DotPilot.Tests/AGENTS.md](../DotPilot.Tests/AGENTS.md) -- Editing UI tests: [../DotPilot.UITests/AGENTS.md](../DotPilot.UITests/AGENTS.md) +- `Application startup and route registration` — [../DotPilot/App.xaml.cs](../DotPilot/App.xaml.cs) +- `Chat shell route` — [../DotPilot/Presentation/MainPage.xaml](../DotPilot/Presentation/MainPage.xaml) +- `Agent creation route` — [../DotPilot/Presentation/SecondPage.xaml](../DotPilot/Presentation/SecondPage.xaml) +- `Settings shell` — [../DotPilot/Presentation/Controls/SettingsShell.xaml](../DotPilot/Presentation/Controls/SettingsShell.xaml) +- `Active runtime contracts` — [../DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs](../DotPilot.Core/Features/AgentSessions/AgentSessionContracts.cs) +- `Active runtime commands` — [../DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs](../DotPilot.Core/Features/AgentSessions/AgentSessionCommands.cs) +- `Session runtime service` — [../DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs](../DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs) +- `Provider readiness catalog` — [../DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs](../DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs) +- `Session grain` — [../DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs](../DotPilot.Runtime.Host/Features/AgentSessions/SessionGrain.cs) +- `UI end-to-end flow` — [../DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs](../DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs) + +## Review Focus + +- Keep the product framed as a chat-first local-agent client, not as a backlog-shaped workbench. +- Replace seed-data assumptions with real provider, agent, session, and transcript state. +- Keep repo/git operations as optional tools inside a session, not as the app's primary information architecture. +- Prefer provider SDKs and `IChatClient`-style abstractions over custom parallel request/result wrappers unless a concrete gap forces an adapter layer. diff --git a/docs/Features/agent-control-plane-experience.md b/docs/Features/agent-control-plane-experience.md index ee6601a..d056286 100644 --- a/docs/Features/agent-control-plane-experience.md +++ b/docs/Features/agent-control-plane-experience.md @@ -2,205 +2,166 @@ ## Summary -`dotPilot` is a desktop-first control plane for local-first agent operations. It must let an operator manage agent profiles, provider toolchains, sessions, files, tools, approvals, telemetry, evaluation, and local runtimes from one workbench. +`dotPilot` is a desktop-first control plane for local-first agent operations, but its visible product shape is a chat client for sessions. The operator should feel like they are working inside a persistent session with local agents, not bouncing between backlog-shaped product slices. -The product must support coding sessions, but it must not be limited to coding. The same architecture and UI should support research, analysis, orchestration, review, and operator workflows. +The product must support coding sessions, but it must not be limited to coding. The same shell should support research, analysis, orchestration, review, and operator workflows. ## Scope ### In Scope -- desktop workbench shell -- provider toolchain management for `Codex`, `Claude Code`, and `GitHub Copilot` -- session composition for one or many agents -- agent profiles and reusable roles -- repository tree, file viewing, attachments, tool-call visibility, and Git workflows -- MCP/tool federation and repo intelligence -- local runtime selection through `LLamaSharp` and `ONNX Runtime` -- telemetry, evaluation, replay, and policy-aware audit trails +- desktop chat shell with session list, active transcript, and streaming activity +- provider readiness settings for `Codex`, `Claude Code`, `GitHub Copilot`, and the deterministic debug provider +- agent profiles backed by provider SDK or `IChatClient`-style integrations +- Orleans-backed session and agent-profile state +- local persistence through `EF Core` + `SQLite` +- visible tool/status streaming in the transcript +- optional repo/git actions as tools inside a session ### Out Of Scope -- implementing the actual runtime in this task -- replacing the current Uno shell with a different product layout +- cloud orchestration +- remote Orleans clustering +- auto-installing provider CLIs without operator confirmation +- local-model runtime integration beyond the current debug provider - adding `MLXSharp` in the first product wave ## Product Rules -1. `dotPilot` must remain a desktop-first operator workbench, not only a prompt window. -2. The existing shell direction must be preserved: - - left navigation and workspace tree - - central session/chat surface - - right inspector or activity pane - - dedicated agent-builder/profile surface -3. A session must be able to use: - - one provider agent - - many provider agents - - a mixed provider plus local-model composition -4. The operator must be able to see which provider toolchains are installed, authenticated, outdated, misconfigured, or unavailable. -5. A session must expose plan, execute, and review states explicitly. -6. Files, screenshots, logs, diffs, and generated artifacts must be attachable and inspectable from the workbench. -7. Tool calls, approvals, and diffs must never be hidden behind opaque provider output. -8. Git flows must remain in-app for common operations: - - status - - diff - - stage - - commit - - history - - compare - - branch or worktree selection -9. Local runtime support must share the same event and session model as remote provider sessions. -10. Telemetry and evaluation must be first-class: - - OpenTelemetry-first runtime traces - - quality and safety evaluations through `Microsoft.Extensions.AI.Evaluation*` - - replay and export for session history -11. `MLXSharp` must not be planned into the first roadmap wave. -12. GitHub backlog items must describe product capability directly and must not mention blocked competitor names. +1. `dotPilot` must feel like a desktop chat app for local agents, not like a workbench made from backlog slices. +2. Settings are about provider readiness and install guidance, not about separate product centers. +3. A session is the primary container for work and must persist across app restarts. +4. Each agent participating in that experience must have durable identity and configuration outside the UI layer. +5. Provider status must be explicit before live use: + - installed or missing + - enabled or disabled + - ready or blocked + - install/help command visible when blocked +6. Transcript output must show more than assistant text: + - user messages + - assistant messages + - tool start and completion events + - status updates + - error states +7. Repo/git flows are optional tools inside the session experience, not a separate shell. +8. The provider-independent baseline must work through the built-in debug provider so UI tests and CI can always exercise the end-to-end chat flow. ## Primary Operator Flow ```mermaid flowchart LR Open["Open dotPilot"] - Check["Open Toolchain Center"] - Configure["Verify provider versions, auth, settings"] - Profile["Create or edit agent profile"] - Compose["Create session and choose participating agents"] - Work["Browse files, attach context, run plan/execute/review"] - Approve["Approve tool calls, commands, writes, MCP actions"] - Inspect["Inspect diffs, artifacts, telemetry, and evaluations"] - Resume["Pause, resume, branch, or replay session"] - - Open --> Check --> Configure --> Profile --> Compose --> Work --> Approve --> Inspect --> Resume + Settings["Open settings"] + Providers["Verify provider readiness or install guidance"] + Agent["Create agent profile"] + Session["Create or resume session"] + Chat["Send message"] + Stream["Observe streaming transcript and tool activity"] + Continue["Continue session or switch sessions"] + + Open --> Settings --> Providers --> Agent --> Session --> Chat --> Stream --> Continue ``` -## Session Lifecycle +## Session Runtime Flow ```mermaid -stateDiagram-v2 - [*] --> Plan - Plan --> Execute - Execute --> Review - Execute --> Paused - Review --> Execute - Review --> Completed - Execute --> Failed - Paused --> Execute - Failed --> Review +sequenceDiagram + participant Operator + participant UI as Uno UI + participant Service as AgentSessionService + participant DB as SQLite + participant Grain as SessionGrain + participant Provider as Provider SDK / Debug Client + + Operator->>UI: Send message + UI->>Service: SendMessageAsync(...) + Service->>DB: Persist user message + Service->>Provider: Stream response + Provider-->>Service: Assistant/status/tool updates + Service->>DB: Persist transcript entries + Service->>Grain: Upsert session state + Service-->>UI: Stream SessionStreamEntry updates ``` ## Main Behaviour -### Toolchain and Provider Setup +### Provider Setup -- The operator opens the settings or toolchain center. -- The app detects whether each provider is installed and reachable. +- The operator opens settings. +- The app detects whether each provider CLI is installed and available on `PATH`. - The app shows: - - version - - auth status - - health status - - update availability - - configuration errors -- The operator can run a connection test before starting a session. - -### Session Composition - -- The operator starts a new session. -- The operator chooses one or more participating agents. -- Each selected agent can bind to: - - a provider CLI or SDK-backed provider - - a local model runtime -- The operator can pick role templates such as: - - coding - - research - - analyst - - reviewer - - operator - - orchestrator + - current status summary + - installed version when available + - whether agent creation is currently allowed + - an install/help command when setup is missing +- The deterministic debug provider is always available for local verification. + +### Agent Profiles + +- The operator creates an agent profile by selecting: + - provider + - role + - model + - capabilities + - system prompt +- Agent profiles are durable and survive restarts. +- The current shipped flow creates one provider-backed primary agent per session, while the architecture keeps room for later multi-agent expansion. ### Session Execution -- The operator can browse a repo tree and open files inline. -- The operator can attach files, folders, logs, screenshots, and diffs. -- The session surface must show: - - conversation output - - tool calls - - approvals - - diffs - - artifacts - - branch or workspace context - -### Review and Audit - -- The operator can inspect agent-generated changes before accepting them. -- The operator can inspect tool-call history and session events. -- The operator can replay or export the session for later inspection. - -### Telemetry and Evaluation - -- The runtime emits OpenTelemetry-friendly traces, metrics, and logs. -- The operator can inspect a local telemetry view. -- Evaluations can score: - - relevance - - groundedness - - completeness - - task adherence - - tool-call accuracy - - safety metrics where configured +- The operator starts or resumes a session from the chat sidebar. +- Each session has durable transcript history. +- The transcript shows: + - user messages + - assistant output + - status entries + - tool-start entries + - tool-complete entries + - errors +- The composer behaves like a terminal-style message input, with visible progress during send and stream. + +### Repo and Git Actions + +- Repo and git operations can exist as tools invoked inside a session. +- The app only needs the common operator actions in the first wave: + - create repository + - fetch + - pull + - push + - merge + - inspect diffs +- These actions must show up as tool activity or session results, not as a separate product mode. ## Edge and Failure Flows -### Provider Missing or Outdated +### Provider Missing -- If a provider is not installed, the toolchain center must show that state before session creation. -- If a provider is installed but stale, the app must show a warning and available update action. -- If auth is missing, the app must not silently fail during the first live session turn. +- If a provider is not installed, settings must show that state before agent creation. +- The app must expose the suggested install command instead of silently failing later. -### Mixed Session with Partial Availability +### Provider Disabled -- If one selected agent is unavailable, the operator must be told which agent failed and why. -- The operator can remove or replace the failing agent without recreating the entire session conceptually. +- If a provider is disabled, the app must say so explicitly and block agent creation for that provider. -### Approval Pause +### Session Resume -- When a session reaches an approval gate, it must move to a paused state. -- The operator must be able to resume the same session after approval. +- If the app restarts, previously persisted sessions and transcript history must still load from the local store. -### Local Runtime Failure +### Live Provider Not Yet Wired -- If a local runtime is incompatible with the selected model, the operator must see a compatibility error rather than silent degraded behavior. - -### Telemetry or Evaluation Disabled - -- The app must continue to function if optional trace export backends are not configured. -- The app must surface which evaluation metrics are active and which are unavailable in the current environment. +- If a provider is configured but live execution is not implemented yet, the session flow must surface that state as an explicit transcript error entry. ## Verification Strategy -### Documentation and Planning Verification - - `docs/Architecture.md` reflects the same boundaries described here. -- `docs/ADR/ADR-0001-agent-control-plane-architecture.md` records the architectural choice and trade-offs. -- `docs/Features/control-plane-domain-model.md` captures the reusable issue `#22` contract relationships for agents, sessions, fleets, providers, runtimes, approvals, artifacts, telemetry, and evaluations. -- `docs/Features/runtime-communication-contracts.md` captures the shared result/problem language from issue `#23` for runtime public boundaries. -- GitHub issues map back to the capabilities and flows in this spec. - -### Future Product Verification - -- `Uno.UITests` cover the workbench shell, toolchain center, session composition, approvals, and Git flows. -- provider-independent runtime and session tests use an in-repo deterministic test client so CI can validate agent flows without external provider CLIs. -- tests that require real `Codex`, `Claude Code`, or `GitHub Copilot` toolchains run only when the matching toolchain is available in the environment. -- UI tests cover each feature's visible interactive elements plus at least one complete operator flow through the affected surface. -- integration tests cover provider adapters, session persistence, replay, and orchestration flows. -- local runtime smoke tests cover `LLamaSharp` and `ONNX Runtime`. -- evaluation harness tests exercise transcript scoring and regression detection. - -## Definition of Done - -- The repository contains: - - updated governance reflecting the product direction - - updated architecture documentation - - an ADR for the control-plane architecture - - this executable feature spec - - a GitHub issue backlog that tracks the approved roadmap as epics plus child issues -- The issue backlog is detailed enough that implementation can proceed feature by feature without re-inventing the scope. +- `docs/ADR/ADR-0001-agent-control-plane-architecture.md` records the session-first desktop architecture and SDK-first provider direction. +- `docs/ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md` records the presentation-only app boundary and slice layout. +- `DotPilot.Tests` cover provider readiness, agent creation, session creation, and deterministic transcript persistence. +- `DotPilot.UITests` cover the main operator flow: + 1. open app + 2. open settings + 3. enable debug provider + 4. create agent + 5. create session + 6. send message + 7. observe streamed transcript output diff --git a/docs/Features/embedded-orleans-host.md b/docs/Features/embedded-orleans-host.md index 76ae500..7079347 100644 --- a/docs/Features/embedded-orleans-host.md +++ b/docs/Features/embedded-orleans-host.md @@ -2,23 +2,23 @@ ## Summary -Issue [#24](https://github.com/managedcode/dotPilot/issues/24) embeds the first Orleans silo into the desktop runtime path without polluting the Uno UI project or the browserwasm build. The first cut is intentionally local-first: `UseLocalhostClustering`, in-memory grain storage, and in-memory reminders only. +`DotPilot.Runtime.Host` embeds the Orleans silo used by the desktop runtime path without polluting the `Uno` app project or the browserwasm build. The current cut is intentionally local-first: `UseLocalhostClustering`, in-memory grain storage, and in-memory reminders only. ## Scope ### In Scope - a dedicated `DotPilot.Runtime.Host` class library for Orleans hosting -- Orleans grain interfaces and runtime-host contracts in `DotPilot.Core` -- initial Session, Workspace, Fleet, Policy, and Artifact grains +- Orleans grain interfaces for agent profiles and sessions in `DotPilot.Core/Features/AgentSessions` +- `AgentProfileGrain` and `SessionGrain` - desktop startup integration through the Uno host builder - automated tests for lifecycle, grain round-trips, mismatched keys, and in-memory volatility across restarts ### Out Of Scope - remote clusters -- external durable storage providers -- Agent Framework orchestration and session-archive flows beyond the host boundary +- external durable Orleans storage providers +- moving durable transcript persistence into Orleans storage - UI redesign around the runtime host ## Flow @@ -26,45 +26,46 @@ Issue [#24](https://github.com/managedcode/dotPilot/issues/24) embeds the first ```mermaid flowchart LR App["DotPilot/App.xaml.cs"] - HostExt["UseDotPilotEmbeddedRuntime()"] + HostExt["UseDotPilotAgentSessions()"] Silo["Embedded Orleans silo"] Store["In-memory grain storage + reminders"] - Grains["Session / Workspace / Fleet / Policy / Artifact grains"] - Contracts["DotPilot.Core runtime-host contracts"] + Session["SessionGrain"] + Agent["AgentProfileGrain"] + Contracts["DotPilot.Core AgentSessions grain contracts"] App --> HostExt HostExt --> Silo Silo --> Store - Silo --> Grains - Grains --> Contracts + Silo --> Session + Silo --> Agent + Session --> Contracts + Agent --> Contracts ``` ## Design Notes - The app references `DotPilot.Runtime.Host` only on non-browser targets so `DotPilot.UITests` and the browserwasm build do not carry the server-only Orleans host. -- `DotPilot.Core` owns the grain interfaces plus the `EmbeddedRuntimeHostSnapshot` contract. +- `DotPilot.Core` owns the grain interfaces and the durable agent/session descriptors used by those grains. - `DotPilot.Runtime.Host` owns: - Orleans host configuration - - host lifecycle catalog state + - host option names - grain implementations -- Agent Framework orchestration, replay archives, and resume logic live in the sibling runtime slice document: [Embedded Runtime Orchestration](./embedded-runtime-orchestration.md). - The initial cluster configuration is intentionally local: - `UseLocalhostClustering` - named in-memory grain storage - in-memory reminders -- Runtime DTOs used by Orleans grain calls now carry Orleans serializer metadata so the grain contract surface is actually serialization-safe instead of only being plain records. +- Durable transcript history and provider settings live in the sibling runtime slice through `SQLite`; Orleans stores only the in-cluster session and agent grain state for this product wave. ## Verification - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~EmbeddedRuntimeHost` +- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~AgentSessions` - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` - `dotnet test DotPilot.slnx` ## References - [Architecture Overview](../Architecture.md) -- [Embedded Runtime Orchestration](./embedded-runtime-orchestration.md) - [ADR-0003: Keep the Uno App Presentation-Only and Move Feature Work into Vertical-Slice Class Libraries](../ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) - [Local development configuration](https://learn.microsoft.com/dotnet/orleans/host/configuration-guide/local-development-configuration) - [Quickstart: Build your first Orleans app with ASP.NET Core](https://learn.microsoft.com/dotnet/orleans/quickstarts/build-your-first-orleans-app) diff --git a/docs/Features/embedded-runtime-orchestration.md b/docs/Features/embedded-runtime-orchestration.md deleted file mode 100644 index a2976ae..0000000 --- a/docs/Features/embedded-runtime-orchestration.md +++ /dev/null @@ -1,99 +0,0 @@ -# Embedded Runtime Orchestration - -## Summary - -Issues [#25](https://github.com/managedcode/dotPilot/issues/25), [#26](https://github.com/managedcode/dotPilot/issues/26), and [#27](https://github.com/managedcode/dotPilot/issues/27) land as one local-first runtime slice on top of the embedded Orleans desktop host. `DotPilot.Runtime` owns the orchestration client, session archive store, and deterministic turn engine; `DotPilot.Runtime.Host` owns the Orleans grains and the explicit traffic-policy catalog. - -## Scope - -### In Scope - -- `Microsoft Agent Framework` as the preferred local orchestration engine for desktop runtime turns -- explicit traffic-policy visibility for session, workspace, fleet, policy, and artifact grains -- local-first session archive persistence with replay markdown, checkpoint files, and restart-safe resume -- deterministic execution and approval-gated flows that stay testable in CI without external providers - -### Out Of Scope - -- remote Orleans clustering or durable Orleans storage providers -- provider-specific orchestration adapters -- hiding runtime state inside the Uno app project - -## Flow - -```mermaid -flowchart LR - Ui["DotPilot desktop shell"] - Client["AgentFrameworkRuntimeClient"] - Workflow["Microsoft Agent Framework workflow"] - Engine["DeterministicAgentTurnEngine"] - Archive["RuntimeSessionArchiveStore"] - Checkpoints["Checkpoint files + index"] - Host["Embedded Orleans host"] - Policy["EmbeddedRuntimeTrafficPolicyCatalog"] - Grains["Session / Workspace / Fleet / Policy / Artifact grains"] - - Ui --> Client - Client --> Workflow - Workflow --> Engine - Workflow --> Checkpoints - Client --> Archive - Client --> Host - Host --> Policy - Policy --> Grains - Host --> Grains - Archive --> Checkpoints -``` - -## Session Resume Flow - -```mermaid -sequenceDiagram - participant Operator - participant UI as DotPilot UI - participant Runtime as AgentFrameworkRuntimeClient - participant Store as RuntimeSessionArchiveStore - participant MAF as Agent Framework - participant Host as Orleans grains - - Operator->>UI: Start execution with approval-gated prompt - UI->>Runtime: ExecuteAsync(request) - Runtime->>MAF: RunAsync(start signal) - MAF-->>Runtime: Paused result + checkpoint files - Runtime->>Store: Save archive.json + replay.md + checkpoint id - Runtime->>Host: Upsert session + artifacts - Operator->>UI: Resume after restart - UI->>Runtime: ResumeAsync(resume request) - Runtime->>Store: Load archive + checkpoint id - Runtime->>MAF: ResumeAsync(checkpoint) - MAF-->>Runtime: Final result - Runtime->>Store: Persist updated replay and archive state - Runtime->>Host: Upsert final session + artifacts -``` - -## Design Notes - -- The orchestration boundary stays in `DotPilot.Runtime`, not in the Uno app, so desktop startup remains presentation-only. -- `AgentFrameworkRuntimeClient` uses `Microsoft.Agents.AI.Workflows` for run orchestration, checkpoint storage, and resume semantics. -- `RuntimeSessionArchiveStore` persists three operator-facing artifacts per session: - - `archive.json` - - `replay.md` - - checkpoint files under `checkpoints/` -- The implementation explicitly waits for checkpoint materialization before archiving paused sessions because the workflow run halts before checkpoint files are always observable from `Run.LastCheckpoint`. -- `#26` asked for `ManagedCode.Orleans.Graph`, but the current public package targets Orleans `9.x` while this repository is pinned to Orleans `10.0.1`. The runtime therefore exposes an explicit `EmbeddedRuntimeTrafficPolicyCatalog` plus Mermaid graph output now, while keeping the policy boundary ready for a future package-compatible graph implementation. -- Browser and deterministic paths stay available, so CI can validate the runtime slice without external CLI providers or auth. - -## Verification - -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeFoundation` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- `dotnet test DotPilot.slnx` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - -## References - -- [Architecture Overview](../Architecture.md) -- [Embedded Orleans Host](./embedded-orleans-host.md) -- [ADR-0001: Agent Control Plane Architecture](../ADR/ADR-0001-agent-control-plane-architecture.md) -- [ADR-0003: Keep the Uno App Presentation-Only and Move Feature Work into Vertical-Slice Class Libraries](../ADR/ADR-0003-vertical-slices-and-ui-only-uno-app.md) diff --git a/docs/Features/runtime-communication-contracts.md b/docs/Features/runtime-communication-contracts.md deleted file mode 100644 index e8c2c7e..0000000 --- a/docs/Features/runtime-communication-contracts.md +++ /dev/null @@ -1,59 +0,0 @@ -# Runtime Communication Contracts - -## Summary - -Issue [#23](https://github.com/managedcode/dotPilot/issues/23) standardizes the first public runtime success and failure contracts on top of `ManagedCode.Communication`. This gives the control plane one explicit result language for deterministic runtime flows now, and for provider adapters, embedded hosting, and orchestration later. - -## Scope - -### In Scope - -- `ManagedCode.Communication` as the shared result and problem package for public runtime boundaries -- typed runtime communication problem codes for validation, provider readiness, runtime-host availability, orchestration availability, and policy rejection -- `Result` as the first public runtime boundary used by the deterministic runtime client -- documentation and tests that prove both success and failure flows - -### Out Of Scope - -- end-user copywriting for every eventual UI error state -- provider adapter implementation -- Orleans host implementation -- Agent Framework orchestration implementation - -## Flow - -```mermaid -flowchart LR - Request["AgentTurnRequest"] - Deterministic["DeterministicAgentRuntimeClient"] - Success["Result.Succeed(...)"] - Validation["Problem: PromptRequired"] - Provider["Problem: ProviderUnavailable / Auth / Config / Outdated"] - Runtime["Problem: RuntimeHostUnavailable / OrchestrationUnavailable"] - - Request --> Deterministic - Deterministic --> Success - Deterministic --> Validation - Deterministic --> Provider - Deterministic --> Runtime -``` - -## Contract Notes - -- `RuntimeCommunicationProblemCode` is the stable typed error-code set for the first communication boundary. -- `RuntimeCommunicationProblems` centralizes `Problem` creation so later provider, host, and orchestration slices do not drift into ad hoc error construction. -- Validation now returns a failed `Result` with a field-level error on `Prompt` instead of throwing for expected bad input. -- Provider-readiness failures are encoded as typed problems mapped from `ProviderConnectionStatus`, which keeps the failure language aligned with the domain model from issue `#22`. -- Approval pauses remain successful results because they are a valid runtime state transition, not an error. - -## Verification - -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter FullyQualifiedName~RuntimeCommunication` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- `dotnet test DotPilot.slnx` - -## Dependencies - -- Parent epic: [#11](https://github.com/managedcode/dotPilot/issues/11) -- Depends on: [#22](https://github.com/managedcode/dotPilot/issues/22) -- Follow-up host/runtime slices: [#24](https://github.com/managedcode/dotPilot/issues/24), [#25](https://github.com/managedcode/dotPilot/issues/25) diff --git a/docs/Features/toolchain-center.md b/docs/Features/toolchain-center.md deleted file mode 100644 index 6b74999..0000000 --- a/docs/Features/toolchain-center.md +++ /dev/null @@ -1,65 +0,0 @@ -# Toolchain Center - -## Summary - -Epic [#14](https://github.com/managedcode/dotPilot/issues/14) adds a first-class Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot`. The slice gives the operator one desktop surface to inspect installation state, version visibility, authentication readiness, connection diagnostics, provider configuration, and background polling before a live session starts. - -## Scope - -### In Scope - -- Toolchain Center shell and detail surface for issue [#33](https://github.com/managedcode/dotPilot/issues/33) -- `Codex` readiness, version, auth, and operator actions for issue [#34](https://github.com/managedcode/dotPilot/issues/34) -- `Claude Code` readiness, version, auth, and operator actions for issue [#35](https://github.com/managedcode/dotPilot/issues/35) -- `GitHub Copilot` readiness, CLI or SDK prerequisite visibility, and operator actions for issue [#36](https://github.com/managedcode/dotPilot/issues/36) -- Connection-test and health-diagnostic modeling for issue [#37](https://github.com/managedcode/dotPilot/issues/37) -- Secrets and environment configuration modeling for issue [#38](https://github.com/managedcode/dotPilot/issues/38) -- Background polling summaries and stale-state surfacing for issue [#39](https://github.com/managedcode/dotPilot/issues/39) - -### Out Of Scope - -- live provider session execution -- remote version feeds or package-manager-driven auto-update workflows -- secure secret storage beyond current environment and local configuration visibility -- local model runtimes outside the external provider toolchain path - -## Flow - -```mermaid -flowchart LR - Settings["Settings shell"] - Center["Toolchain Center"] - Providers["Codex / Claude Code / GitHub Copilot"] - Diagnostics["Launch + auth + tool access + connection + resume diagnostics"] - Config["Secrets + environment + resolved CLI path"] - Polling["Background polling summary"] - Operator["Operator action list"] - - Settings --> Center - Center --> Providers - Providers --> Diagnostics - Providers --> Config - Providers --> Polling - Providers --> Operator -``` - -## Contract Notes - -- `DotPilot.Core/Features/ToolchainCenter` owns the provider-agnostic contracts for readiness state, version status, auth state, health state, diagnostics, configuration entries, actions, workstreams, and polling summaries. -- `DotPilot.Runtime/Features/ToolchainCenter` owns provider profile definitions and side-effect-bounded CLI probing. The slice reads local executable metadata and environment signals only; it does not start real provider sessions. -- The Toolchain Center is the default settings category so provider readiness is visible without extra drilling after the operator enters settings. -- Provider configuration must stay visible without leaking secrets. Secret entries show status only, while non-secret entries can show the current resolved value. -- Background polling is represented as operator-facing state, not a hidden implementation detail. The UI must tell the operator when readiness was checked and when the next refresh will run. -- Missing or incomplete provider readiness is a surfaced state, not a fallback path. The app keeps blocked, warning, and action-required states explicit. - -## Verification - -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` -- `dotnet test DotPilot.slnx` - -## Dependencies - -- Parent epic: [#14](https://github.com/managedcode/dotPilot/issues/14) -- Child issues: [#33](https://github.com/managedcode/dotPilot/issues/33), [#34](https://github.com/managedcode/dotPilot/issues/34), [#35](https://github.com/managedcode/dotPilot/issues/35), [#36](https://github.com/managedcode/dotPilot/issues/36), [#37](https://github.com/managedcode/dotPilot/issues/37), [#38](https://github.com/managedcode/dotPilot/issues/38), [#39](https://github.com/managedcode/dotPilot/issues/39) diff --git a/docs/Features/workbench-foundation.md b/docs/Features/workbench-foundation.md deleted file mode 100644 index b75a914..0000000 --- a/docs/Features/workbench-foundation.md +++ /dev/null @@ -1,59 +0,0 @@ -# Workbench Foundation - -## Summary - -Epic [#13](https://github.com/managedcode/dotPilot/issues/13) turns the current static Uno shell into the first real operator workbench. The slice keeps the existing desktop-first information architecture, but replaces prototype-only assumptions with a runtime-backed repository tree, file surface, artifact and log inspection, and a first-class settings shell. - -## Scope - -### In Scope - -- primary three-pane workbench shell for issue `#28` -- gitignore-aware repository tree with search and open-file navigation for issue `#29` -- file viewer and diff-review surface aligned with a Monaco-style editor contract for issue `#30` -- artifact dock and runtime log console for issue `#31` -- unified settings shell for providers, policies, and storage for issue `#32` - -### Out Of Scope - -- provider runtime execution -- Orleans host orchestration -- persistent session replay -- full IDE parity - -## Flow - -```mermaid -flowchart LR - Nav["Left navigation"] - Tree["Repository tree + search"] - File["File surface + diff review"] - Session["Central session surface"] - Inspector["Artifacts + logs"] - Settings["Settings shell"] - - Nav --> Tree - Tree --> File - File --> Inspector - Session --> Inspector - Nav --> Settings - Settings --> Nav -``` - -## Contract Notes - -- The Uno app stays presentation-only; workbench data, repository scanning, and settings descriptors come from app-external feature slices. -- Browser UI tests need deterministic data, so the workbench runtime path must provide browser-safe seeded content when direct filesystem access is unavailable. -- Repository navigation, file inspection, diff review, artifact inspection, and settings navigation are treated as one operator flow rather than isolated widgets. -- The file surface is designed around a Monaco-style editor contract even when the current renderer remains constrained by cross-platform Uno surfaces. - -## Verification - -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` -- `dotnet test DotPilot.slnx` - -## Dependencies - -- Parent epic: [#13](https://github.com/managedcode/dotPilot/issues/13) -- Child issues: [#28](https://github.com/managedcode/dotPilot/issues/28), [#29](https://github.com/managedcode/dotPilot/issues/29), [#30](https://github.com/managedcode/dotPilot/issues/30), [#31](https://github.com/managedcode/dotPilot/issues/31), [#32](https://github.com/managedcode/dotPilot/issues/32) From 454dde94b35b48584df4520776111f32f1611375 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 13:29:10 +0100 Subject: [PATCH 02/30] agentees --- AGENTS.md | 2 + Directory.Packages.props | 4 +- DotPilot.Runtime.Host/AGENTS.md | 2 +- .../DotPilot.Runtime.Host.csproj | 3 +- .../AgentSessionHostBuilderExtensions.cs | 15 +- .../AgentSessions/AgentSessionHostNames.cs | 3 +- .../AgentSessions/AgentSessionHostOptions.cs | 5 +- .../AgentSessionHostStoragePaths.cs | 17 ++ DotPilot.Runtime/AGENTS.md | 3 + DotPilot.Runtime/DotPilot.Runtime.csproj | 1 + .../AgentRuntimeConversationFactory.cs | 107 +++++++++++ .../AgentSessionSerialization.cs | 18 ++ .../AgentSessions/AgentSessionService.cs | 66 +++---- ...AgentSessionServiceCollectionExtensions.cs | 25 +-- .../AgentSessionStorageOptions.cs | 4 + .../AgentSessions/AgentSessionStoragePaths.cs | 46 +++++ .../FolderChatHistoryProvider.cs | 78 ++++++++ .../LocalAgentChatHistoryStore.cs | 91 +++++++++ .../LocalAgentSessionStateStore.cs | 101 ++++++++++ .../AgentSessions/UnavailableChatClient.cs | 49 +++++ .../AgentSessionHostPersistenceTests.cs | 121 ++++++++++++ .../AgentSessionPersistenceTests.cs | 174 ++++++++++++++++++ consolidate-codex-branches.plan.md | 88 --------- epic-11-foundation-contracts.plan.md | 95 ---------- epic-12-embedded-runtime.plan.md | 105 ----------- issue-14-toolchain-center.plan.md | 144 --------------- issue-24-embedded-orleans-host.plan.md | 101 ---------- pr-76-review-followup.plan.md | 87 --------- pr-review-comment-sweep.plan.md | 90 --------- 29 files changed, 873 insertions(+), 772 deletions(-) create mode 100644 DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostStoragePaths.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionSerialization.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionStoragePaths.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/LocalAgentChatHistoryStore.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionStateStore.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/UnavailableChatClient.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentSessionHostPersistenceTests.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs delete mode 100644 consolidate-codex-branches.plan.md delete mode 100644 epic-11-foundation-contracts.plan.md delete mode 100644 epic-12-embedded-runtime.plan.md delete mode 100644 issue-14-toolchain-center.plan.md delete mode 100644 issue-24-embedded-orleans-host.plan.md delete mode 100644 pr-76-review-followup.plan.md delete mode 100644 pr-review-comment-sweep.plan.md diff --git a/AGENTS.md b/AGENTS.md index f72ee74..c37e83b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -167,6 +167,8 @@ For this app: - Provider integrations should stay SDK-first: when Codex, Claude Code, GitHub Copilot, or debug/test providers already expose an `IChatClient`-style abstraction, build agent orchestration on top of that instead of inventing parallel request/result wrappers without a clear gap - Persist app models and durable session state through `SQLite` plus `EF Core` when the data must survive restarts; do not keep the core chat/session experience trapped in seed data or transient in-memory catalogs - Model agents and sessions as Orleans grains, with each session acting as the workflow container that coordinates participant agents and streams messages, tool activity, and status updates into the UI +- When agent conversations must survive restarts, persist the full `AgentSession` plus chat history through an Agent Framework history/storage provider backed by a local desktop folder; do not reduce durable conversation state to transcript text rows only +- When Orleans grain state for agents or sessions must survive restarts on the local desktop host, use a local folder-backed Orleans storage provider instead of leaving those grains on in-memory persistence - Do not keep legacy product slices alive during a rewrite: when `Workbench`, `ToolchainCenter`, legacy runtime demos, or similar prototype surfaces are being replaced, remove them instead of leaving a parallel legacy path in the codebase - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication diff --git a/Directory.Packages.props b/Directory.Packages.props index 750dd4d..0abc290 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -12,9 +12,9 @@ + - @@ -24,6 +24,8 @@ + + diff --git a/DotPilot.Runtime.Host/AGENTS.md b/DotPilot.Runtime.Host/AGENTS.md index 09171af..7bff85f 100644 --- a/DotPilot.Runtime.Host/AGENTS.md +++ b/DotPilot.Runtime.Host/AGENTS.md @@ -17,7 +17,7 @@ Stack: `.NET 10`, class library, embedded Orleans host and local runtime-host se - Keep this project free of `Uno Platform`, XAML, and page/view-model logic. - Keep it focused on local embedded host concerns: silo configuration, grain registration, and host lifecycle. -- Use `UseLocalhostClustering` plus in-memory storage/reminders for the first runtime-host cut. +- Use `UseLocalhostClustering` for the embedded desktop cluster, and move grain persistence to a local folder-backed store when the product needs restart-safe state on the operator machine. - Do not add remote clustering, external durable stores, or provider-specific orchestration here unless a later issue explicitly requires them. ## Local Commands diff --git a/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj b/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj index 6f8f53a..baed956 100644 --- a/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj +++ b/DotPilot.Runtime.Host/DotPilot.Runtime.Host.csproj @@ -7,7 +7,8 @@ - + + diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs index 9d57047..d3b5167 100644 --- a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostBuilderExtensions.cs @@ -1,3 +1,5 @@ +using ManagedCode.Storage.FileSystem; +using ManagedCode.Storage.FileSystem.Extensions; using Microsoft.Extensions.Hosting; using Orleans.Configuration; @@ -32,8 +34,17 @@ internal static void ConfigureSilo(ISiloBuilder siloBuilder, AgentSessionHostOpt cluster.ClusterId = options.ClusterId; cluster.ServiceId = options.ServiceId; }); - siloBuilder.AddMemoryGrainStorage(AgentSessionHostNames.GrainStorageProviderName); + siloBuilder.ConfigureServices(services => + { + services.AddFileSystemStorageAsDefault(storage => + { + storage.BaseFolder = AgentSessionHostStoragePaths.ResolveStorageBasePath(options); + }); + }); + siloBuilder.AddGrainStorage(AgentSessionHostNames.GrainStorageProviderName, storage => + { + storage.StateDirectory = options.GrainStateDirectory; + }); siloBuilder.UseInMemoryReminderService(); } } - diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs index 9eb8154..0774a10 100644 --- a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostNames.cs @@ -6,10 +6,9 @@ internal static class AgentSessionHostNames public const string DefaultServiceId = "dotpilot-desktop"; public const int DefaultSiloPort = 11_111; public const int DefaultGatewayPort = 30_000; - public const string GrainStorageProviderName = "agent-sessions-memory"; + public const string GrainStorageProviderName = "agent-sessions-storage"; public const string SessionStateName = "session"; public const string AgentStateName = "agent"; public const string SessionGrainName = "Session"; public const string AgentGrainName = "Agent"; } - diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs index 2f24867..5a6b557 100644 --- a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostOptions.cs @@ -2,6 +2,10 @@ namespace DotPilot.Runtime.Host.Features.AgentSessions; public sealed class AgentSessionHostOptions { + public string? StorageBasePath { get; init; } + + public string GrainStateDirectory { get; init; } = "orleans/grain-state"; + public string ClusterId { get; init; } = AgentSessionHostNames.DefaultClusterId; public string ServiceId { get; init; } = AgentSessionHostNames.DefaultServiceId; @@ -10,4 +14,3 @@ public sealed class AgentSessionHostOptions public int GatewayPort { get; init; } = AgentSessionHostNames.DefaultGatewayPort; } - diff --git a/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostStoragePaths.cs b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostStoragePaths.cs new file mode 100644 index 0000000..6fbef5a --- /dev/null +++ b/DotPilot.Runtime.Host/Features/AgentSessions/AgentSessionHostStoragePaths.cs @@ -0,0 +1,17 @@ +namespace DotPilot.Runtime.Host.Features.AgentSessions; + +internal static class AgentSessionHostStoragePaths +{ + private const string AppFolderName = "DotPilot"; + + public static string ResolveStorageBasePath(AgentSessionHostOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.IsNullOrWhiteSpace(options.StorageBasePath) + ? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppFolderName) + : options.StorageBasePath; + } +} diff --git a/DotPilot.Runtime/AGENTS.md b/DotPilot.Runtime/AGENTS.md index 866b232..ace0493 100644 --- a/DotPilot.Runtime/AGENTS.md +++ b/DotPilot.Runtime/AGENTS.md @@ -19,6 +19,9 @@ Stack: `.NET 10`, class library, provider-backed runtime services, local persist - Keep this project free of `Uno Platform`, XAML, and page/view-model logic. - Implement feature slices against `DotPilot.Core` contracts instead of reaching back into the app project. - Prefer deterministic runtime behavior, provider readiness probing, and `SQLite`-backed persistence here so tests can exercise real flows without mocks. +- When conversation continuity is required, keep the durable chat/session runtime state split correctly: + - transcript and operator-facing projections can stay in `SQLite` + - the opaque `AgentSession` and chat-history provider state should persist in a local folder-backed Agent Framework store - Keep external-provider assumptions soft: absence of Codex, Claude Code, or GitHub Copilot in CI must not break the provider-independent baseline. - For the first embedded Orleans host implementation, stay local-first with `UseLocalhostClustering` and in-memory storage/reminders so the desktop runtime remains self-contained. diff --git a/DotPilot.Runtime/DotPilot.Runtime.csproj b/DotPilot.Runtime/DotPilot.Runtime.csproj index a568d4f..f019a10 100644 --- a/DotPilot.Runtime/DotPilot.Runtime.csproj +++ b/DotPilot.Runtime/DotPilot.Runtime.csproj @@ -10,6 +10,7 @@ + diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs b/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs new file mode 100644 index 0000000..285187d --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs @@ -0,0 +1,107 @@ +using System.Globalization; +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class AgentRuntimeConversationFactory( + LocalAgentSessionStateStore sessionStateStore, + IServiceProvider serviceProvider, + TimeProvider timeProvider) +{ + private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice."; + private static readonly System.Text.CompositeFormat NotYetImplementedCompositeFormat = + System.Text.CompositeFormat.Parse(NotYetImplementedFormat); + + public async ValueTask InitializeAsync( + AgentProfileRecord agentRecord, + SessionId sessionId, + CancellationToken cancellationToken) + { + var runtimeSession = await LoadOrCreateAsync(agentRecord, sessionId, cancellationToken); + await sessionStateStore.SaveAsync(runtimeSession.Agent, runtimeSession.Session, sessionId, cancellationToken); + } + + public async ValueTask LoadOrCreateAsync( + AgentProfileRecord agentRecord, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(agentRecord); + + var historyProvider = new FolderChatHistoryProvider( + serviceProvider.GetRequiredService()); + var agent = CreateAgent(agentRecord, historyProvider); + var session = await sessionStateStore.TryLoadAsync(agent, sessionId, cancellationToken); + if (session is null) + { + session = await CreateNewSessionAsync(agent, sessionId, cancellationToken); + await sessionStateStore.SaveAsync(agent, session, sessionId, cancellationToken); + } + + FolderChatHistoryProvider.BindToSession(session, sessionId); + return new RuntimeConversationContext(agent, session); + } + + public ValueTask SaveAsync( + RuntimeConversationContext runtimeContext, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(runtimeContext); + return sessionStateStore.SaveAsync(runtimeContext.Agent, runtimeContext.Session, sessionId, cancellationToken); + } + + private static async ValueTask CreateNewSessionAsync( + ChatClientAgent agent, + SessionId sessionId, + CancellationToken cancellationToken) + { + var session = await agent.CreateSessionAsync(cancellationToken); + FolderChatHistoryProvider.BindToSession(session, sessionId); + return session; + } + + private ChatClientAgent CreateAgent( + AgentProfileRecord agentRecord, + FolderChatHistoryProvider historyProvider) + { + var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agentRecord.ProviderKind); + var loggerFactory = serviceProvider.GetService(); + var options = new ChatClientAgentOptions + { + Id = agentRecord.Id.ToString("N", CultureInfo.InvariantCulture), + Name = agentRecord.Name, + Description = providerProfile.DisplayName, + ChatHistoryProvider = historyProvider, + UseProvidedChatClientAsIs = true, + ChatOptions = new ChatOptions + { + Instructions = agentRecord.SystemPrompt, + ModelId = agentRecord.ModelName, + }, + }; + + return CreateChatClient(providerProfile, agentRecord.Name) + .AsAIAgent(options, loggerFactory, serviceProvider); + } + + private IChatClient CreateChatClient( + AgentSessionProviderProfile providerProfile, + string agentName) + { + return providerProfile.Kind == AgentProviderKind.Debug + ? new DebugChatClient(agentName, timeProvider) + : new UnavailableChatClient( + string.Format( + CultureInfo.InvariantCulture, + NotYetImplementedCompositeFormat, + providerProfile.DisplayName)); + } +} + +internal sealed record RuntimeConversationContext(ChatClientAgent Agent, AgentSession Session); diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionSerialization.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionSerialization.cs new file mode 100644 index 0000000..3b9ad79 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionSerialization.cs @@ -0,0 +1,18 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal static class AgentSessionSerialization +{ + public static JsonSerializerOptions Options { get; } = CreateOptions(); + + private static JsonSerializerOptions CreateOptions() + { + return new JsonSerializerOptions + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + WriteIndented = false, + }; + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs index 3eea7dc..20ab371 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs @@ -1,13 +1,13 @@ using DotPilot.Core.Features.AgentSessions; using DotPilot.Core.Features.ControlPlaneDomain; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; namespace DotPilot.Runtime.Features.AgentSessions; internal sealed class AgentSessionService( IDbContextFactory dbContextFactory, + AgentRuntimeConversationFactory runtimeConversationFactory, IServiceProvider serviceProvider, TimeProvider timeProvider) : IAgentSessionService, IDisposable @@ -44,12 +44,14 @@ public async ValueTask GetWorkspaceAsync(CancellationTok var agents = await dbContext.AgentProfiles .OrderBy(record => record.Name) .ToListAsync(cancellationToken); - var sessions = await dbContext.Sessions + var sessions = (await dbContext.Sessions + .ToListAsync(cancellationToken)) .OrderByDescending(record => record.UpdatedAt) - .ToListAsync(cancellationToken); - var entries = await dbContext.SessionEntries + .ToList(); + var entries = (await dbContext.SessionEntries + .ToListAsync(cancellationToken)) .OrderBy(record => record.Timestamp) - .ToListAsync(cancellationToken); + .ToList(); var agentsById = agents.ToDictionary(record => record.Id); var sessionItems = sessions @@ -79,10 +81,11 @@ await BuildProviderStatusesAsync(dbContext, cancellationToken), .Where(record => record.Id == session.PrimaryAgentProfileId) .ToListAsync(cancellationToken); var agentsById = agents.ToDictionary(record => record.Id); - var entries = await dbContext.SessionEntries - .Where(record => record.SessionId == sessionId.Value) + var entries = (await dbContext.SessionEntries + .Where(record => record.SessionId == sessionId.Value) + .ToListAsync(cancellationToken)) .OrderBy(record => record.Timestamp) - .ToListAsync(cancellationToken); + .ToList(); return new SessionTranscriptSnapshot( MapSessionListItem(session, agentsById, entries), @@ -149,6 +152,7 @@ public async ValueTask CreateSessionAsync( dbContext.Sessions.Add(session); dbContext.SessionEntries.Add(CreateEntryRecord(sessionId, SessionStreamEntryKind.Status, StatusAuthor, SessionReadyText, now, accentLabel: StatusAccentLabel)); await dbContext.SaveChangesAsync(cancellationToken); + await runtimeConversationFactory.InitializeAsync(agent, sessionId, cancellationToken); await UpsertSessionGrainAsync(session); return await GetSessionAsync(sessionId, cancellationToken) ?? @@ -197,6 +201,7 @@ public async IAsyncEnumerable SendMessageAsync( var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agent.ProviderKind); var providerStatuses = await BuildProviderStatusesAsync(dbContext, cancellationToken); var providerStatus = providerStatuses.First(status => status.Kind == providerProfile.Kind); + var runtimeConversation = await runtimeConversationFactory.LoadOrCreateAsync(agent, command.SessionId, cancellationToken); var now = timeProvider.GetUtcNow(); var userEntry = CreateEntryRecord(command.SessionId, SessionStreamEntryKind.UserMessage, UserAuthor, command.Message.Trim(), now); @@ -247,13 +252,23 @@ public async IAsyncEnumerable SendMessageAsync( await dbContext.SaveChangesAsync(cancellationToken); yield return MapEntry(toolStartEntry); - var debugClient = new DebugChatClient(agent.Name, timeProvider); - var messageHistory = await BuildMessageHistoryAsync(dbContext, command.SessionId, cancellationToken); - var streamedMessageId = Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture); + string? streamedMessageId = null; var accumulated = new System.Text.StringBuilder(); - await foreach (var update in debugClient.GetStreamingResponseAsync(messageHistory, cancellationToken: cancellationToken)) + await foreach (var update in runtimeConversation.Agent.RunStreamingAsync( + command.Message.Trim(), + runtimeConversation.Session, + options: null, + cancellationToken)) { + if (string.IsNullOrEmpty(update.Text)) + { + continue; + } + + streamedMessageId ??= string.IsNullOrWhiteSpace(update.MessageId) + ? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture) + : update.MessageId; accumulated.Append(update.Text); yield return new SessionStreamEntry( streamedMessageId, @@ -265,9 +280,11 @@ public async IAsyncEnumerable SendMessageAsync( new AgentProfileId(agent.Id)); } + await runtimeConversationFactory.SaveAsync(runtimeConversation, command.SessionId, cancellationToken); + var assistantEntry = new SessionEntryRecord { - Id = streamedMessageId, + Id = streamedMessageId ?? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture), SessionId = command.SessionId.Value, AgentProfileId = agent.Id, Kind = (int)SessionStreamEntryKind.AssistantMessage, @@ -405,29 +422,6 @@ private static ProviderStatusDescriptor BuildProviderStatus( actions); } - private static async Task> BuildMessageHistoryAsync( - LocalAgentSessionDbContext dbContext, - SessionId sessionId, - CancellationToken cancellationToken) - { - var entries = await dbContext.SessionEntries - .Where(record => record.SessionId == sessionId.Value) - .OrderBy(record => record.Timestamp) - .ToListAsync(cancellationToken); - - return entries - .Where(record => record.Kind is (int)SessionStreamEntryKind.UserMessage or (int)SessionStreamEntryKind.AssistantMessage) - .Select(record => new ChatMessage( - record.Kind == (int)SessionStreamEntryKind.UserMessage ? ChatRole.User : ChatRole.Assistant, - record.Text) - { - AuthorName = record.Author, - CreatedAt = record.Timestamp, - MessageId = record.Id, - }) - .ToArray(); - } - private static SessionEntryRecord CreateEntryRecord( SessionId sessionId, SessionStreamEntryKind kind, diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs index 5dfd86c..37de905 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs @@ -5,15 +5,15 @@ namespace DotPilot.Runtime.Features.AgentSessions; public static class AgentSessionServiceCollectionExtensions { - private const string DatabaseFileName = "dotpilot-agent-sessions.db"; - private const string DatabaseFolderName = "DotPilot"; - public static IServiceCollection AddAgentSessions( this IServiceCollection services, AgentSessionStorageOptions? storageOptions = null) { services.AddSingleton(storageOptions ?? new AgentSessionStorageOptions()); services.AddDbContextFactory(ConfigureDbContext); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); return services; } @@ -28,22 +28,11 @@ private static void ConfigureDbContext(IServiceProvider serviceProvider, DbConte return; } - var databasePath = storageOptions.DatabasePath; - if (string.IsNullOrWhiteSpace(databasePath)) - { - var rootPath = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), - DatabaseFolderName); - Directory.CreateDirectory(rootPath); - databasePath = Path.Combine(rootPath, DatabaseFileName); - } - else + var databasePath = AgentSessionStoragePaths.ResolveDatabasePath(storageOptions); + var databaseDirectory = Path.GetDirectoryName(databasePath); + if (!string.IsNullOrWhiteSpace(databaseDirectory)) { - var databaseDirectory = Path.GetDirectoryName(databasePath); - if (!string.IsNullOrWhiteSpace(databaseDirectory)) - { - Directory.CreateDirectory(databaseDirectory); - } + Directory.CreateDirectory(databaseDirectory); } builder.UseSqlite($"Data Source={databasePath}"); diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs index 6a4442e..3a7a482 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStorageOptions.cs @@ -7,4 +7,8 @@ public sealed class AgentSessionStorageOptions public string InMemoryDatabaseName { get; init; } = "DotPilotAgentSessions"; public string? DatabasePath { get; init; } + + public string? RuntimeSessionDirectoryPath { get; init; } + + public string? ChatHistoryDirectoryPath { get; init; } } diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionStoragePaths.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStoragePaths.cs new file mode 100644 index 0000000..370e72a --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionStoragePaths.cs @@ -0,0 +1,46 @@ +namespace DotPilot.Runtime.Features.AgentSessions; + +internal static class AgentSessionStoragePaths +{ + private const string AppFolderName = "DotPilot"; + private const string DatabaseFileName = "dotpilot-agent-sessions.db"; + private const string RuntimeSessionsFolderName = "agent-runtime-sessions"; + private const string ChatHistoryFolderName = "agent-chat-history"; + + public static string ResolveDatabasePath(AgentSessionStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (!string.IsNullOrWhiteSpace(options.DatabasePath)) + { + return options.DatabasePath; + } + + return Path.Combine(GetAppStorageRoot(), DatabaseFileName); + } + + public static string ResolveRuntimeSessionDirectory(AgentSessionStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.IsNullOrWhiteSpace(options.RuntimeSessionDirectoryPath) + ? Path.Combine(GetAppStorageRoot(), RuntimeSessionsFolderName) + : options.RuntimeSessionDirectoryPath; + } + + public static string ResolveChatHistoryDirectory(AgentSessionStorageOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + return string.IsNullOrWhiteSpace(options.ChatHistoryDirectoryPath) + ? Path.Combine(GetAppStorageRoot(), ChatHistoryFolderName) + : options.ChatHistoryDirectoryPath; + } + + private static string GetAppStorageRoot() + { + return Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppFolderName); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs b/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs new file mode 100644 index 0000000..2fd9586 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class FolderChatHistoryProvider(LocalAgentChatHistoryStore chatHistoryStore) + : ChatHistoryProvider( + provideOutputMessageFilter: static messages => messages, + storeInputRequestMessageFilter: static messages => messages, + storeInputResponseMessageFilter: static messages => messages) +{ + private const string ProviderStateKey = "DotPilot.AgentSessionHistory"; + private static readonly ProviderSessionState SessionState = new( + static _ => new FolderChatHistoryState(), + ProviderStateKey, + AgentSessionSerialization.Options); + + public static void BindToSession(AgentSession session, SessionId sessionId) + { + ArgumentNullException.ThrowIfNull(session); + + var state = SessionState.GetOrInitializeState(session); + state.StorageKey = sessionId.Value.ToString("N", CultureInfo.InvariantCulture); + SessionState.SaveState(session, state); + } + + protected override async ValueTask> ProvideChatHistoryAsync( + InvokingContext context, + CancellationToken cancellationToken) + { + if (context.Session is null) + { + return []; + } + + var storageKey = GetStorageKey(context.Session); + return storageKey is null + ? [] + : await chatHistoryStore.LoadAsync(storageKey, cancellationToken); + } + + protected override async ValueTask StoreChatHistoryAsync( + InvokedContext context, + CancellationToken cancellationToken) + { + if (context.Session is null) + { + return; + } + + var storageKey = GetStorageKey(context.Session); + if (storageKey is null) + { + return; + } + + var responseMessages = context.ResponseMessages ?? []; + await chatHistoryStore.AppendAsync( + storageKey, + context.RequestMessages.Concat(responseMessages), + cancellationToken); + } + + private static string? GetStorageKey(AgentSession session) + { + ArgumentNullException.ThrowIfNull(session); + + var state = SessionState.GetOrInitializeState(session); + return string.IsNullOrWhiteSpace(state.StorageKey) ? null : state.StorageKey; + } +} + +internal sealed class FolderChatHistoryState +{ + public string? StorageKey { get; set; } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/LocalAgentChatHistoryStore.cs b/DotPilot.Runtime/Features/AgentSessions/LocalAgentChatHistoryStore.cs new file mode 100644 index 0000000..2cbd031 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/LocalAgentChatHistoryStore.cs @@ -0,0 +1,91 @@ +using System.Text.Json; +using Microsoft.Extensions.AI; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class LocalAgentChatHistoryStore(AgentSessionStorageOptions storageOptions) +{ + private const string FileExtension = ".json"; + private const string TempSuffix = ".tmp"; + private readonly Dictionary _memoryHistory = []; + + public async ValueTask> LoadAsync( + string storageKey, + CancellationToken cancellationToken) + { + var path = GetPath(storageKey); + if (UseTransientStore()) + { + return _memoryHistory.GetValueOrDefault(path) ?? []; + } + + if (!File.Exists(path)) + { + return []; + } + + await using var stream = File.OpenRead(path); + var messages = await JsonSerializer.DeserializeAsync( + stream, + AgentSessionSerialization.Options, + cancellationToken); + + return messages ?? []; + } + + public async ValueTask AppendAsync( + string storageKey, + IEnumerable messages, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(messages); + + var existing = await LoadAsync(storageKey, cancellationToken); + var combined = existing.Concat(messages).ToArray(); + var path = GetPath(storageKey); + if (UseTransientStore()) + { + _memoryHistory[path] = combined; + return; + } + + await WriteAsync(path, combined, cancellationToken); + } + + private string GetPath(string storageKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(storageKey); + + var directory = AgentSessionStoragePaths.ResolveChatHistoryDirectory(storageOptions); + return Path.Combine(directory, storageKey + FileExtension); + } + + private static async ValueTask WriteAsync( + string path, + ChatMessage[] payload, + CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = path + TempSuffix; + await using (var stream = File.Create(tempPath)) + { + await JsonSerializer.SerializeAsync( + stream, + payload, + AgentSessionSerialization.Options, + cancellationToken); + } + + File.Move(tempPath, path, overwrite: true); + } + + private bool UseTransientStore() + { + return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser(); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionStateStore.cs b/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionStateStore.cs new file mode 100644 index 0000000..88584c9 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/LocalAgentSessionStateStore.cs @@ -0,0 +1,101 @@ +using System.Globalization; +using System.Text.Json; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Agents.AI; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class LocalAgentSessionStateStore(AgentSessionStorageOptions storageOptions) +{ + private const string FileExtension = ".json"; + private const string TempSuffix = ".tmp"; + private readonly Dictionary _memorySessions = []; + + public async ValueTask TryLoadAsync( + AIAgent agent, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(agent); + + var path = GetPath(sessionId); + string? payload; + if (UseTransientStore()) + { + payload = _memorySessions.GetValueOrDefault(path); + } + else + { + if (!File.Exists(path)) + { + return null; + } + + payload = await File.ReadAllTextAsync(path, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + using var document = JsonDocument.Parse(payload); + return await agent.DeserializeSessionAsync( + document.RootElement.Clone(), + AgentSessionSerialization.Options, + cancellationToken); + } + + public async ValueTask SaveAsync( + AIAgent agent, + AgentSession session, + SessionId sessionId, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(session); + + var serialized = await agent.SerializeSessionAsync( + session, + AgentSessionSerialization.Options, + cancellationToken); + var path = GetPath(sessionId); + var payload = serialized.GetRawText(); + if (UseTransientStore()) + { + _memorySessions[path] = payload; + return; + } + + await WriteTextAsync(path, payload, cancellationToken); + } + + private string GetPath(SessionId sessionId) + { + var directory = AgentSessionStoragePaths.ResolveRuntimeSessionDirectory(storageOptions); + return Path.Combine( + directory, + sessionId.Value.ToString("N", CultureInfo.InvariantCulture) + FileExtension); + } + + private static async ValueTask WriteTextAsync( + string path, + string payload, + CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var tempPath = path + TempSuffix; + await File.WriteAllTextAsync(tempPath, payload, cancellationToken); + File.Move(tempPath, path, overwrite: true); + } + + private bool UseTransientStore() + { + return storageOptions.UseInMemoryDatabase || OperatingSystem.IsBrowser(); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/UnavailableChatClient.cs b/DotPilot.Runtime/Features/AgentSessions/UnavailableChatClient.cs new file mode 100644 index 0000000..a0963eb --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/UnavailableChatClient.cs @@ -0,0 +1,49 @@ +using System.Runtime.CompilerServices; +using Microsoft.Extensions.AI; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class UnavailableChatClient(string message) : IChatClient +{ + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = messages; + _ = options; + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException(message); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + _ = messages; + _ = options; + return ThrowAsync(cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + _ = serviceKey; + return serviceType == typeof(IChatClient) ? this : null; + } + + public void Dispose() + { + } + + private async IAsyncEnumerable ThrowAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + throw new InvalidOperationException(message); +#pragma warning disable CS0162 + yield break; +#pragma warning restore CS0162 + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentSessionHostPersistenceTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentSessionHostPersistenceTests.cs new file mode 100644 index 0000000..972a434 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentSessionHostPersistenceTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Sockets; +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Host.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class AgentSessionHostPersistenceTests +{ + [Test] + public async Task AgentAndSessionGrainsPersistAcrossHostRestart() + { + var root = CreateRootPath(); + var clusterId = "dotpilot-test-" + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture); + var serviceId = "dotpilot-service-" + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture); + var agentId = AgentProfileId.New(); + var sessionId = SessionId.New(); + + try + { + var agentDescriptor = new AgentProfileDescriptor + { + Id = agentId, + Name = "Persisted Grain Agent", + Role = AgentRoleKind.Operator, + ProviderId = ProviderId.New(), + ModelRuntimeId = null, + Tags = ["local"], + }; + var sessionDescriptor = new SessionDescriptor + { + Id = sessionId, + WorkspaceId = WorkspaceId.New(), + Title = "Persisted Grain Session", + Phase = SessionPhase.Execute, + ApprovalState = ApprovalState.NotRequired, + FleetId = null, + AgentProfileIds = [agentId], + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + using (var firstHost = await StartHostAsync(CreateOptions(root, clusterId, serviceId))) + { + var grains = firstHost.Services.GetRequiredService(); + await grains.GetGrain(agentId.ToString()).UpsertAsync(agentDescriptor); + await grains.GetGrain(sessionId.ToString()).UpsertAsync(sessionDescriptor); + } + + using (var secondHost = await StartHostAsync(CreateOptions(root, clusterId, serviceId))) + { + var grains = secondHost.Services.GetRequiredService(); + var reloadedAgent = await grains.GetGrain(agentId.ToString()).GetAsync(); + var reloadedSession = await grains.GetGrain(sessionId.ToString()).GetAsync(); + + reloadedAgent.Should().NotBeNull(); + reloadedAgent!.Name.Should().Be("Persisted Grain Agent"); + reloadedAgent.Tags.Should().ContainSingle("local"); + + reloadedSession.Should().NotBeNull(); + reloadedSession!.Title.Should().Be("Persisted Grain Session"); + reloadedSession.AgentProfileIds.Should().ContainSingle(id => id == agentId); + } + + Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories).Should().NotBeEmpty(); + } + finally + { + DeleteDirectory(root); + } + } + + private static AgentSessionHostOptions CreateOptions(string root, string clusterId, string serviceId) + { + return new AgentSessionHostOptions + { + StorageBasePath = root, + ClusterId = clusterId, + ServiceId = serviceId, + SiloPort = GetFreeTcpPort(), + GatewayPort = GetFreeTcpPort(), + }; + } + + private static async ValueTask StartHostAsync(AgentSessionHostOptions options) + { + var host = Host.CreateDefaultBuilder() + .UseDotPilotAgentSessions(options) + .ConfigureLogging(logging => logging.ClearProviders()) + .Build(); + await host.StartAsync(); + return host; + } + + private static int GetFreeTcpPort() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + + private static string CreateRootPath() + { + return Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(AgentSessionHostPersistenceTests), + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + } + + private static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs new file mode 100644 index 0000000..0e2c977 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs @@ -0,0 +1,174 @@ +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class AgentSessionPersistenceTests +{ + private static readonly JsonSerializerOptions HistorySerializerOptions = new() + { + TypeInfoResolver = new DefaultJsonTypeInfoResolver(), + }; + + [Test] + public async Task SendMessageAsyncPersistsFolderBackedAgentSessionAndHistoryAcrossServiceRestart() + { + var root = CreateRootPath(); + + try + { + SessionId sessionId; + AgentSessionStorageOptions storageOptions = CreateStorageOptions(root); + + await using (var firstFixture = CreateFixture(storageOptions)) + { + var agent = await EnableDebugAndCreateAgentAsync(firstFixture.Service, "Persistent Agent"); + var session = await firstFixture.Service.CreateSessionAsync( + new CreateSessionCommand("Persistent session", agent.Id), + CancellationToken.None); + sessionId = session.Session.Id; + + await DrainAsync( + firstFixture.Service.SendMessageAsync( + new SendSessionMessageCommand(sessionId, "first persisted prompt"), + CancellationToken.None)); + } + + var sessionFile = Path.Combine( + storageOptions.RuntimeSessionDirectoryPath!, + sessionId.Value.ToString("N", System.Globalization.CultureInfo.InvariantCulture) + ".json"); + var historyFile = Path.Combine( + storageOptions.ChatHistoryDirectoryPath!, + sessionId.Value.ToString("N", System.Globalization.CultureInfo.InvariantCulture) + ".json"); + + File.Exists(sessionFile).Should().BeTrue(); + File.Exists(historyFile).Should().BeTrue(); + + var firstHistory = await ReadHistoryAsync(historyFile); + firstHistory.Should().ContainSingle(message => + message.Role == ChatRole.User && + message.Text == "first persisted prompt"); + firstHistory.Should().ContainSingle(message => + message.Role == ChatRole.Assistant && + message.Text.Contains("Debug provider received: first persisted prompt", StringComparison.Ordinal)); + + await using (var secondFixture = CreateFixture(storageOptions)) + { + var reloaded = await secondFixture.Service.GetSessionAsync(sessionId, CancellationToken.None); + reloaded.Should().NotBeNull(); + reloaded!.Entries.Should().Contain(entry => + entry.Kind == SessionStreamEntryKind.AssistantMessage && + entry.Text.Contains("Debug provider received: first persisted prompt", StringComparison.Ordinal)); + + await DrainAsync( + secondFixture.Service.SendMessageAsync( + new SendSessionMessageCommand(sessionId, "second persisted prompt"), + CancellationToken.None)); + } + + var secondHistory = await ReadHistoryAsync(historyFile); + secondHistory.Should().ContainSingle(message => + message.Role == ChatRole.User && + message.Text == "first persisted prompt"); + secondHistory.Should().ContainSingle(message => + message.Role == ChatRole.User && + message.Text == "second persisted prompt"); + secondHistory.Should().Contain(message => + message.Role == ChatRole.Assistant && + message.Text.Contains("Debug provider received: second persisted prompt", StringComparison.Ordinal)); + } + finally + { + DeleteDirectory(root); + } + } + + private static AgentSessionStorageOptions CreateStorageOptions(string root) + { + return new AgentSessionStorageOptions + { + DatabasePath = Path.Combine(root, "sqlite", "agent-sessions.db"), + RuntimeSessionDirectoryPath = Path.Combine(root, "runtime-sessions"), + ChatHistoryDirectoryPath = Path.Combine(root, "chat-history"), + }; + } + + private static TestFixture CreateFixture(AgentSessionStorageOptions storageOptions) + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(storageOptions); + + var provider = services.BuildServiceProvider(); + var service = provider.GetRequiredService(); + return new TestFixture(provider, service); + } + + private static async Task DrainAsync(IAsyncEnumerable stream) + { + await foreach (var _ in stream) + { + } + } + + private static async Task> ReadHistoryAsync(string path) + { + await using var stream = File.OpenRead(path); + var messages = await JsonSerializer.DeserializeAsync( + stream, + HistorySerializerOptions, + CancellationToken.None); + + return messages ?? []; + } + + private static async Task EnableDebugAndCreateAgentAsync( + IAgentSessionService service, + string name) + { + await service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + + return await service.CreateAgentAsync( + new CreateAgentProfileCommand( + name, + AgentRoleKind.Operator, + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification.", + ["Shell"]), + CancellationToken.None); + } + + private static string CreateRootPath() + { + return Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(AgentSessionPersistenceTests), + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + } + + private static void DeleteDirectory(string path) + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + + private sealed class TestFixture(ServiceProvider provider, IAgentSessionService service) : IAsyncDisposable + { + public IAgentSessionService Service { get; } = service; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } +} diff --git a/consolidate-codex-branches.plan.md b/consolidate-codex-branches.plan.md deleted file mode 100644 index 814ceba..0000000 --- a/consolidate-codex-branches.plan.md +++ /dev/null @@ -1,88 +0,0 @@ -## Goal - -Consolidate the user-requested local branches into one new branch, keep the merged runtime and UI fixes, restore a green validation baseline, open one replacement PR, and leave only `main` plus the new consolidated branch in the local repo. - -## Scope - -In scope: -- Merge the requested branch content into `codex/consolidated-13-15-76` -- Fix any integration regressions introduced by the consolidation -- Re-run full repo validation, including `DotPilot.UITests` -- Push the consolidated branch and open a single PR -- Remove extra local branches and extra local worktrees so only `main` and the consolidated branch remain - -Out of scope: -- New backlog feature work outside the merged branches -- Any dependency additions -- Human merge/approval actions on GitHub - -## Constraints And Risks - -- The repo requires `-warnaserror` builds. -- UI tests must run through the real `DotPilot.UITests` harness; no manual app launch outside the harness. -- The consolidated branch must preserve the startup responsiveness fixes from the PR 76 review follow-up. -- The local branch cleanup must not delete `main` or the new consolidated branch. - -## Testing Methodology - -- Validate the compile baseline with the repo `build` command. -- Validate end-to-end UI behavior only through `dotnet test DotPilot.UITests/DotPilot.UITests.csproj`. -- Validate the full repo through the solution test command after focused fixes are green. -- Validate coverage with the repo collector command and confirm no regression versus the pre-consolidation baseline. - -## Ordered Plan - -- [x] Confirm the active branch/worktree state and identify the consolidated branch target. -- [x] Reproduce the consolidated-branch regression through the real `DotPilot.UITests` harness. -- [x] Capture the root cause of the harness failure instead of treating it as a generic host timeout. -- [x] Restore the missing shared build input and any other merge fallout required to make the browser host buildable again. -- [x] Run focused UI verification to prove the browser host starts and the failing settings/workbench flow passes again. -- [x] Run the full required validation sequence: - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.slnx` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` -- [x] Commit the consolidation fixes on `codex/consolidated-13-15-76`. -- [x] Push the consolidated branch and open one replacement PR to `main`. -- [x] Delete extra local branches and extra local worktrees so only `main` and `codex/consolidated-13-15-76` remain locally. - -## Full-Test Baseline - -- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj --filter FullyQualifiedName~WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible -v minimal` - - Failed before test execution with `CSC` errors because `/Users/ksemenenko/Developer/dotPilot/CodeMetricsConfig.txt` was missing during the `net10.0-browserwasm` host build. - -## Tracked Failing Tests - -- [x] `WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible` - - Symptom: browser host exits before reachable - - Root cause: `CodeMetricsConfig.txt` missing from repo root, so the browserwasm compile inside the harness fails - - Intended fix: restore `CodeMetricsConfig.txt` with the shared analyzer config content and rerun the harness - -## Verification Results - -- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj --filter FullyQualifiedName~WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible -v minimal` - - Passed: `1` -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors -- `dotnet test DotPilot.slnx` - - Passed: `60` unit tests and `22` UI tests -- `dotnet format DotPilot.slnx --verify-no-changes` - - Passed -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - - Passed: `60` tests - - Coverage artifact: `DotPilot.Tests/TestResults/9a4b4ba7-ae2c-4a23-9eab-0af4d4e30730/coverage.cobertura.xml` - -## Git Results - -- Consolidated branch pushed: `origin/codex/consolidated-13-15-76` -- Replacement PR: `#79` -- Local branches remaining: `main`, `codex/consolidated-13-15-76` -- Local worktrees remaining: only `/Users/ksemenenko/Developer/dotPilot` -- Remote branches remaining for this repo clone: `origin/main`, `origin/codex/consolidated-13-15-76` - -## Done Criteria - -- The consolidated branch contains the requested merged work plus the follow-up fixes. -- Full repo validation is green. -- One PR exists for the consolidated branch. -- Only `main` and `codex/consolidated-13-15-76` remain as local branches. diff --git a/epic-11-foundation-contracts.plan.md b/epic-11-foundation-contracts.plan.md deleted file mode 100644 index ff07b82..0000000 --- a/epic-11-foundation-contracts.plan.md +++ /dev/null @@ -1,95 +0,0 @@ -## Goal - -Implement epic `#11` on a dedicated branch by fully covering its direct child issues `#22` and `#23` with code, docs, and automated tests, then open one PR that closes the epic and both child issues automatically. - -## Scope - -In scope: -- issue `#22`: finalize the control-plane domain model for agents, sessions, fleets, tools, artifacts, telemetry, and evaluations -- issue `#23`: finalize `ManagedCode.Communication` usage for public runtime result and problem contracts -- fix any remaining gaps on `main` that keep the epic from being honestly closeable, including stale docs, issue references, and missing automated verification coverage -- keep the work inside `DotPilot.Core`, `DotPilot.Runtime`, `DotPilot.Tests`, and docs that describe these slices - -Out of scope: -- runtime host or orchestration implementation changes beyond what is strictly needed to prove the issue `#23` contract surface -- UI redesign or workbench behavior -- provider-specific adapter work from later epics - -## Constraints And Risks - -- The app remains presentation-only; this epic is contract and foundation work, not UI-first behavior. -- Do not claim the epic is complete unless both direct child issues are covered by real implementation and automated tests. -- Tests must stay realistic and exercise caller-visible flows through public contracts. -- Existing open issue state on GitHub may reflect missing PR closing refs rather than missing code; the branch must still produce real repository improvements before opening a new PR. -- Avoid user-specific local paths and workflow-specific branch names in durable test data and user-facing docs; task-local plan notes may still reference the active branch and PR. - -## Testing Methodology - -- Validate issue `#22` through serialization-safe contract round-trips, identifier behavior, and cross-record relationship assertions. -- Validate issue `#23` through deterministic runtime client success and failure flows that surface `ManagedCode.Communication` results and problems at the public runtime boundary. -- Keep verification layered: - - focused issue `#22/#23` tests - - full `DotPilot.Tests` - - full solution tests including `DotPilot.UITests` - - coverage for `DotPilot.Tests` -- Require changed production files to stay at or above the repo coverage bar. - -## Ordered Plan - -- [x] Confirm epic `#11` scope and direct child issues from GitHub. -- [x] Create a dedicated branch from clean `main`. -- [x] Audit `main` for remaining gaps in issue `#22/#23` implementation, docs, and tests. -- [x] Correct stale architecture and feature docs so epic `#11`, issue `#22`, and issue `#23` are referenced accurately. -- [x] Add or tighten automated tests for issue `#22` and issue `#23` in slice-aligned locations, including deterministic runtime result/problem coverage. -- [x] Run focused verification for the changed slice tests. -- [x] Run the full repo validation sequence: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.slnx` - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` -- [x] Commit the epic `#11` work and open one PR with correct GitHub closing refs. - -## Full-Test Baseline - -- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors. -- [x] `dotnet test DotPilot.slnx` - - Passed with `61` unit tests and `22` UI tests. - -## Tracked Failing Tests - -- [x] No baseline failures in the repository state under serial execution. -- [x] Baseline note: a parallel local `build` + `test` attempt caused a self-inflicted file-lock on `DotPilot.Core/obj`; this was not a repository failure and was resolved by rerunning the required commands serially per root `AGENTS.md`. - -## Done Criteria - -- Epic `#11` has a real implementation close-out branch, not only issue closure metadata. -- Issue `#22` contracts are documented, serialization-safe, and covered by automated tests. -- Issue `#23` result/problem contracts are documented, exercised through public runtime flows, and covered by automated tests. -- Architecture and feature docs no longer misattribute issue `#22/#23` to epic `#12`. -- The final PR closes `#11`, `#22`, and `#23` automatically after merge. - -## Audit Notes - -- `main` already contained the bulk of the issue `#22/#23` implementation, but the close-out was incomplete: - - `docs/Features/control-plane-domain-model.md` incorrectly listed epic `#12` as the parent instead of epic `#11` - - `docs/Architecture.md` and `ADR-0003` treated issues `#22` and `#23` as if they belonged to epic `#12` - - domain-contract tests still embedded a user-specific local filesystem path and stale branch name - - issue `#23` lacked focused automated coverage that exercised `ManagedCode.Communication` through the public deterministic runtime client boundary - -## Final Validation Results - -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter "FullyQualifiedName~ControlPlaneDomain|FullyQualifiedName~RuntimeCommunication"` - - Passed with `23` tests. -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors. -- `dotnet test DotPilot.slnx` - - Passed with `61` unit tests and `22` UI tests. -- `dotnet format DotPilot.slnx --verify-no-changes` - - Passed with no formatting drift. -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - - Passed with overall coverage `91.66%` line and `61.66%` branch. - - Changed production files met the repo bar: - - `RuntimeFoundationCatalog`: `100.00%` line / `100.00%` branch -- Pull request - - Opened [PR #82](https://github.com/managedcode/dotPilot/pull/82) from `codex/epic-11-foundation-contracts` to `main` with `Closes #11`, `Closes #22`, and `Closes #23`. diff --git a/epic-12-embedded-runtime.plan.md b/epic-12-embedded-runtime.plan.md deleted file mode 100644 index f7c8ff4..0000000 --- a/epic-12-embedded-runtime.plan.md +++ /dev/null @@ -1,105 +0,0 @@ -## Goal - -Implement epic `#12` on one delivery branch by covering its direct child issues `#24`, `#25`, `#26`, and `#27` in a single tested runtime slice, while keeping the Uno app presentation-only and keeping the first Orleans host cut on localhost clustering with in-memory Orleans storage/reminders. - -## Scope - -In scope: -- issue `#24`: embedded Orleans silo inside the desktop host with the initial core grains -- issue `#25`: Microsoft Agent Framework integration as the orchestration runtime on top of the embedded host -- issue `#26`: explicit grain traffic policy and visibility, with package-compatible graphing kept honest -- issue `#27`: session persistence, replay, checkpointing, and resume for local-first runtime flows -- runtime-facing contracts, deterministic orchestration seams, docs, and tests needed to prove the full epic behavior - -Out of scope: -- related but non-child issues such as `#50`, `#69`, and `#77` -- provider-specific live adapters beyond the existing deterministic or environment-gated paths -- remote Orleans clustering or external durable storage providers -- replacing the current Uno shell with a different UI model - -## Constraints And Risks - -- The app project must stay presentation-only; runtime hosting, orchestration, graph policy, and persistence logic belong in separate DLLs. -- The first Orleans host cut must use `UseLocalhostClustering`, in-memory grain storage, and in-memory reminders. -- Durable session replay and resume for `#27` must not force a remote or durable Orleans cluster; if needed, it must persist serialized session/checkpoint data outside Orleans storage. -- All added behavior must be covered by automated tests and the full repo validation sequence must stay green. -- Any new dependencies must be the minimum official set needed for the runtime slice and must remain compatible with the pinned SDK and current `LangVersion`. -- `ManagedCode.Orleans.Graph` currently targets Orleans `9.x`; this branch must not lie about graph enforcement if the package cannot coexist with Orleans `10.0.1`. - -## Testing Methodology - -- Cover host lifecycle, grain registration, traffic policy, orchestration execution, session serialization, checkpoint persistence, replay, and resume through real runtime boundaries. -- Keep deterministic in-repo orchestration available for CI so the epic remains testable without external provider CLIs or auth. -- Add regression tests for both happy-path and negative-path flows: - - invalid runtime requests - - traffic-policy violations - - missing or corrupt persisted session state - - restart/resume behavior -- Keep `DotPilot.UITests` in the final pass because browser and app composition must remain green even when runtime hosting expands. -- Require every direct child issue in scope to map to at least one explicit automated test flow. - -## Ordered Plan - -- [x] Confirm the exact direct-child issue set for epic `#12` and keep unrelated issues out of the PR scope. -- [x] Add or restore the embedded Orleans host slice from the cleanest available implementation path for issue `#24`. -- [x] Add the minimum runtime dependencies and contracts for Microsoft Agent Framework orchestration for issue `#25`. -- [x] Implement the first orchestration runtime path on top of the deterministic runtime flow and Orleans-backed runtime boundaries. -- [x] Add explicit grain traffic policy modeling and enforcement for issue `#26`, including runtime-visible policy information and denial behavior. -- [x] Add local-first session persistence, replay, checkpointing, and resume for issue `#27` without changing Orleans clustering/storage topology. -- [x] Update runtime docs, feature docs, ADR references, and architecture diagrams so the epic boundaries and flows are explicit. -- [x] Add or update automated tests for every covered issue: - - host lifecycle and grain registration - - orchestration execution and session serialization - - traffic-policy allow and deny flows - - checkpoint persistence, replay, and resume -- [x] Run the full repo validation sequence: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.slnx` - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` -- [x] Commit the epic branch implementation and open one PR that closes epic `#12` and its covered child issues correctly. - -## Full-Test Baseline - -- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors. -- [x] `dotnet test DotPilot.slnx` - - Passed with `52` unit tests and `22` UI tests. - -## Tracked Failing Tests - -- [x] No baseline failures before epic implementation. -- [x] `ExecuteAsyncPausesForApprovalAndResumeAsyncCompletesAfterHostRestart` - - Failure symptom: paused archive was persisted before Agent Framework checkpoint files had materialized, so `CheckpointId` was null. - - Root cause: `RunAsync()` returns on `RequestHaltEvent` before checkpoint metadata is always observable through `Run.LastCheckpoint`. - - Fix path: wait for checkpoint materialization and resolve checkpoint metadata from run state plus persisted checkpoint files. -- [x] `ResumeAsyncPersistsRejectedApprovalAsFailedReplay` - - Failure symptom: resume on the same runtime client threw workflow ownership errors. - - Root cause: `Run` handles were not disposed, so the workflow remained owned by the previous runner. - - Fix path: dispose Agent Framework `Run` handles with `await using`. - -## Done Criteria - -- The branch covers direct child issues `#24`, `#25`, `#26`, and `#27` with real implementation, not only planning artifacts. -- The Uno app remains presentation-only and browser-safe. -- Orleans stays on localhost clustering and in-memory storage/reminders. -- Orchestration, traffic policy, and session persistence flows are automated and green. -- The final PR references the epic and child issues with correct GitHub closing semantics. - -## Final Validation Results - -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors. -- `dotnet test DotPilot.slnx` - - Passed with `72` unit tests and `22` UI tests. -- `dotnet format DotPilot.slnx --verify-no-changes` - - Passed with no formatting drift. -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - - Passed with overall coverage `84.26%` line and `50.93%` branch. - - Changed runtime files met the repo bar: - - `AgentFrameworkRuntimeClient`: `100.00%` line / `90.00%` branch - - `RuntimeSessionArchiveStore`: `100.00%` line / `100.00%` branch - - `EmbeddedRuntimeTrafficPolicy`: `100.00%` line / `83.33%` branch - - `EmbeddedRuntimeTrafficPolicyCatalog`: `100.00%` line / `100.00%` branch -- Pull request - - Opened [PR #81](https://github.com/managedcode/dotPilot/pull/81) from `codex/epic-12-embedded-runtime` to `main` with `Closes #12`, `Closes #24`, `Closes #25`, `Closes #26`, and `Closes #27`. diff --git a/issue-14-toolchain-center.plan.md b/issue-14-toolchain-center.plan.md deleted file mode 100644 index d9d58e4..0000000 --- a/issue-14-toolchain-center.plan.md +++ /dev/null @@ -1,144 +0,0 @@ -# Issue 14 Toolchain Center Plan - -## Goal - -Implement epic `#14` in one coherent vertical slice so `dotPilot` gains a first-class Toolchain Center for `Codex`, `Claude Code`, and `GitHub Copilot`, while keeping the `Uno` app presentation-focused and all non-UI logic in separate DLLs. - -## Scope - -### In scope - -- Issue `#33`: Toolchain Center UI -- Issue `#34`: Codex detection, version, auth, update, and operator actions -- Issue `#35`: Claude Code detection, version, auth, update, and operator actions -- Issue `#36`: GitHub Copilot readiness, CLI or server visibility, SDK prerequisite visibility, and operator actions -- Issue `#37`: provider connection-test and health-diagnostics model -- Issue `#38`: provider secrets and environment configuration management model and UI -- Issue `#39`: background polling model and surfaced stale-state warnings -- Core contracts in `DotPilot.Core` -- Runtime probing, diagnostics, polling, and configuration composition in `DotPilot.Runtime` -- Desktop-first `Uno` presentation and navigation in `DotPilot` -- Automated coverage in `DotPilot.Tests` and `DotPilot.UITests` -- Architecture and feature documentation updates required by the new slice - -### Out of scope - -- Epic `#15` provider adapter issues `#40`, `#41`, `#42` -- Real live session execution for external providers -- New external package dependencies unless explicitly approved -- Non-provider local runtime setup outside Toolchain Center scope - -## Constraints And Risks - -- Keep the `Uno` app cleanly UI-only; non-UI toolchain behavior must live in `DotPilot.Core` and `DotPilot.Runtime`. -- Do not add new NuGet dependencies without explicit user approval. -- Do not hide readiness problems behind fallback behavior; missing, stale, or broken provider state must remain visible and attributable. -- Provider-specific tests that require real `Codex`, `Claude Code`, or `GitHub Copilot` toolchains must be environment-gated, while provider-independent coverage must still stay green in CI. -- UI tests must continue to run through `dotnet test DotPilot.UITests/DotPilot.UITests.csproj`; no manual app launch path is allowed for UI verification. -- Local and CI validation must use `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false`. -- Existing workbench navigation issues may already be present in the UI baseline and must be tracked explicitly if reproduced in this clean worktree. - -## Testing Methodology - -- Unit and integration-style tests in `DotPilot.Tests` will verify: - - Toolchain Center contracts and snapshot shape - - provider readiness probes for success, missing toolchain, partial readiness, and stale-state warnings - - provider diagnostics and secrets or environment modeling - - background polling metadata and surfaced warning summaries -- UI tests in `DotPilot.UITests` will verify: - - navigation into the Toolchain Center - - provider summary visibility - - provider detail visibility for each supported provider - - secrets and environment sections - - diagnostics and polling-state visibility - - at least one end-to-end operator flow through settings to Toolchain Center and back to the broader shell -- Real-toolchain tests will run only when the corresponding executable and auth prerequisites are available. -- The task is not complete until the changed tests, related suites, broader solution verification, and coverage run are all green or any pre-existing blockers are documented with root cause and explicit non-regression evidence. - -## Ordered Plan - -- [x] Step 1. Capture the clean-worktree baseline. - - Run the mandatory build and relevant test suites before code changes. - - Update the failing-test tracker below with every reproduced baseline failure. -- [x] Step 2. Define the Toolchain Center slice contracts in `DotPilot.Core`. - - Add explicit provider readiness, version, auth, diagnostics, secrets, environment, action, and polling models. - - Keep contracts provider-agnostic where possible and provider-specific only where required by the epic. -- [x] Step 3. Implement runtime probing and composition in `DotPilot.Runtime`. - - Build provider-specific readiness snapshots for `Codex`, `Claude Code`, and `GitHub Copilot`. - - Add operator-action models, diagnostics summaries, secrets or environment metadata, and polling-state summaries. - - Keep probing side-effect free except for tightly bounded metadata or command checks. -- [x] Step 4. Integrate the Toolchain Center into the desktop settings surface in `DotPilot`. - - Add a first-class Toolchain Center entry and detail surface. - - Keep the layout desktop-first, fast to scan, and aligned with the current shell. - - Surface errors and warnings directly instead of masking them. -- [x] Step 5. Add or update automated tests in parallel with the production slice work. - - Start with failing regression or feature tests where new behavior is introduced. - - Cover provider-independent flows broadly and gated real-provider flows conditionally. -- [x] Step 6. Update durable docs. - - Update `docs/Architecture.md` with the new slice and diagrams. - - Add or update a feature doc in `docs/Features/` for Toolchain Center behavior and verification. - - Correct any stale root guidance discovered during the task, including `LangVersion` wording if still inconsistent with source. -- [x] Step 7. Run final validation and prepare the PR. - - Run format, build, focused tests, broader tests, UI tests, and coverage. - - Create a PR that uses GitHub closing references for the implemented issues. - -## Full-Test Baseline Step - -- [x] Run `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- [x] Run `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- [x] Run `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` - -## Already Failing Tests Tracker - -- [x] `GivenMainPage.WhenFilteringTheRepositoryThenTheMatchingFileOpens` - - Failure symptom: `Uno.UITest` target selection was unstable when the repository list used DOM-expanded item content instead of one stable tappable target. - - Root-cause notes: the sidebar repository flow mixed a `ListView` selection surface with a nested text-only automation target, which made follow-up navigation flows brittle after document-open actions. - - Resolution: the tests now open the document through one canonical search-and-open helper, assert the opened title explicitly, and the repository list remains unique under `Uno` automation mapping. -- [x] `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` run completion - - Failure symptom: the suite previously stalled around the first failing workbench navigation flow and left the browser harness in an unclear state. - - Root-cause notes: multiple broken navigation paths and stale diagnostics made the harness look hung even though the real issue was route resolution and ambiguous navigation controls. - - Resolution: page-specific sidebar automation ids, route fixes for `-/Main`, and improved DOM or hit-test diagnostics now leave the suite green and terminating normally. - -## Final Results - -- `dotnet format DotPilot.slnx --verify-no-changes` -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` -- `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` -- `dotnet test DotPilot.slnx` -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - -Final green baselines after the slice landed: - -- `DotPilot.Tests`: `52` passed -- `DotPilot.UITests`: `22` passed -- Coverage collector overall: `91.58%` line / `61.33%` branch -- Key changed runtime files: - - `ToolchainCenterCatalog`: `95.00%` line / `100.00%` branch - - `ToolchainCommandProbe`: `89.23%` line / `87.50%` branch - - `ToolchainProviderSnapshotFactory`: `98.05%` line / `78.43%` branch - - `ProviderToolchainProbe`: `95.12%` line / `85.71%` branch - -## Final Validation Skills - -- `mcaf-dotnet` - - Reason: enforce repo-specific `.NET` commands, analyzer policy, language-version compatibility, and final validation order. -- `mcaf-testing` - - Reason: keep test layering explicit and prove user-visible flows instead of only internal wiring. -- `mcaf-architecture-overview` - - Reason: update the cross-project architecture map and diagrams after the new slice boundaries are introduced. - -## Final Validation Commands - -1. `dotnet format DotPilot.slnx --verify-no-changes` - - Reason: repo-required formatting and analyzer drift check. -2. `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Reason: mandatory warning-free build for local and CI parity. -3. `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` - - Reason: unit and integration-style validation for the non-UI slice. -4. `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` - - Reason: mandatory end-to-end UI verification through the real harness. -5. `dotnet test DotPilot.slnx` - - Reason: broader solution regression pass across all test projects. -6. `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - - Reason: prove coverage expectations for the changed production code. diff --git a/issue-24-embedded-orleans-host.plan.md b/issue-24-embedded-orleans-host.plan.md deleted file mode 100644 index 5e6a68b..0000000 --- a/issue-24-embedded-orleans-host.plan.md +++ /dev/null @@ -1,101 +0,0 @@ -## Goal - -Implement issue `#24` by embedding a local-first Orleans silo into the Uno desktop host, using `UseLocalhostClustering` plus in-memory grain storage and reminders, while keeping the browser/UI-test path isolated from server-only Orleans dependencies. - -## Scope - -In scope: -- Add the minimum Orleans contracts and grain interfaces for the initial runtime host cut -- Add a dedicated runtime host class library for the embedded Orleans implementation -- Register the initial Session, Workspace, Fleet, Policy, and Artifact grains -- Integrate the embedded Orleans silo into the Uno desktop startup path only -- Expose enough runtime-host status to validate startup, shutdown, and configuration through tests and docs -- Update architecture/docs for the new runtime host boundary - -Out of scope: -- Agent Framework orchestration -- Remote clustering -- External durable storage providers -- Full UI work beyond existing runtime/readiness presentation needs - -## Constraints And Risks - -- The first Orleans cut must use `UseLocalhostClustering` and in-memory storage/reminders only. -- The Uno app must remain presentation-only; Orleans implementation must live in a separate DLL. -- Browserwasm and UI-test paths must stay green; server-only Orleans packages must not leak into the browser build. -- All validation must pass with `-warnaserror`. -- No mocks, fakes, or stubs in verification. - -## Testing Methodology - -- Add contract and runtime tests for Orleans host configuration, host lifecycle, and initial grain registration. -- Verify the app composition path through real DI/build boundaries rather than isolated helper tests only. -- Keep `DotPilot.UITests` in the final validation because browser builds must remain unaffected by the Orleans addition. -- Prove the host uses localhost clustering plus in-memory storage/reminders through caller-visible configuration or startup behavior, not just private constants. - -## Ordered Plan - -- [x] Confirm the correct backlog item and architecture boundary for Orleans hosting. -- [x] Record the Orleans local-host policy in governance before implementation. -- [x] Inspect current runtime contracts, startup composition, and test seams for the Orleans host insertion point. -- [x] Add or update the runtime-host feature contracts in `DotPilot.Core`. -- [x] Add a dedicated Orleans runtime host project with the minimum official Orleans package set and a local `AGENTS.md`. -- [x] Implement the embedded Orleans silo configuration with localhost clustering and in-memory storage/reminders. -- [x] Register the initial Session, Workspace, Fleet, Policy, and Artifact grains. -- [x] Integrate the Orleans host into the Uno desktop startup/composition path without affecting browserwasm. -- [x] Add or update automated tests for contracts, lifecycle, and composition. -- [x] Update `docs/Architecture.md` and the relevant feature/runtime docs with Mermaid diagrams and the runtime-host boundary. -- [x] Run the full repo validation sequence: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.slnx` - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` -- [x] Commit the implementation and open a PR that uses GitHub closing references for `#24`. - - Commit: `c63c63c` (`Implement embedded Orleans localhost host`) - - PR: [#80](https://github.com/managedcode/dotPilot/pull/80) - -## Full-Test Baseline - -- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors. -- [x] `dotnet test DotPilot.slnx` - - Passed with `60` unit tests and `22` UI tests. - -## Tracked Failing Tests - -- [x] `InitialGrainsReturnNullBeforeTheirFirstWrite` - - Symptom: Orleans `CodecNotFoundException` for `SessionDescriptor` - - Root cause: control-plane runtime DTOs were not annotated for Orleans serialization/code generation - - Fix status: resolved by adding Orleans serializer metadata to the domain contracts -- [x] `InitialGrainsRoundTripTheirDescriptorState` - - Symptom: Orleans `CodecNotFoundException` for compiler-generated `<>z__ReadOnlyArray` - - Root cause: collection-expression values stored in `IReadOnlyList` produced a compiler-internal runtime type that Orleans could not deep-copy - - Fix status: resolved by changing runtime-bound collection properties to array-backed contract fields -- [x] `SessionGrainRejectsDescriptorIdsThatDoNotMatchThePrimaryKey` - - Symptom: the same collection-copy failure masked the intended `ArgumentException` - - Root cause: serialization failed before the grain method body executed - - Fix status: resolved after the array-backed contract change -- [x] `SessionStateDoesNotSurviveHostRestartWhenUsingInMemoryStorage` - - Symptom: the same collection-copy failure blocked the in-memory restart assertion - - Root cause: serialization failed before persistence behavior could be exercised - - Fix status: resolved after the array-backed contract change - -## Final Validation Notes - -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - Passed with `0` warnings and `0` errors after the final regression-test update. -- `dotnet test DotPilot.slnx` - - Passed with `67` unit tests and `22` UI tests. -- `dotnet format DotPilot.slnx --verify-no-changes` - - Passed with no formatting drift. -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - - Passed with a non-zero report after changing `ExcludeAssembliesWithoutSources` from `MissingAny` to `MissingAll`, which keeps mixed-source Orleans-generated assemblies measurable instead of dropping the whole report to zero. - - Latest report: `82.55%` line coverage and `50.39%` branch coverage overall, with `DotPilot.Runtime.Host` at `100%` line and `100%` branch coverage. - -## Done Criteria - -- Orleans hosting is implemented through a dedicated non-UI DLL and integrated into the desktop host. -- The host uses `UseLocalhostClustering` plus in-memory storage/reminders. -- The initial core grains are registered and reachable through real runtime tests. -- Browser/UI-test validation remains green. -- A PR is open with `Closes #24`. diff --git a/pr-76-review-followup.plan.md b/pr-76-review-followup.plan.md deleted file mode 100644 index 2f2e4f0..0000000 --- a/pr-76-review-followup.plan.md +++ /dev/null @@ -1,87 +0,0 @@ -# PR 76 Review Follow-up Plan - -## Goal - -Address the meaningful review comments on `PR #76`, remove backlog-specific text that leaked into production `ToolchainCenter` runtime metadata, and update the PR body so merge closes every relevant open issue included in the stacked change set. - -## Scope - -- In scope: - - `DotPilot.Runtime` fixes for `ToolchainCenterCatalog`, `ToolchainCommandProbe`, `ToolchainProviderSnapshotFactory`, and `RuntimeFoundationCatalog` - - regression and behavior tests in `DotPilot.Tests` - - PR `#76` body update with GitHub closing references for the open issue stack included in the branch history -- Out of scope: - - new product features outside existing `PR #76` - - dependency changes - - release workflow changes - -## Constraints And Risks - -- Build and test must run with `-warnaserror`. -- Do not run parallel `dotnet` or `MSBuild` work in the same checkout. -- `DotPilot.UITests` remains mandatory final verification. -- Review fixes must not keep GitHub backlog text inside production runtime snapshots or user-facing summaries. -- PR body should only close issues actually delivered by this stacked branch. - -## Testing Methodology - -- Runtime snapshot and probe behavior will be tested through `DotPilot.Tests` using real subprocess execution paths rather than mocks. -- Catalog lifecycle fixes will be covered with deterministic tests that validate disposal, snapshot stability, and provider caching behavior. -- Final validation must prove both the focused runtime slice and the broader repo verification path. - -## Ordered Plan - -- [x] Step 1. Establish the real baseline for this PR branch. - - Verification: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter Toolchain` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter RuntimeFoundationCatalog` -- [x] Step 2. Remove backlog-specific text from `ToolchainCenterCatalog` and make snapshot polling/disposal thread-safe. - - Verification: - - targeted `ToolchainCenterCatalogTests` -- [x] Step 3. Fix `ToolchainCommandProbe` launch-failure and redirected-stream handling. - - Verification: - - targeted `ToolchainCommandProbeTests` -- [x] Step 4. Fix provider-summary/status logic in `ToolchainProviderSnapshotFactory`. - - Verification: - - targeted `ToolchainProviderSnapshotFactoryTests` -- [x] Step 5. Fix `RuntimeFoundationCatalog` provider caching so UI-thread snapshot reads do not re-probe subprocesses. - - Verification: - - targeted `RuntimeFoundationCatalogTests` -- [x] Step 6. Update PR `#76` body with GitHub closing references for all relevant open issues merged through this stack. - - Verification: - - `gh pr view 76 --repo managedcode/dotPilot --json body` -- [x] Step 7. Run final verification and record outcomes. - - Verification: - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj` - - `dotnet test DotPilot.UITests/DotPilot.UITests.csproj` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - -## Baseline Results - -- [x] `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` -- [x] `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter Toolchain` -- [x] `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter RuntimeFoundationCatalog` - -## Known Failing Tests - -- None. The focused baseline and final repo validation passed. - -## Results - -- `dotnet format DotPilot.slnx --verify-no-changes` passed. -- `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` passed. -- `dotnet test DotPilot.slnx` passed with `57` unit tests and `22` UI tests green. -- `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` passed with overall collector result `91.09%` line / `63.66%` branch. -- `PR #76` body now uses `Closes #13`, `Closes #14`, and `Closes #28-#39`, so those issues will auto-close on merge. - -## Final Validation Skills - -- `mcaf-dotnet` - - Run build and test verification with the repo-defined commands. -- `mcaf-testing` - - Confirm new regressions cover the review-comment failure modes. -- `gh-address-comments` - - Verify the review comments are resolved and the PR body closes the correct issues on merge. diff --git a/pr-review-comment-sweep.plan.md b/pr-review-comment-sweep.plan.md deleted file mode 100644 index 03d402c..0000000 --- a/pr-review-comment-sweep.plan.md +++ /dev/null @@ -1,90 +0,0 @@ -## Goal - -Address the meaningful review comments across all currently open PRs created by this branch owner, starting from the oldest open PR and moving forward, then validate the affected slices and keep the repository push-ready. - -## Scope - -In scope: -- open PRs created by this account, processed oldest to newest -- code-review comments, review threads, and actionable issue comments that still make engineering sense -- code, tests, docs, and PR metadata changes needed to satisfy those comments -- verification for each touched slice plus the final required repo validation - -Out of scope: -- comments on already merged or closed PRs unless they reappear on an open PR -- comments that are stale, incorrect, or conflict with newer accepted decisions -- rebasing or rewriting unrelated branch history - -## Current PR Order - -1. PR `#79` — `codex/consolidated-13-15-76` -2. PR `#80` — `codex/issue-24-embedded-orleans-host` -3. PR `#81` — `codex/epic-12-embedded-runtime` -4. PR `#82` — `codex/epic-11-foundation-contracts` - -## Constraints And Risks - -- Start with the oldest open PR and move forward. -- Only fix comments that still make sense against the current repository state. -- Keep serial `dotnet` execution; do not run concurrent build/test commands in one checkout. -- Each production change needs corresponding automated coverage if behavior changes. -- The branch may need updates that touch multiple slices; keep validation layered and honest. - -## Testing Methodology - -- Gather all open review comments and unresolved threads for PRs `#79-#82`. -- For each PR, apply only the comments that remain valid. -- Run focused tests around the touched slice before moving to the next PR. -- After the sweep, run: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.slnx` - - `dotnet format DotPilot.slnx --verify-no-changes` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --settings DotPilot.Tests/coverlet.runsettings --collect:"XPlat Code Coverage"` - -## Ordered Plan - -- [x] Confirm the open PR list and processing order. -- [x] Collect actionable review comments and threads for PRs `#79`, `#80`, `#81`, and `#82`. -- [x] Audit each comment for current validity and group them by PR and affected slice. -- [x] Apply the valid fixes for PR `#79` and run focused verification. -- [x] Apply the valid fixes for PR `#80` and run focused verification. -- [x] Apply the valid fixes for PR `#81` and run focused verification. -- [x] Apply the valid fixes for PR `#82` and run focused verification. -- [x] Run the full repo validation sequence. -- [x] Commit the sweep and push the branch updates needed for the affected PR heads. - -## Full-Test Baseline - -- [x] Sweep baseline captured from open PR review threads and current branch verification. -- [x] PR `#79` focused verification passed: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter "FullyQualifiedName~ToolchainCenter|FullyQualifiedName~RuntimeFoundation"` - - `dotnet test DotPilot.UITests/DotPilot.UITests.csproj --filter "FullyQualifiedName~WhenNavigatingToSettingsThenCategoriesAndEntriesAreVisible|FullyQualifiedName~WhenNavigatingToSettingsThenToolchainCenterProviderDetailsAreVisible|FullyQualifiedName~WhenSwitchingToolchainProvidersThenProviderSpecificDetailsAreVisible"` - - `dotnet format DotPilot.slnx --verify-no-changes` -- [x] PR `#80` focused verification passed: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter "FullyQualifiedName~EmbeddedRuntimeHost|FullyQualifiedName~ToolchainCommandProbe"` - - `dotnet format DotPilot.slnx --verify-no-changes` -- [x] PR `#81` focused verification passed: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter "FullyQualifiedName~AgentFrameworkRuntimeClient|FullyQualifiedName~EmbeddedRuntimeTrafficPolicy|FullyQualifiedName~RuntimeFoundationCatalog"` - - `dotnet format DotPilot.slnx --verify-no-changes` -- [x] PR `#82` focused verification passed: - - `dotnet build DotPilot.slnx -warnaserror -m:1 -p:BuildInParallel=false` - - `dotnet test DotPilot.Tests/DotPilot.Tests.csproj --filter "FullyQualifiedName~ControlPlaneDomain"` - - `dotnet format DotPilot.slnx --verify-no-changes` -- [x] Full repo validation passed on every updated PR head: - - PR `#79` (`codex/consolidated-13-15-76`): `60` unit tests, `22` UI tests, coverage collector green. - - PR `#80` (`codex/issue-24-embedded-orleans-host`): `68` unit tests, `22` UI tests, coverage collector green. - - PR `#81` (`codex/epic-12-embedded-runtime`): `75` unit tests, `22` UI tests, coverage collector green. - - PR `#82` (`codex/epic-11-foundation-contracts`): `61` unit tests, `22` UI tests, coverage collector green. - -## Tracked Failing Tests - -- [x] No failing tests remained after the PR sweep. - -## Done Criteria - -- Every meaningful open review comment across PRs `#79-#82` has been either fixed or explicitly rejected as stale/invalid. -- Relevant focused tests are green after each PR-specific fix set. -- The full repo validation sequence is green after the full sweep. From b3e67a57ea41f005fd37dd464bec2e0943b85cbc Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 13:37:32 +0100 Subject: [PATCH 03/30] Persist local agent history and grain state --- .../FolderChatHistoryProvider.cs | 30 ++++++++++++++++- .../AgentSessionPersistenceTests.cs | 2 +- docs/Architecture.md | 33 ++++++++++++++----- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs b/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs index 2fd9586..225ac5e 100644 --- a/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs +++ b/DotPilot.Runtime/Features/AgentSessions/FolderChatHistoryProvider.cs @@ -56,10 +56,23 @@ protected override async ValueTask StoreChatHistoryAsync( return; } + var existing = await chatHistoryStore.LoadAsync(storageKey, cancellationToken); + var knownMessageKeys = existing + .Select(CreateMessageKey) + .ToHashSet(StringComparer.Ordinal); var responseMessages = context.ResponseMessages ?? []; + var newMessages = context.RequestMessages + .Concat(responseMessages) + .Where(message => knownMessageKeys.Add(CreateMessageKey(message))) + .ToArray(); + if (newMessages.Length == 0) + { + return; + } + await chatHistoryStore.AppendAsync( storageKey, - context.RequestMessages.Concat(responseMessages), + newMessages, cancellationToken); } @@ -70,6 +83,21 @@ await chatHistoryStore.AppendAsync( var state = SessionState.GetOrInitializeState(session); return string.IsNullOrWhiteSpace(state.StorageKey) ? null : state.StorageKey; } + + private static string CreateMessageKey(ChatMessage message) + { + ArgumentNullException.ThrowIfNull(message); + + return string.IsNullOrWhiteSpace(message.MessageId) + ? string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "{0}|{1}|{2:O}|{3}", + message.Role, + message.AuthorName, + message.CreatedAt, + message.Text) + : message.MessageId; + } } internal sealed class FolderChatHistoryState diff --git a/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs index 0e2c977..1de32f6 100644 --- a/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs +++ b/DotPilot.Tests/Features/AgentSessions/AgentSessionPersistenceTests.cs @@ -22,7 +22,7 @@ public async Task SendMessageAsyncPersistsFolderBackedAgentSessionAndHistoryAcro try { SessionId sessionId; - AgentSessionStorageOptions storageOptions = CreateStorageOptions(root); + var storageOptions = CreateStorageOptions(root); await using (var firstFixture = CreateFixture(storageOptions)) { diff --git a/docs/Architecture.md b/docs/Architecture.md index 13897e1..bbedb0d 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -9,13 +9,13 @@ This file is the required start-here architecture map for non-trivial tasks. - **Product shape:** `DotPilot` is a desktop chat client for local agent sessions. The default operator flow is: open settings, verify providers, create an agent, start or resume a session, send a message, and watch streaming status/tool output in the transcript. - **Presentation boundary:** [../DotPilot/](../DotPilot/) is the `Uno Platform` shell only. It owns desktop startup, routes, XAML composition, and visible operator flows such as session list, transcript, agent creation, and provider settings. - **Contracts boundary:** [../DotPilot.Core/](../DotPilot.Core/) owns the durable non-UI contracts for provider readiness, agent profiles, session lists, transcript entries, commands, and Orleans grain interfaces. -- **Runtime boundary:** [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider catalogs, CLI readiness checks, deterministic debug-provider behavior, `EF Core` + `SQLite` persistence, and the `IAgentSessionService` implementation. -- **Embedded host boundary:** [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host and the grains that represent session and agent-profile state. The first product wave stays local-first with `UseLocalhostClustering` plus in-memory Orleans storage/reminders, while durable product state lives in the local `SQLite` store. +- **Runtime boundary:** [../DotPilot.Runtime/](../DotPilot.Runtime/) owns provider catalogs, CLI readiness checks, deterministic debug-provider behavior, `EF Core` + `SQLite` projection persistence, local folder-backed `AgentSession` storage, local folder-backed chat-history persistence through `ChatHistoryProvider`, and the `IAgentSessionService` implementation. +- **Embedded host boundary:** [../DotPilot.Runtime.Host/](../DotPilot.Runtime.Host/) owns the embedded Orleans host and the grains that represent session and agent-profile state. The product stays local-first with `UseLocalhostClustering`, in-memory reminders, and local folder-backed Orleans grain storage through `ManagedCode.Storage`. - **Verification boundary:** [../DotPilot.Tests/](../DotPilot.Tests/) covers caller-visible runtime, persistence, contract, and view-model flows through public boundaries. [../DotPilot.UITests/](../DotPilot.UITests/) covers the desktop operator journey from provider setup to streaming chat. ## Scoping -- **In scope for the active rewrite:** chat-first session UX, provider readiness/settings, agent creation, Orleans-backed session and agent state, local persistence via `SQLite`, deterministic debug provider, transcript/tool streaming, and optional repo/git utilities inside a session. +- **In scope for the active rewrite:** chat-first session UX, provider readiness/settings, agent creation, Orleans-backed session and agent state, local persistence via `SQLite`, local folder-backed `AgentSession` and chat-history storage, deterministic debug provider, transcript/tool streaming, and optional repo/git utilities inside a session. - **In scope for later slices:** multi-agent sessions, richer workflow composition, provider-specific live execution, session export/replay, and deeper git/worktree utilities. - **Out of scope in the current repository slice:** remote workers, remote Orleans clustering, cloud persistence, multi-user identity, and external durable stores. @@ -30,8 +30,8 @@ flowchart LR Architecture["docs/Architecture.md"] Ui["DotPilot Uno desktop shell"] Core["DotPilot.Core contracts"] - Runtime["DotPilot.Runtime runtime + SQLite"] - Host["DotPilot.Runtime.Host Orleans host + grains"] + Runtime["DotPilot.Runtime runtime + SQLite + folder session storage"] + Host["DotPilot.Runtime.Host Orleans host + grains + folder grain state"] Unit["DotPilot.Tests"] UiTests["DotPilot.UITests"] @@ -83,18 +83,23 @@ flowchart TD Ui["Uno shell"] ViewModels["Session / agent / settings view models"] Service["IAgentSessionService"] - Store["EF Core + SQLite"] + ProjectionStore["EF Core + SQLite projections"] + SessionStore["Folder AgentSession + chat history"] SessionGrain["ISessionGrain"] AgentGrain["IAgentProfileGrain"] + GrainStore["ManagedCode.Storage Orleans filesystem store"] ProviderCatalog["Provider catalog + readiness probe"] ProviderClient["Provider SDK / IChatClient or debug client"] Stream["SessionStreamEntry updates"] Ui --> ViewModels ViewModels --> Service - Service --> Store + Service --> ProjectionStore + Service --> SessionStore Service --> SessionGrain Service --> AgentGrain + SessionGrain --> GrainStore + AgentGrain --> GrainStore Service --> ProviderCatalog ProviderCatalog --> ProviderClient Service --> ProviderClient @@ -108,22 +113,28 @@ flowchart TD sequenceDiagram participant UI as Uno UI participant Service as AgentSessionService - participant DB as SQLite + participant DB as SQLite projections + participant FS as Local folder AgentSession/history store participant SG as SessionGrain participant AG as AgentProfileGrain + participant GS as Local folder Orleans grain store participant Provider as Provider SDK / Debug Client UI->>Service: CreateAgentAsync(...) Service->>DB: Save agent profile Service->>AG: UpsertAsync(agent profile) + AG->>GS: Persist grain state UI->>Service: CreateSessionAsync(...) Service->>DB: Save session + initial status entry + Service->>FS: Create/persist opaque AgentSession Service->>SG: UpsertAsync(session) + SG->>GS: Persist grain state UI->>Service: SendMessageAsync(...) Service->>DB: Save user message Service->>Provider: Run / stream Provider-->>Service: Streaming updates Service->>DB: Persist transcript entries + Service->>FS: Persist ChatHistoryProvider state + serialized AgentSession Service-->>UI: SessionStreamEntry updates ``` @@ -163,6 +174,10 @@ sequenceDiagram ## Review Focus - Keep the product framed as a chat-first local-agent client, not as a backlog-shaped workbench. -- Replace seed-data assumptions with real provider, agent, session, and transcript state. +- Replace seed-data assumptions with real provider, agent, session, transcript, and durable runtime state. - Keep repo/git operations as optional tools inside a session, not as the app's primary information architecture. - Prefer provider SDKs and `IChatClient`-style abstractions over custom parallel request/result wrappers unless a concrete gap forces an adapter layer. +- Keep the persistence split explicit: + - `SQLite` for operator-facing projections and settings + - local folder-backed `AgentSession` plus chat history for agent continuity + - local folder-backed Orleans storage for grain state From 842ad578d8e4dd213ce20afd46a703d51c67f781 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 14:36:18 +0100 Subject: [PATCH 04/30] Fix chat composer shortcuts and UI lag --- .../AgentProviderStatusSnapshotReader.cs | 124 +++++++++++++++++ .../AgentSessions/AgentSessionService.cs | 125 +++--------------- .../ChatComposerKeyboardPolicyTests.cs | 50 +++++++ .../AgentSessions/GivenChatSessionsShell.cs | 2 + DotPilot.UITests/Harness/TestBase.cs | 1 - .../ChatComposerKeyboardPolicy.cs | 23 ++++ DotPilot/Presentation/ChatDesignModels.cs | 18 ++- .../Presentation/Controls/ChatComposer.xaml | 103 +++++++++------ .../Controls/ChatComposer.xaml.cs | 70 ++++++++++ DotPilot/Presentation/MainViewModel.cs | 45 ++++++- 10 files changed, 408 insertions(+), 153 deletions(-) create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/ChatComposerKeyboardPolicyTests.cs create mode 100644 DotPilot/Presentation/ChatComposerKeyboardPolicy.cs diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs new file mode 100644 index 0000000..05134e4 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs @@ -0,0 +1,124 @@ +using DotPilot.Core.Features.AgentSessions; +using Microsoft.EntityFrameworkCore; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal static class AgentProviderStatusSnapshotReader +{ + private const string BrowserStatusSummary = "Desktop CLI probing is unavailable in the browser automation head."; + private const string DisabledStatusSummary = "Provider is disabled for local agent creation."; + private const string BuiltInStatusSummary = "Built in and ready for deterministic local testing."; + private const string MissingCliSummaryFormat = "{0} CLI is not installed."; + private const string ReadySummaryFormat = "{0} CLI is available on PATH."; + private static readonly System.Text.CompositeFormat MissingCliSummaryCompositeFormat = + System.Text.CompositeFormat.Parse(MissingCliSummaryFormat); + private static readonly System.Text.CompositeFormat ReadySummaryCompositeFormat = + System.Text.CompositeFormat.Parse(ReadySummaryFormat); + + public static async Task> BuildAsync( + LocalAgentSessionDbContext dbContext, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dbContext); + + var preferences = await dbContext.ProviderPreferences + .ToDictionaryAsync( + preference => (AgentProviderKind)preference.ProviderKind, + cancellationToken); + + return await Task.Run( + () => AgentSessionProviderCatalog.All + .Select(profile => BuildProviderStatus(profile, GetProviderPreference(profile.Kind, preferences))) + .ToArray(), + cancellationToken); + } + + public static async Task IsEnabledAsync( + LocalAgentSessionDbContext dbContext, + AgentProviderKind providerKind, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(dbContext); + + var record = await dbContext.ProviderPreferences + .FirstOrDefaultAsync( + preference => preference.ProviderKind == (int)providerKind, + cancellationToken); + + return record?.IsEnabled == true; + } + + private static ProviderPreferenceRecord GetProviderPreference( + AgentProviderKind kind, + Dictionary preferences) + { + return preferences.TryGetValue(kind, out var preference) + ? preference + : new ProviderPreferenceRecord + { + ProviderKind = (int)kind, + IsEnabled = false, + UpdatedAt = DateTimeOffset.MinValue, + }; + } + + private static ProviderStatusDescriptor BuildProviderStatus( + AgentSessionProviderProfile profile, + ProviderPreferenceRecord preference) + { + var providerId = AgentSessionDeterministicIdentity.CreateProviderId(profile.CommandName); + var actions = new List(); + string? installedVersion = null; + var status = AgentProviderStatus.Ready; + var statusSummary = BuiltInStatusSummary; + var canCreateAgents = true; + + if (OperatingSystem.IsBrowser() && !profile.IsBuiltIn) + { + actions.Add(new ProviderActionDescriptor("Install", "Run this on desktop.", profile.InstallCommand)); + status = AgentProviderStatus.Unsupported; + statusSummary = BrowserStatusSummary; + canCreateAgents = false; + } + else if (profile.IsBuiltIn) + { + installedVersion = profile.DefaultModelName; + } + else + { + var executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); + if (string.IsNullOrWhiteSpace(executablePath)) + { + actions.Add(new ProviderActionDescriptor("Install", "Install the CLI, then refresh settings.", profile.InstallCommand)); + status = AgentProviderStatus.RequiresSetup; + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, MissingCliSummaryCompositeFormat, profile.DisplayName); + canCreateAgents = false; + } + else + { + installedVersion = AgentSessionCommandProbe.ReadVersion(executablePath, ["--version"]); + actions.Add(new ProviderActionDescriptor("Open CLI", "CLI detected on PATH.", $"{profile.CommandName} --version")); + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); + } + } + + if (!preference.IsEnabled) + { + status = AgentProviderStatus.Disabled; + statusSummary = $"{DisabledStatusSummary} {statusSummary}"; + canCreateAgents = false; + } + + return new ProviderStatusDescriptor( + providerId, + profile.Kind, + profile.DisplayName, + profile.CommandName, + status, + statusSummary, + installedVersion, + preference.IsEnabled, + canCreateAgents, + actions); + } +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs index 20ab371..58ca93d 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs @@ -12,25 +12,17 @@ internal sealed class AgentSessionService( TimeProvider timeProvider) : IAgentSessionService, IDisposable { - private const string BrowserStatusSummary = "Desktop CLI probing is unavailable in the browser automation head."; - private const string DisabledStatusSummary = "Provider is disabled for local agent creation."; - private const string BuiltInStatusSummary = "Built in and ready for deterministic local testing."; - private const string MissingCliSummaryFormat = "{0} CLI is not installed."; - private const string ReadySummaryFormat = "{0} CLI is available on PATH."; private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice."; private const string SessionReadyText = "Session created. Send the first message to start the workflow."; private const string UserAuthor = "You"; private const string ToolAuthor = "Tool"; private const string StatusAuthor = "System"; + private const string DisabledProviderSendText = "The provider for this agent is disabled. Re-enable it in settings before sending."; private const string DebugToolStartText = "Preparing local debug workflow."; private const string DebugToolDoneText = "Debug workflow finished."; private const string ToolAccentLabel = "tool"; private const string StatusAccentLabel = "status"; private const string ErrorAccentLabel = "error"; - private static readonly System.Text.CompositeFormat MissingCliSummaryCompositeFormat = - System.Text.CompositeFormat.Parse(MissingCliSummaryFormat); - private static readonly System.Text.CompositeFormat ReadySummaryCompositeFormat = - System.Text.CompositeFormat.Parse(ReadySummaryFormat); private static readonly System.Text.CompositeFormat NotYetImplementedCompositeFormat = System.Text.CompositeFormat.Parse(NotYetImplementedFormat); private readonly SemaphoreSlim _initializationGate = new(1, 1); @@ -61,7 +53,7 @@ public async ValueTask GetWorkspaceAsync(CancellationTok return new AgentWorkspaceSnapshot( sessionItems, agents.Select(MapAgentSummary).ToArray(), - await BuildProviderStatusesAsync(dbContext, cancellationToken), + await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken), sessionItems.Length > 0 ? sessionItems[0].Id : null); } @@ -101,7 +93,7 @@ public async ValueTask CreateAgentAsync( await EnsureInitializedAsync(cancellationToken); await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var providers = await BuildProviderStatusesAsync(dbContext, cancellationToken); + var providers = await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken); var provider = providers.First(status => status.Kind == command.ProviderKind); if (!provider.CanCreateAgents) { @@ -182,7 +174,7 @@ public async ValueTask UpdateProviderAsync( record.UpdatedAt = timeProvider.GetUtcNow(); await dbContext.SaveChangesAsync(cancellationToken); - return (await BuildProviderStatusesAsync(dbContext, cancellationToken)) + return (await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken)) .First(status => status.Kind == command.ProviderKind); } @@ -199,8 +191,6 @@ public async IAsyncEnumerable SendMessageAsync( var agent = await dbContext.AgentProfiles .FirstAsync(record => record.Id == session.PrimaryAgentProfileId, cancellationToken); var providerProfile = AgentSessionProviderCatalog.Get((AgentProviderKind)agent.ProviderKind); - var providerStatuses = await BuildProviderStatusesAsync(dbContext, cancellationToken); - var providerStatus = providerStatuses.First(status => status.Kind == providerProfile.Kind); var runtimeConversation = await runtimeConversationFactory.LoadOrCreateAsync(agent, command.SessionId, cancellationToken); var now = timeProvider.GetUtcNow(); @@ -220,7 +210,24 @@ public async IAsyncEnumerable SendMessageAsync( accentLabel: StatusAccentLabel); yield return MapEntry(statusEntry); - if (!providerStatus.CanCreateAgents || providerProfile.Kind is not AgentProviderKind.Debug) + if (!await AgentProviderStatusSnapshotReader.IsEnabledAsync(dbContext, providerProfile.Kind, cancellationToken)) + { + var disabledEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.Error, + StatusAuthor, + DisabledProviderSendText, + timeProvider.GetUtcNow(), + accentLabel: ErrorAccentLabel); + dbContext.SessionEntries.Add(disabledEntry); + session.UpdatedAt = disabledEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + yield return MapEntry(disabledEntry); + yield break; + } + + if (providerProfile.Kind is not AgentProviderKind.Debug) { var notImplementedEntry = CreateEntryRecord( command.SessionId, @@ -334,94 +341,6 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken) } } - private static async Task> BuildProviderStatusesAsync( - LocalAgentSessionDbContext dbContext, - CancellationToken cancellationToken) - { - var preferences = await dbContext.ProviderPreferences - .ToDictionaryAsync( - preference => (AgentProviderKind)preference.ProviderKind, - cancellationToken); - - return AgentSessionProviderCatalog.All - .Select(profile => BuildProviderStatus(profile, GetProviderPreference(profile.Kind, preferences))) - .ToArray(); - } - - private static ProviderPreferenceRecord GetProviderPreference( - AgentProviderKind kind, - Dictionary preferences) - { - return preferences.TryGetValue(kind, out var preference) - ? preference - : new ProviderPreferenceRecord - { - ProviderKind = (int)kind, - IsEnabled = false, - UpdatedAt = DateTimeOffset.MinValue, - }; - } - - private static ProviderStatusDescriptor BuildProviderStatus( - AgentSessionProviderProfile profile, - ProviderPreferenceRecord preference) - { - var providerId = AgentSessionDeterministicIdentity.CreateProviderId(profile.CommandName); - var actions = new List(); - string? installedVersion = null; - var status = AgentProviderStatus.Ready; - var statusSummary = BuiltInStatusSummary; - var canCreateAgents = true; - - if (OperatingSystem.IsBrowser() && !profile.IsBuiltIn) - { - actions.Add(new ProviderActionDescriptor("Install", "Run this on desktop.", profile.InstallCommand)); - status = AgentProviderStatus.Unsupported; - statusSummary = BrowserStatusSummary; - canCreateAgents = false; - } - else if (profile.IsBuiltIn) - { - installedVersion = profile.DefaultModelName; - } - else - { - var executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); - if (string.IsNullOrWhiteSpace(executablePath)) - { - actions.Add(new ProviderActionDescriptor("Install", "Install the CLI, then refresh settings.", profile.InstallCommand)); - status = AgentProviderStatus.RequiresSetup; - statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, MissingCliSummaryCompositeFormat, profile.DisplayName); - canCreateAgents = false; - } - else - { - installedVersion = AgentSessionCommandProbe.ReadVersion(executablePath, ["--version"]); - actions.Add(new ProviderActionDescriptor("Open CLI", "CLI detected on PATH.", $"{profile.CommandName} --version")); - statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); - } - } - - if (!preference.IsEnabled) - { - status = AgentProviderStatus.Disabled; - statusSummary = $"{DisabledStatusSummary} {statusSummary}"; - canCreateAgents = false; - } - - return new ProviderStatusDescriptor( - providerId, - profile.Kind, - profile.DisplayName, - profile.CommandName, - status, - statusSummary, - installedVersion, - preference.IsEnabled, - canCreateAgents, - actions); - } - private static SessionEntryRecord CreateEntryRecord( SessionId sessionId, SessionStreamEntryKind kind, diff --git a/DotPilot.Tests/Features/AgentSessions/ChatComposerKeyboardPolicyTests.cs b/DotPilot.Tests/Features/AgentSessions/ChatComposerKeyboardPolicyTests.cs new file mode 100644 index 0000000..8eee529 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/ChatComposerKeyboardPolicyTests.cs @@ -0,0 +1,50 @@ +using DotPilot.Presentation; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class ChatComposerKeyboardPolicyTests +{ + [Test] + public void ResolveReturnsSendMessageForPlainEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + isEnterKey: true, + isShiftPressed: false, + isAltPressed: false); + + action.Should().Be(ChatComposerKeyboardAction.SendMessage); + } + + [Test] + public void ResolveReturnsInsertNewLineForShiftEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + isEnterKey: true, + isShiftPressed: true, + isAltPressed: false); + + action.Should().Be(ChatComposerKeyboardAction.InsertNewLine); + } + + [Test] + public void ResolveReturnsInsertNewLineForAltEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + isEnterKey: true, + isShiftPressed: false, + isAltPressed: true); + + action.Should().Be(ChatComposerKeyboardAction.InsertNewLine); + } + + [Test] + public void ResolveReturnsNoneWhenKeyIsNotEnter() + { + var action = ChatComposerKeyboardPolicy.Resolve( + isEnterKey: false, + isShiftPressed: true, + isAltPressed: true); + + action.Should().Be(ChatComposerKeyboardAction.None); + } +} diff --git a/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs index bb2775f..68d006f 100644 --- a/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs +++ b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs @@ -25,6 +25,7 @@ public sealed class GivenChatSessionsShell : TestBase private const string AgentCreateStatusMessageAutomationId = "AgentCreateStatusMessage"; private const string CreateAgentButtonAutomationId = "CreateAgentButton"; private const string ChatComposerInputAutomationId = "ChatComposerInput"; + private const string ChatComposerHintAutomationId = "ChatComposerHint"; private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton"; private const string ChatStartNewButtonAutomationId = "ChatStartNewButton"; private const string ChatTitleTextAutomationId = "ChatTitleText"; @@ -46,6 +47,7 @@ public async Task WhenOpeningTheAppThenChatNavigationAndComposerAreVisible() EnsureOnChatScreen(); WaitForElement(ChatTitleTextAutomationId); WaitForElement(ChatComposerInputAutomationId); + WaitForTextContains(ChatComposerHintAutomationId, "Enter sends.", ScreenTransitionTimeout); WaitForElement(ChatStartNewButtonAutomationId); TakeScreenshot("chat_shell_visible"); diff --git a/DotPilot.UITests/Harness/TestBase.cs b/DotPilot.UITests/Harness/TestBase.cs index 9c11dec..0380274 100644 --- a/DotPilot.UITests/Harness/TestBase.cs +++ b/DotPilot.UITests/Harness/TestBase.cs @@ -1,4 +1,3 @@ - namespace DotPilot.UITests.Harness; [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/DotPilot/Presentation/ChatComposerKeyboardPolicy.cs b/DotPilot/Presentation/ChatComposerKeyboardPolicy.cs new file mode 100644 index 0000000..84ea951 --- /dev/null +++ b/DotPilot/Presentation/ChatComposerKeyboardPolicy.cs @@ -0,0 +1,23 @@ +namespace DotPilot.Presentation; + +public static class ChatComposerKeyboardPolicy +{ + public static ChatComposerKeyboardAction Resolve(bool isEnterKey, bool isShiftPressed, bool isAltPressed) + { + if (!isEnterKey) + { + return ChatComposerKeyboardAction.None; + } + + return isShiftPressed || isAltPressed + ? ChatComposerKeyboardAction.InsertNewLine + : ChatComposerKeyboardAction.SendMessage; + } +} + +public enum ChatComposerKeyboardAction +{ + None, + SendMessage, + InsertNewLine, +} diff --git a/DotPilot/Presentation/ChatDesignModels.cs b/DotPilot/Presentation/ChatDesignModels.cs index ba0d570..e6f2159 100644 --- a/DotPilot/Presentation/ChatDesignModels.cs +++ b/DotPilot/Presentation/ChatDesignModels.cs @@ -3,10 +3,20 @@ namespace DotPilot.Presentation; -public sealed partial record SessionSidebarItem( - SessionId Id, - string Title, - string Preview); +public sealed class SessionSidebarItem(SessionId id, string title, string preview) : ObservableObject +{ + private string _preview = preview; + + public SessionId Id { get; } = id; + + public string Title { get; } = title; + + public string Preview + { + get => _preview; + set => SetProperty(ref _preview, value); + } +} public sealed partial record ChatTimelineItem( string Id, diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml b/DotPilot/Presentation/Controls/ChatComposer.xaml index a1212f8..769f8b5 100644 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml +++ b/DotPilot/Presentation/Controls/ChatComposer.xaml @@ -12,62 +12,83 @@ BorderBrush="{StaticResource AppOutlineBrush}" BorderThickness="1" CornerRadius="28" - Padding="8"> + Padding="12"> - - - - - + RowSpacing="12"> + + + + - + TextWrapping="Wrap" + VerticalAlignment="Stretch" + VerticalContentAlignment="Top" /> - + + + + + + - + Text="New session" /> + + + + diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml.cs b/DotPilot/Presentation/Controls/ChatComposer.xaml.cs index 787981f..07998ad 100644 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml.cs +++ b/DotPilot/Presentation/Controls/ChatComposer.xaml.cs @@ -1,9 +1,79 @@ +using Microsoft.UI.Input; +using Microsoft.UI.Xaml.Input; +using Windows.System; +using Windows.UI.Core; + namespace DotPilot.Presentation.Controls; public sealed partial class ChatComposer : UserControl { + private const string NewLineValue = "\n"; + public ChatComposer() { InitializeComponent(); } + + private void OnComposerInputKeyDown(object sender, KeyRoutedEventArgs e) + { + if (sender is not TextBox textBox) + { + return; + } + + var action = ChatComposerKeyboardPolicy.Resolve( + isEnterKey: e.Key is VirtualKey.Enter, + isShiftPressed: IsKeyPressed(VirtualKey.Shift), + isAltPressed: IsKeyPressed(VirtualKey.Menu)); + if (action is ChatComposerKeyboardAction.SendMessage) + { + ExecuteSubmitCommand(); + e.Handled = true; + return; + } + + if (action is not ChatComposerKeyboardAction.InsertNewLine) + { + return; + } + + InsertNewLine(textBox); + e.Handled = true; + } + + private void ExecuteSubmitCommand() + { + if (DataContext is not MainViewModel viewModel) + { + return; + } + + if (!viewModel.SendMessageCommand.CanExecute(parameter: null)) + { + return; + } + + viewModel.SendMessageCommand.Execute(parameter: null); + } + + private static bool IsKeyPressed(VirtualKey key) + { + return InputKeyboardSource.GetKeyStateForCurrentThread(key).HasFlag(CoreVirtualKeyStates.Down); + } + + private static void InsertNewLine(TextBox textBox) + { + ArgumentNullException.ThrowIfNull(textBox); + + var currentText = textBox.Text ?? string.Empty; + var insertionIndex = Math.Clamp(textBox.SelectionStart, 0, currentText.Length); + var selectionLength = Math.Clamp(textBox.SelectionLength, 0, currentText.Length - insertionIndex); + var updatedText = currentText + .Remove(insertionIndex, selectionLength) + .Insert(insertionIndex, NewLineValue); + + textBox.Text = updatedText; + textBox.SelectionStart = insertionIndex + NewLineValue.Length; + textBox.SelectionLength = 0; + } } diff --git a/DotPilot/Presentation/MainViewModel.cs b/DotPilot/Presentation/MainViewModel.cs index 488e8ac..4b082c2 100644 --- a/DotPilot/Presentation/MainViewModel.cs +++ b/DotPilot/Presentation/MainViewModel.cs @@ -1,6 +1,7 @@ using System.Collections.ObjectModel; using System.Globalization; using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; namespace DotPilot.Presentation; @@ -178,8 +179,7 @@ private async Task StartNewSessionAsync() var session = await _agentSessionService.CreateSessionAsync( new CreateSessionCommand($"Session with {agent.Name}", agent.Id), CancellationToken.None); - await LoadWorkspaceAsync(); - SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == session.Session.Id); + InsertOrUpdateRecentChat(session.Session, selectSession: true); } private async Task SendMessageAsync() @@ -192,6 +192,7 @@ private async Task SendMessageAsync() ComposerText = string.Empty; FeedbackMessage = SendInProgressMessage; + var latestPreview = message; try { @@ -208,11 +209,15 @@ private async Task SendMessageAsync() new SendSessionMessageCommand(SelectedChat.Id, message), CancellationToken.None)) { + if (entry.Kind is SessionStreamEntryKind.AssistantMessage && !string.IsNullOrWhiteSpace(entry.Text)) + { + latestPreview = entry.Text; + } + ApplyTimelineEntry(entry); } - await LoadWorkspaceAsync(); - SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == SelectedChat?.Id); + UpdateRecentChatPreview(SelectedChat.Id, latestPreview ?? message); FeedbackMessage = string.Empty; } catch (Exception exception) @@ -240,6 +245,38 @@ private void RebuildRecentChats(IReadOnlyList sessions) } } + private void InsertOrUpdateRecentChat(SessionListItem session, bool selectSession) + { + var existingIndex = RecentChats + .Select((item, index) => new { item, index }) + .FirstOrDefault(pair => pair.item.Id == session.Id) + ?.index; + var sidebarItem = new SessionSidebarItem(session.Id, session.Title, session.Preview); + + if (existingIndex is int index) + { + RecentChats.RemoveAt(index); + } + + RecentChats.Insert(0, sidebarItem); + + if (selectSession) + { + SelectedChat = sidebarItem; + } + } + + private void UpdateRecentChatPreview(SessionId sessionId, string preview) + { + var existingItem = RecentChats.FirstOrDefault(item => item.Id == sessionId); + if (existingItem is null) + { + return; + } + + existingItem.Preview = preview; + } + private void RebuildTimeline(IReadOnlyList entries) { Messages.Clear(); From 7aa916bc5cd631f1c8ef1d06425dcdcbc72ac9b1 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 15:19:22 +0100 Subject: [PATCH 05/30] fixes --- AGENTS.md | 6 + DotPilot.Runtime/AGENTS.md | 3 + .../AgentSessions/AgentProviderStatusCache.cs | 125 +++++++++ .../AgentProviderStatusSnapshotReader.cs | 68 ++--- .../AgentRuntimeConversationFactory.cs | 24 +- .../AgentSessionProviderCatalog.cs | 12 +- .../AgentSessions/AgentSessionRuntimeLog.cs | 224 ++++++++++++++++ .../AgentSessions/AgentSessionService.cs | 243 ++++++++++++------ ...AgentSessionServiceCollectionExtensions.cs | 4 + .../IAgentProviderStatusCache.cs | 10 + .../AgentProviderStatusCacheTests.cs | 163 ++++++++++++ .../AgentSessions/AgentSessionLoggingTests.cs | 126 +++++++++ DotPilot/AGENTS.md | 4 +- DotPilot/App.xaml.cs | 7 + DotPilot/Presentation/AgentBuilderModels.cs | 4 + DotPilot/Presentation/AsyncCommand.cs | 11 +- DotPilot/Presentation/ChatDesignModels.cs | 6 + DotPilot/Presentation/MainViewModel.cs | 156 ++++++++--- DotPilot/Presentation/PresentationLog.cs | 63 +++++ DotPilot/Presentation/SecondViewModel.cs | 95 +++++-- DotPilot/Presentation/SettingsViewModel.cs | 122 +++++++-- DotPilot/Presentation/ShellViewModel.cs | 3 + 22 files changed, 1286 insertions(+), 193 deletions(-) create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusCache.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/IAgentProviderStatusCache.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentProviderStatusCacheTests.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentSessionLoggingTests.cs create mode 100644 DotPilot/Presentation/PresentationLog.cs diff --git a/AGENTS.md b/AGENTS.md index c37e83b..2d263bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -161,14 +161,20 @@ For this app: - GitHub is the backlog, not the product: use issues and PRs only to drive task scope and traceability, and never copy GitHub issue text, labels, workflow language, or tracker metadata into production code, runtime snapshots, or user-facing UI - never claim an epic is complete until its current GitHub scope is verified against the live issue graph; check which issues are real children versus issues that merely depend on the epic or belong to a different parent epic - Desktop responsiveness is a product requirement: avoid synchronous probe, filesystem, network, or process work on UI-facing construction and navigation paths so the app stays fast and immediately reactive +- Prefer a thin desktop presentation layer over UI-owned orchestration: long-running work, background coordination, and durable session state should live in Orleans/runtime boundaries, while the Uno UI mainly renders state and forwards operator commands - Do not invent a repo-specific product framing such as "workbench" unless the active issue or feature spec explicitly uses it; implement the app features described in the backlog instead of turning internal implementation language into the product narrative - The primary product IA is a desktop chat client for local agents: session list, active session transcript, terminal-like streaming activity, agent management, and provider settings must be the default mental model instead of workbench, issue-tracking, domain-browser, or toolchain-center concepts - User-facing UI must not expose backlog numbers, issue labels, workstream labels, "workbench", "domain", or similar internal planning and architecture language unless a feature explicitly exists to show source-control metadata - Provider integrations should stay SDK-first: when Codex, Claude Code, GitHub Copilot, or debug/test providers already expose an `IChatClient`-style abstraction, build agent orchestration on top of that instead of inventing parallel request/result wrappers without a clear gap +- Do not leave Uno binding on reflection fallback: when the shell binds to view models or option models, annotate or shape those types so the generated metadata provider can resolve them without runtime reflection warnings or performance loss - Persist app models and durable session state through `SQLite` plus `EF Core` when the data must survive restarts; do not keep the core chat/session experience trapped in seed data or transient in-memory catalogs - Model agents and sessions as Orleans grains, with each session acting as the workflow container that coordinates participant agents and streams messages, tool activity, and status updates into the UI - When agent conversations must survive restarts, persist the full `AgentSession` plus chat history through an Agent Framework history/storage provider backed by a local desktop folder; do not reduce durable conversation state to transcript text rows only - When Orleans grain state for agents or sessions must survive restarts on the local desktop host, use a local folder-backed Orleans storage provider instead of leaving those grains on in-memory persistence +- Local desktop navigation must not reprobe provider CLIs, model catalogs, or other expensive environment state on every screen switch; keep an internal cached loop for installed toolchain/model state and refresh it explicitly or in the background +- Runtime and orchestration flows must emit structured `ILogger` logs for provider readiness, agent creation, session creation, send execution, and failure paths; ad hoc console-only startup traces are not enough to debug the product +- UI-facing view models must stay projection-only: do not keep orchestration, provider probing, session loading pipelines, or other runtime coordination in the Uno presentation layer when the same work can live in runtime or Orleans services +- Desktop navigation and tab/menu switching must stay memory-fast: screen changes should reuse cached in-memory state and background refresh loops instead of starting fresh filesystem, process, or provider work from the UI thread - Do not keep legacy product slices alive during a rewrite: when `Workbench`, `ToolchainCenter`, legacy runtime demos, or similar prototype surfaces are being replaced, remove them instead of leaving a parallel legacy path in the codebase - GitHub Actions workflows must use descriptive names and filenames that reflect their purpose; do not use a generic `ci.yml` catch-all because build validation and release automation are separate operator flows - GitHub Actions must be split into at least one validation workflow for normal builds/tests and one release workflow for CI-driven version resolution, release-note generation, desktop publishing, and GitHub Release publication diff --git a/DotPilot.Runtime/AGENTS.md b/DotPilot.Runtime/AGENTS.md index ace0493..f03804e 100644 --- a/DotPilot.Runtime/AGENTS.md +++ b/DotPilot.Runtime/AGENTS.md @@ -19,11 +19,14 @@ Stack: `.NET 10`, class library, provider-backed runtime services, local persist - Keep this project free of `Uno Platform`, XAML, and page/view-model logic. - Implement feature slices against `DotPilot.Core` contracts instead of reaching back into the app project. - Prefer deterministic runtime behavior, provider readiness probing, and `SQLite`-backed persistence here so tests can exercise real flows without mocks. +- Keep provider readiness and installed-model/toolchain state behind a runtime-owned cached loop so UI navigation does not rerun expensive probes on every view-model load. +- Keep hot-path state in memory and safe background workers: ordinary UI navigation should consume cached runtime projections, while refresh/probe/update loops run off the UI thread and publish changes back asynchronously. - When conversation continuity is required, keep the durable chat/session runtime state split correctly: - transcript and operator-facing projections can stay in `SQLite` - the opaque `AgentSession` and chat-history provider state should persist in a local folder-backed Agent Framework store - Keep external-provider assumptions soft: absence of Codex, Claude Code, or GitHub Copilot in CI must not break the provider-independent baseline. - For the first embedded Orleans host implementation, stay local-first with `UseLocalhostClustering` and in-memory storage/reminders so the desktop runtime remains self-contained. +- Use `ILogger` as the default diagnostics path for runtime operations; provider probes, agent/session lifecycle events, and provider execution failures should be observable without relying on console output. ## Local Commands diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusCache.cs b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusCache.cs new file mode 100644 index 0000000..bb31857 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusCache.cs @@ -0,0 +1,125 @@ +using System.Diagnostics; +using DotPilot.Core.Features.AgentSessions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class AgentProviderStatusCache( + IDbContextFactory dbContextFactory, + TimeProvider timeProvider, + ILogger logger) + : IAgentProviderStatusCache, IDisposable +{ + private static readonly TimeSpan SnapshotLifetime = TimeSpan.FromMinutes(5); + private const string MissingValue = ""; + private readonly SemaphoreSlim _refreshGate = new(1, 1); + private ProviderStatusSnapshot? _snapshot; + + public ValueTask> GetSnapshotAsync(CancellationToken cancellationToken) + { + return GetSnapshotCoreAsync(forceRefresh: false, cancellationToken); + } + + public ValueTask> RefreshAsync(CancellationToken cancellationToken) + { + return GetSnapshotCoreAsync(forceRefresh: true, cancellationToken); + } + + public void Dispose() + { + _refreshGate.Dispose(); + } + + private async ValueTask> GetSnapshotCoreAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + var snapshot = _snapshot; + if (!forceRefresh && snapshot is not null && !IsExpired(snapshot)) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var cacheAgeMilliseconds = (timeProvider.GetUtcNow() - snapshot.CreatedAt).TotalMilliseconds; + AgentProviderStatusCacheLog.CacheHit( + logger, + cacheAgeMilliseconds); + } + + return snapshot.Providers; + } + + await _refreshGate.WaitAsync(cancellationToken); + try + { + snapshot = _snapshot; + if (!forceRefresh && snapshot is not null && !IsExpired(snapshot)) + { + if (logger.IsEnabled(LogLevel.Information)) + { + var cacheAgeMilliseconds = (timeProvider.GetUtcNow() - snapshot.CreatedAt).TotalMilliseconds; + AgentProviderStatusCacheLog.CacheHit( + logger, + cacheAgeMilliseconds); + } + + return snapshot.Providers; + } + + var startedAt = Stopwatch.GetTimestamp(); + AgentProviderStatusCacheLog.RefreshStarted(logger, forceRefresh); + + try + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var probeResults = await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken); + var providers = probeResults + .Select(result => result.Descriptor) + .ToArray(); + + _snapshot = new ProviderStatusSnapshot(providers, timeProvider.GetUtcNow()); + + foreach (var probeResult in probeResults) + { + AgentProviderStatusCacheLog.ProbeCompleted( + logger, + probeResult.Descriptor.Kind, + probeResult.Descriptor.Status, + probeResult.Descriptor.IsEnabled, + probeResult.Descriptor.CanCreateAgents, + probeResult.Descriptor.InstalledVersion ?? MissingValue, + probeResult.ExecutablePath ?? MissingValue); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + var elapsedMilliseconds = Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds; + AgentProviderStatusCacheLog.RefreshCompleted( + logger, + providers.Length, + elapsedMilliseconds); + } + + return providers; + } + catch (Exception exception) + { + AgentProviderStatusCacheLog.RefreshFailed(logger, exception); + throw; + } + } + finally + { + _refreshGate.Release(); + } + } + + private bool IsExpired(ProviderStatusSnapshot snapshot) + { + return timeProvider.GetUtcNow() - snapshot.CreatedAt >= SnapshotLifetime; + } + + private sealed record ProviderStatusSnapshot( + IReadOnlyList Providers, + DateTimeOffset CreatedAt); +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs index 05134e4..3b527f9 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentProviderStatusSnapshotReader.cs @@ -10,12 +10,16 @@ internal static class AgentProviderStatusSnapshotReader private const string BuiltInStatusSummary = "Built in and ready for deterministic local testing."; private const string MissingCliSummaryFormat = "{0} CLI is not installed."; private const string ReadySummaryFormat = "{0} CLI is available on PATH."; + private const string LiveExecutionUnavailableSummaryFormat = + "{0} CLI is detected, but live session execution is not wired yet in this app slice."; private static readonly System.Text.CompositeFormat MissingCliSummaryCompositeFormat = System.Text.CompositeFormat.Parse(MissingCliSummaryFormat); private static readonly System.Text.CompositeFormat ReadySummaryCompositeFormat = System.Text.CompositeFormat.Parse(ReadySummaryFormat); + private static readonly System.Text.CompositeFormat LiveExecutionUnavailableCompositeFormat = + System.Text.CompositeFormat.Parse(LiveExecutionUnavailableSummaryFormat); - public static async Task> BuildAsync( + public static async Task> BuildAsync( LocalAgentSessionDbContext dbContext, CancellationToken cancellationToken) { @@ -33,21 +37,6 @@ public static async Task> BuildAsync( cancellationToken); } - public static async Task IsEnabledAsync( - LocalAgentSessionDbContext dbContext, - AgentProviderKind providerKind, - CancellationToken cancellationToken) - { - ArgumentNullException.ThrowIfNull(dbContext); - - var record = await dbContext.ProviderPreferences - .FirstOrDefaultAsync( - preference => preference.ProviderKind == (int)providerKind, - cancellationToken); - - return record?.IsEnabled == true; - } - private static ProviderPreferenceRecord GetProviderPreference( AgentProviderKind kind, Dictionary preferences) @@ -62,13 +51,14 @@ private static ProviderPreferenceRecord GetProviderPreference( }; } - private static ProviderStatusDescriptor BuildProviderStatus( + private static ProviderStatusProbeResult BuildProviderStatus( AgentSessionProviderProfile profile, ProviderPreferenceRecord preference) { var providerId = AgentSessionDeterministicIdentity.CreateProviderId(profile.CommandName); var actions = new List(); string? installedVersion = null; + string? executablePath = null; var status = AgentProviderStatus.Ready; var statusSummary = BuiltInStatusSummary; var canCreateAgents = true; @@ -86,7 +76,7 @@ private static ProviderStatusDescriptor BuildProviderStatus( } else { - var executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); + executablePath = AgentSessionCommandProbe.ResolveExecutablePath(profile.CommandName); if (string.IsNullOrWhiteSpace(executablePath)) { actions.Add(new ProviderActionDescriptor("Install", "Install the CLI, then refresh settings.", profile.InstallCommand)); @@ -98,7 +88,19 @@ private static ProviderStatusDescriptor BuildProviderStatus( { installedVersion = AgentSessionCommandProbe.ReadVersion(executablePath, ["--version"]); actions.Add(new ProviderActionDescriptor("Open CLI", "CLI detected on PATH.", $"{profile.CommandName} --version")); - statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); + if (profile.SupportsLiveExecution) + { + statusSummary = string.Format(System.Globalization.CultureInfo.InvariantCulture, ReadySummaryCompositeFormat, profile.DisplayName); + } + else + { + status = AgentProviderStatus.Error; + statusSummary = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + LiveExecutionUnavailableCompositeFormat, + profile.DisplayName); + canCreateAgents = false; + } } } @@ -109,16 +111,22 @@ private static ProviderStatusDescriptor BuildProviderStatus( canCreateAgents = false; } - return new ProviderStatusDescriptor( - providerId, - profile.Kind, - profile.DisplayName, - profile.CommandName, - status, - statusSummary, - installedVersion, - preference.IsEnabled, - canCreateAgents, - actions); + return new ProviderStatusProbeResult( + new ProviderStatusDescriptor( + providerId, + profile.Kind, + profile.DisplayName, + profile.CommandName, + status, + statusSummary, + installedVersion, + preference.IsEnabled, + canCreateAgents, + actions), + executablePath); } } + +internal sealed record ProviderStatusProbeResult( + ProviderStatusDescriptor Descriptor, + string? ExecutablePath); diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs b/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs index 285187d..eed7156 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentRuntimeConversationFactory.cs @@ -11,7 +11,8 @@ namespace DotPilot.Runtime.Features.AgentSessions; internal sealed class AgentRuntimeConversationFactory( LocalAgentSessionStateStore sessionStateStore, IServiceProvider serviceProvider, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice."; private static readonly System.Text.CompositeFormat NotYetImplementedCompositeFormat = @@ -22,8 +23,17 @@ public async ValueTask InitializeAsync( SessionId sessionId, CancellationToken cancellationToken) { + AgentRuntimeConversationFactoryLog.InitializeStarted(logger, sessionId, agentRecord.Id); var runtimeSession = await LoadOrCreateAsync(agentRecord, sessionId, cancellationToken); await sessionStateStore.SaveAsync(runtimeSession.Agent, runtimeSession.Session, sessionId, cancellationToken); + if (logger.IsEnabled(LogLevel.Information)) + { + var agentRuntimeId = agentRecord.Id.ToString("N", CultureInfo.InvariantCulture); + AgentRuntimeConversationFactoryLog.SessionSaved( + logger, + sessionId, + agentRuntimeId); + } } public async ValueTask LoadOrCreateAsync( @@ -41,6 +51,11 @@ public async ValueTask LoadOrCreateAsync( { session = await CreateNewSessionAsync(agent, sessionId, cancellationToken); await sessionStateStore.SaveAsync(agent, session, sessionId, cancellationToken); + AgentRuntimeConversationFactoryLog.SessionCreated(logger, sessionId, agentRecord.Id); + } + else + { + AgentRuntimeConversationFactoryLog.SessionLoaded(logger, sessionId, agentRecord.Id); } FolderChatHistoryProvider.BindToSession(session, sessionId); @@ -53,6 +68,7 @@ public ValueTask SaveAsync( CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(runtimeContext); + AgentRuntimeConversationFactoryLog.SessionSaved(logger, sessionId, runtimeContext.Agent.Id); return sessionStateStore.SaveAsync(runtimeContext.Agent, runtimeContext.Session, sessionId, cancellationToken); } @@ -86,6 +102,12 @@ private ChatClientAgent CreateAgent( }, }; + AgentRuntimeConversationFactoryLog.AgentRuntimeCreated( + logger, + agentRecord.Id, + agentRecord.Name, + providerProfile.Kind); + return CreateChatClient(providerProfile, agentRecord.Name) .AsAIAgent(options, loggerFactory, serviceProvider); } diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs index f75fdb7..16d5ba7 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionProviderCatalog.cs @@ -36,10 +36,10 @@ private static IReadOnlyList CreateProfiles() { return [ - new(AgentProviderKind.Debug, DebugDisplayName, DebugCommandName, DebugModelName, DebugInstallCommand, true), - new(AgentProviderKind.Codex, CodexDisplayName, CodexCommandName, CodexModelName, CodexInstallCommand, false), - new(AgentProviderKind.ClaudeCode, ClaudeDisplayName, ClaudeCommandName, ClaudeModelName, ClaudeInstallCommand, false), - new(AgentProviderKind.GitHubCopilot, CopilotDisplayName, CopilotCommandName, CopilotModelName, CopilotInstallCommand, false), + new(AgentProviderKind.Debug, DebugDisplayName, DebugCommandName, DebugModelName, DebugInstallCommand, true, true), + new(AgentProviderKind.Codex, CodexDisplayName, CodexCommandName, CodexModelName, CodexInstallCommand, false, false), + new(AgentProviderKind.ClaudeCode, ClaudeDisplayName, ClaudeCommandName, ClaudeModelName, ClaudeInstallCommand, false, false), + new(AgentProviderKind.GitHubCopilot, CopilotDisplayName, CopilotCommandName, CopilotModelName, CopilotInstallCommand, false, false), ]; } } @@ -50,5 +50,5 @@ internal sealed record AgentSessionProviderProfile( string CommandName, string DefaultModelName, string InstallCommand, - bool IsBuiltIn); - + bool IsBuiltIn, + bool SupportsLiveExecution); diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs new file mode 100644 index 0000000..a4a11f4 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs @@ -0,0 +1,224 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal static partial class AgentProviderStatusCacheLog +{ + [LoggerMessage( + EventId = 1000, + Level = LogLevel.Information, + Message = "Using cached provider readiness snapshot. AgeMilliseconds={AgeMilliseconds}.")] + public static partial void CacheHit(ILogger logger, double ageMilliseconds); + + [LoggerMessage( + EventId = 1001, + Level = LogLevel.Information, + Message = "Refreshing provider readiness snapshot. ForceRefresh={ForceRefresh}.")] + public static partial void RefreshStarted(ILogger logger, bool forceRefresh); + + [LoggerMessage( + EventId = 1002, + Level = LogLevel.Information, + Message = "Provider readiness snapshot refreshed for {ProviderCount} providers in {ElapsedMilliseconds} ms.")] + public static partial void RefreshCompleted(ILogger logger, int providerCount, double elapsedMilliseconds); + + [LoggerMessage( + EventId = 1003, + Level = LogLevel.Information, + Message = "Provider probe completed. Provider={ProviderKind} Status={Status} Enabled={IsEnabled} CanCreateAgents={CanCreateAgents} InstalledVersion={InstalledVersion} ExecutablePath={ExecutablePath}.")] + public static partial void ProbeCompleted( + ILogger logger, + AgentProviderKind providerKind, + AgentProviderStatus status, + bool isEnabled, + bool canCreateAgents, + string installedVersion, + string executablePath); + + [LoggerMessage( + EventId = 1004, + Level = LogLevel.Error, + Message = "Provider readiness snapshot refresh failed.")] + public static partial void RefreshFailed(ILogger logger, Exception exception); +} + +internal static partial class AgentRuntimeConversationFactoryLog +{ + [LoggerMessage( + EventId = 1100, + Level = LogLevel.Information, + Message = "Initializing runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void InitializeStarted(ILogger logger, SessionId sessionId, Guid agentId); + + [LoggerMessage( + EventId = 1101, + Level = LogLevel.Information, + Message = "Loaded persisted runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void SessionLoaded(ILogger logger, SessionId sessionId, Guid agentId); + + [LoggerMessage( + EventId = 1102, + Level = LogLevel.Information, + Message = "Created new runtime conversation session. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void SessionCreated(ILogger logger, SessionId sessionId, Guid agentId); + + [LoggerMessage( + EventId = 1103, + Level = LogLevel.Information, + Message = "Persisted runtime conversation state. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void SessionSaved(ILogger logger, SessionId sessionId, string agentId); + + [LoggerMessage( + EventId = 1104, + Level = LogLevel.Information, + Message = "Created runtime chat agent. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentRuntimeCreated( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind); +} + +internal static partial class AgentSessionServiceLog +{ + [LoggerMessage( + EventId = 1200, + Level = LogLevel.Information, + Message = "Initializing local agent session store.")] + public static partial void InitializationStarted(ILogger logger); + + [LoggerMessage( + EventId = 1201, + Level = LogLevel.Information, + Message = "Local agent session store initialized.")] + public static partial void InitializationCompleted(ILogger logger); + + [LoggerMessage( + EventId = 1202, + Level = LogLevel.Information, + Message = "Loaded workspace snapshot. Sessions={SessionCount} Agents={AgentCount} Providers={ProviderCount}.")] + public static partial void WorkspaceLoaded(ILogger logger, int sessionCount, int agentCount, int providerCount); + + [LoggerMessage( + EventId = 1203, + Level = LogLevel.Information, + Message = "Loaded session transcript. SessionId={SessionId} EntryCount={EntryCount} ParticipantCount={ParticipantCount}.")] + public static partial void SessionLoaded(ILogger logger, SessionId sessionId, int entryCount, int participantCount); + + [LoggerMessage( + EventId = 1204, + Level = LogLevel.Information, + Message = "Session transcript was requested but not found. SessionId={SessionId}.")] + public static partial void SessionNotFound(ILogger logger, SessionId sessionId); + + [LoggerMessage( + EventId = 1205, + Level = LogLevel.Information, + Message = "Creating agent profile. Name={AgentName} Provider={ProviderKind} Role={Role}.")] + public static partial void AgentCreationStarted( + ILogger logger, + string agentName, + AgentProviderKind providerKind, + AgentRoleKind role); + + [LoggerMessage( + EventId = 1206, + Level = LogLevel.Information, + Message = "Created agent profile. AgentId={AgentId} Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentCreated( + ILogger logger, + Guid agentId, + string agentName, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1207, + Level = LogLevel.Error, + Message = "Agent profile creation failed. Name={AgentName} Provider={ProviderKind}.")] + public static partial void AgentCreationFailed( + ILogger logger, + Exception exception, + string agentName, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1208, + Level = LogLevel.Information, + Message = "Creating session. Title={SessionTitle} AgentId={AgentId}.")] + public static partial void SessionCreationStarted(ILogger logger, string sessionTitle, AgentProfileId agentId); + + [LoggerMessage( + EventId = 1209, + Level = LogLevel.Information, + Message = "Created session. SessionId={SessionId} Title={SessionTitle} AgentId={AgentId}.")] + public static partial void SessionCreated( + ILogger logger, + SessionId sessionId, + string sessionTitle, + AgentProfileId agentId); + + [LoggerMessage( + EventId = 1210, + Level = LogLevel.Information, + Message = "Updated provider preference. Provider={ProviderKind} IsEnabled={IsEnabled}.")] + public static partial void ProviderPreferenceUpdated( + ILogger logger, + AgentProviderKind providerKind, + bool isEnabled); + + [LoggerMessage( + EventId = 1211, + Level = LogLevel.Error, + Message = "Provider preference update failed. Provider={ProviderKind} IsEnabled={IsEnabled}.")] + public static partial void ProviderPreferenceUpdateFailed( + ILogger logger, + Exception exception, + AgentProviderKind providerKind, + bool isEnabled); + + [LoggerMessage( + EventId = 1212, + Level = LogLevel.Information, + Message = "Starting session send. SessionId={SessionId} AgentId={AgentId} Provider={ProviderKind}.")] + public static partial void SendStarted( + ILogger logger, + SessionId sessionId, + Guid agentId, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1213, + Level = LogLevel.Warning, + Message = "Session send blocked because provider is disabled. SessionId={SessionId} Provider={ProviderKind}.")] + public static partial void SendBlockedDisabled( + ILogger logger, + SessionId sessionId, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1214, + Level = LogLevel.Warning, + Message = "Session send blocked because provider runtime is not wired. SessionId={SessionId} Provider={ProviderKind}.")] + public static partial void SendBlockedNotWired( + ILogger logger, + SessionId sessionId, + AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1215, + Level = LogLevel.Information, + Message = "Completed session send. SessionId={SessionId} AgentId={AgentId} AssistantCharacters={AssistantCharacterCount}.")] + public static partial void SendCompleted( + ILogger logger, + SessionId sessionId, + Guid agentId, + int assistantCharacterCount); + + [LoggerMessage( + EventId = 1216, + Level = LogLevel.Error, + Message = "Session send failed. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void SendFailed(ILogger logger, Exception exception, SessionId sessionId, Guid agentId); +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs index 58ca93d..2d551aa 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionService.cs @@ -2,14 +2,17 @@ using DotPilot.Core.Features.ControlPlaneDomain; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; namespace DotPilot.Runtime.Features.AgentSessions; internal sealed class AgentSessionService( IDbContextFactory dbContextFactory, + AgentProviderStatusCache providerStatusCache, AgentRuntimeConversationFactory runtimeConversationFactory, IServiceProvider serviceProvider, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) : IAgentSessionService, IDisposable { private const string NotYetImplementedFormat = "{0} live CLI execution is not wired yet in this slice."; @@ -49,11 +52,18 @@ public async ValueTask GetWorkspaceAsync(CancellationTok var sessionItems = sessions .Select(record => MapSessionListItem(record, agentsById, entries)) .ToArray(); + var providers = await providerStatusCache.GetSnapshotAsync(cancellationToken); + + AgentSessionServiceLog.WorkspaceLoaded( + logger, + sessionItems.Length, + agents.Count, + providers.Count); return new AgentWorkspaceSnapshot( sessionItems, agents.Select(MapAgentSummary).ToArray(), - await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken), + providers, sessionItems.Length > 0 ? sessionItems[0].Id : null); } @@ -66,6 +76,7 @@ await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken) .FirstOrDefaultAsync(record => record.Id == sessionId.Value, cancellationToken); if (session is null) { + AgentSessionServiceLog.SessionNotFound(logger, sessionId); return null; } @@ -79,10 +90,18 @@ await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken) .OrderBy(record => record.Timestamp) .ToList(); - return new SessionTranscriptSnapshot( + var snapshot = new SessionTranscriptSnapshot( MapSessionListItem(session, agentsById, entries), entries.Select(MapEntry).ToArray(), agents.Select(MapAgentSummary).ToArray()); + + AgentSessionServiceLog.SessionLoaded( + logger, + sessionId, + snapshot.Entries.Count, + snapshot.Participants.Count); + + return snapshot; } public async ValueTask CreateAgentAsync( @@ -91,33 +110,51 @@ public async ValueTask CreateAgentAsync( { ArgumentNullException.ThrowIfNull(command); await EnsureInitializedAsync(cancellationToken); + var agentName = command.Name.Trim(); + var modelName = command.ModelName.Trim(); + var systemPrompt = command.SystemPrompt.Trim(); + AgentSessionServiceLog.AgentCreationStarted(logger, agentName, command.ProviderKind, command.Role); - await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var providers = await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken); - var provider = providers.First(status => status.Kind == command.ProviderKind); - if (!provider.CanCreateAgents) + try { - throw new InvalidOperationException(provider.StatusSummary); - } + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var providers = await providerStatusCache.GetSnapshotAsync(cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + if (!provider.CanCreateAgents) + { + throw new InvalidOperationException(provider.StatusSummary); + } - var createdAt = timeProvider.GetUtcNow(); - var record = new AgentProfileRecord - { - Id = Guid.CreateVersion7(), - Name = command.Name.Trim(), - Role = (int)command.Role, - ProviderKind = (int)command.ProviderKind, - ModelName = command.ModelName.Trim(), - SystemPrompt = command.SystemPrompt.Trim(), - CapabilitiesJson = SerializeCapabilities(command.Capabilities), - CreatedAt = createdAt, - }; + var createdAt = timeProvider.GetUtcNow(); + var record = new AgentProfileRecord + { + Id = Guid.CreateVersion7(), + Name = agentName, + Role = (int)command.Role, + ProviderKind = (int)command.ProviderKind, + ModelName = modelName, + SystemPrompt = systemPrompt, + CapabilitiesJson = SerializeCapabilities(command.Capabilities), + CreatedAt = createdAt, + }; - dbContext.AgentProfiles.Add(record); - await dbContext.SaveChangesAsync(cancellationToken); - await UpsertAgentGrainAsync(MapAgentDescriptor(record)); + dbContext.AgentProfiles.Add(record); + await dbContext.SaveChangesAsync(cancellationToken); + await UpsertAgentGrainAsync(MapAgentDescriptor(record)); + + AgentSessionServiceLog.AgentCreated( + logger, + record.Id, + record.Name, + command.ProviderKind); - return MapAgentSummary(record); + return MapAgentSummary(record); + } + catch (Exception exception) + { + AgentSessionServiceLog.AgentCreationFailed(logger, exception, agentName, command.ProviderKind); + throw; + } } public async ValueTask CreateSessionAsync( @@ -126,6 +163,8 @@ public async ValueTask CreateSessionAsync( { ArgumentNullException.ThrowIfNull(command); await EnsureInitializedAsync(cancellationToken); + var sessionTitle = command.Title.Trim(); + AgentSessionServiceLog.SessionCreationStarted(logger, sessionTitle, command.AgentProfileId); await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var agent = await dbContext.AgentProfiles @@ -135,7 +174,7 @@ public async ValueTask CreateSessionAsync( var session = new SessionRecord { Id = sessionId.Value, - Title = command.Title.Trim(), + Title = sessionTitle, PrimaryAgentProfileId = agent.Id, CreatedAt = now, UpdatedAt = now, @@ -147,6 +186,8 @@ public async ValueTask CreateSessionAsync( await runtimeConversationFactory.InitializeAsync(agent, sessionId, cancellationToken); await UpsertSessionGrainAsync(session); + AgentSessionServiceLog.SessionCreated(logger, sessionId, session.Title, command.AgentProfileId); + return await GetSessionAsync(sessionId, cancellationToken) ?? throw new InvalidOperationException("Created session could not be reloaded."); } @@ -157,25 +198,40 @@ public async ValueTask UpdateProviderAsync( { ArgumentNullException.ThrowIfNull(command); await EnsureInitializedAsync(cancellationToken); - - await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); - var record = await dbContext.ProviderPreferences - .FirstOrDefaultAsync(preference => preference.ProviderKind == (int)command.ProviderKind, cancellationToken); - if (record is null) + try { - record = new ProviderPreferenceRecord + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + var record = await dbContext.ProviderPreferences + .FirstOrDefaultAsync(preference => preference.ProviderKind == (int)command.ProviderKind, cancellationToken); + if (record is null) { - ProviderKind = (int)command.ProviderKind, - }; - dbContext.ProviderPreferences.Add(record); - } + record = new ProviderPreferenceRecord + { + ProviderKind = (int)command.ProviderKind, + }; + dbContext.ProviderPreferences.Add(record); + } - record.IsEnabled = command.IsEnabled; - record.UpdatedAt = timeProvider.GetUtcNow(); - await dbContext.SaveChangesAsync(cancellationToken); + record.IsEnabled = command.IsEnabled; + record.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + + var providers = await providerStatusCache.RefreshAsync(cancellationToken); + var provider = providers.First(status => status.Kind == command.ProviderKind); + + AgentSessionServiceLog.ProviderPreferenceUpdated(logger, command.ProviderKind, command.IsEnabled); - return (await AgentProviderStatusSnapshotReader.BuildAsync(dbContext, cancellationToken)) - .First(status => status.Kind == command.ProviderKind); + return provider; + } + catch (Exception exception) + { + AgentSessionServiceLog.ProviderPreferenceUpdateFailed( + logger, + exception, + command.ProviderKind, + command.IsEnabled); + throw; + } } public async IAsyncEnumerable SendMessageAsync( @@ -184,7 +240,6 @@ public async IAsyncEnumerable SendMessageAsync( { ArgumentNullException.ThrowIfNull(command); await EnsureInitializedAsync(cancellationToken); - await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); var session = await dbContext.Sessions .FirstAsync(record => record.Id == command.SessionId.Value, cancellationToken); @@ -194,6 +249,12 @@ public async IAsyncEnumerable SendMessageAsync( var runtimeConversation = await runtimeConversationFactory.LoadOrCreateAsync(agent, command.SessionId, cancellationToken); var now = timeProvider.GetUtcNow(); + AgentSessionServiceLog.SendStarted( + logger, + command.SessionId, + agent.Id, + providerProfile.Kind); + var userEntry = CreateEntryRecord(command.SessionId, SessionStreamEntryKind.UserMessage, UserAuthor, command.Message.Trim(), now); dbContext.SessionEntries.Add(userEntry); session.UpdatedAt = now; @@ -210,8 +271,12 @@ public async IAsyncEnumerable SendMessageAsync( accentLabel: StatusAccentLabel); yield return MapEntry(statusEntry); - if (!await AgentProviderStatusSnapshotReader.IsEnabledAsync(dbContext, providerProfile.Kind, cancellationToken)) + var providerStatuses = await providerStatusCache.GetSnapshotAsync(cancellationToken); + var providerStatus = providerStatuses.First(status => status.Kind == providerProfile.Kind); + + if (!providerStatus.IsEnabled) { + AgentSessionServiceLog.SendBlockedDisabled(logger, command.SessionId, providerProfile.Kind); var disabledEntry = CreateEntryRecord( command.SessionId, SessionStreamEntryKind.Error, @@ -229,6 +294,7 @@ public async IAsyncEnumerable SendMessageAsync( if (providerProfile.Kind is not AgentProviderKind.Debug) { + AgentSessionServiceLog.SendBlockedNotWired(logger, command.SessionId, providerProfile.Kind); var notImplementedEntry = CreateEntryRecord( command.SessionId, SessionStreamEntryKind.Error, @@ -262,12 +328,31 @@ public async IAsyncEnumerable SendMessageAsync( string? streamedMessageId = null; var accumulated = new System.Text.StringBuilder(); - await foreach (var update in runtimeConversation.Agent.RunStreamingAsync( - command.Message.Trim(), - runtimeConversation.Session, - options: null, - cancellationToken)) + await using var updateEnumerator = runtimeConversation.Agent.RunStreamingAsync( + command.Message.Trim(), + runtimeConversation.Session, + options: null, + cancellationToken) + .GetAsyncEnumerator(cancellationToken); + + while (true) { + Microsoft.Agents.AI.AgentResponseUpdate update; + try + { + if (!await updateEnumerator.MoveNextAsync()) + { + break; + } + + update = updateEnumerator.Current; + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + throw; + } + if (string.IsNullOrEmpty(update.Text)) { continue; @@ -287,33 +372,45 @@ public async IAsyncEnumerable SendMessageAsync( new AgentProfileId(agent.Id)); } - await runtimeConversationFactory.SaveAsync(runtimeConversation, command.SessionId, cancellationToken); - - var assistantEntry = new SessionEntryRecord + SessionStreamEntry toolDoneStreamEntry; + try { - Id = streamedMessageId ?? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture), - SessionId = command.SessionId.Value, - AgentProfileId = agent.Id, - Kind = (int)SessionStreamEntryKind.AssistantMessage, - Author = agent.Name, - Text = accumulated.ToString(), - Timestamp = timeProvider.GetUtcNow(), - }; - var toolDoneEntry = CreateEntryRecord( - command.SessionId, - SessionStreamEntryKind.ToolCompleted, - ToolAuthor, - DebugToolDoneText, - timeProvider.GetUtcNow(), - agentProfileId: new AgentProfileId(agent.Id), - accentLabel: ToolAccentLabel); + await runtimeConversationFactory.SaveAsync(runtimeConversation, command.SessionId, cancellationToken); - dbContext.SessionEntries.Add(assistantEntry); - dbContext.SessionEntries.Add(toolDoneEntry); - session.UpdatedAt = assistantEntry.Timestamp; - await dbContext.SaveChangesAsync(cancellationToken); + var assistantEntry = new SessionEntryRecord + { + Id = streamedMessageId ?? Guid.CreateVersion7().ToString("N", System.Globalization.CultureInfo.InvariantCulture), + SessionId = command.SessionId.Value, + AgentProfileId = agent.Id, + Kind = (int)SessionStreamEntryKind.AssistantMessage, + Author = agent.Name, + Text = accumulated.ToString(), + Timestamp = timeProvider.GetUtcNow(), + }; + var toolDoneEntry = CreateEntryRecord( + command.SessionId, + SessionStreamEntryKind.ToolCompleted, + ToolAuthor, + DebugToolDoneText, + timeProvider.GetUtcNow(), + agentProfileId: new AgentProfileId(agent.Id), + accentLabel: ToolAccentLabel); + + dbContext.SessionEntries.Add(assistantEntry); + dbContext.SessionEntries.Add(toolDoneEntry); + session.UpdatedAt = assistantEntry.Timestamp; + await dbContext.SaveChangesAsync(cancellationToken); + + AgentSessionServiceLog.SendCompleted(logger, command.SessionId, agent.Id, accumulated.Length); + toolDoneStreamEntry = MapEntry(toolDoneEntry); + } + catch (Exception exception) + { + AgentSessionServiceLog.SendFailed(logger, exception, command.SessionId, agent.Id); + throw; + } - yield return MapEntry(toolDoneEntry); + yield return toolDoneStreamEntry; } private async Task EnsureInitializedAsync(CancellationToken cancellationToken) @@ -331,9 +428,11 @@ private async Task EnsureInitializedAsync(CancellationToken cancellationToken) return; } + AgentSessionServiceLog.InitializationStarted(logger); await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await dbContext.Database.EnsureCreatedAsync(cancellationToken); _initialized = true; + AgentSessionServiceLog.InitializationCompleted(logger); } finally { diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs index 37de905..e9ec21d 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs @@ -9,10 +9,14 @@ public static IServiceCollection AddAgentSessions( this IServiceCollection services, AgentSessionStorageOptions? storageOptions = null) { + services.AddLogging(); services.AddSingleton(storageOptions ?? new AgentSessionStorageOptions()); services.AddDbContextFactory(ConfigureDbContext); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/DotPilot.Runtime/Features/AgentSessions/IAgentProviderStatusCache.cs b/DotPilot.Runtime/Features/AgentSessions/IAgentProviderStatusCache.cs new file mode 100644 index 0000000..ca3da73 --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/IAgentProviderStatusCache.cs @@ -0,0 +1,10 @@ +using DotPilot.Core.Features.AgentSessions; + +namespace DotPilot.Runtime.Features.AgentSessions; + +public interface IAgentProviderStatusCache +{ + ValueTask> GetSnapshotAsync(CancellationToken cancellationToken); + + ValueTask> RefreshAsync(CancellationToken cancellationToken); +} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentProviderStatusCacheTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentProviderStatusCacheTests.cs new file mode 100644 index 0000000..5baa4b1 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentProviderStatusCacheTests.cs @@ -0,0 +1,163 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +[NonParallelizable] +public sealed class AgentProviderStatusCacheTests +{ + [Test] + public async Task GetWorkspaceAsyncReturnsCachedProviderStatusUntilRefreshRequested() + { + using var commandScope = CommandProbeScope.Create(); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + + await using var fixture = CreateFixture(); + var initial = await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None); + + initial.InstalledVersion.Should().Be("1.0.0"); + + commandScope.WriteVersionCommand("codex", "codex version 2.0.0"); + + var cachedWorkspace = await fixture.Service.GetWorkspaceAsync(CancellationToken.None); + cachedWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + + await fixture.ProviderStatusCache.RefreshAsync(CancellationToken.None); + + var refreshedWorkspace = await fixture.Service.GetWorkspaceAsync(CancellationToken.None); + refreshedWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("2.0.0"); + } + + [Test] + public async Task EnabledExternalProviderRemainsUnavailableUntilLiveRuntimeIsWired() + { + using var commandScope = CommandProbeScope.Create(); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + + await using var fixture = CreateFixture(); + var provider = await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Codex, true), + CancellationToken.None); + + provider.IsEnabled.Should().BeTrue(); + provider.CanCreateAgents.Should().BeFalse(); + provider.Status.Should().Be(AgentProviderStatus.Error); + provider.StatusSummary.Should().Contain("not wired yet"); + provider.InstalledVersion.Should().Be("1.0.0"); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + return new TestFixture( + provider, + provider.GetRequiredService(), + provider.GetRequiredService()); + } + + private sealed class TestFixture( + ServiceProvider provider, + IAgentSessionService service, + IAgentProviderStatusCache providerStatusCache) + : IAsyncDisposable + { + public IAgentSessionService Service { get; } = service; + + public IAgentProviderStatusCache ProviderStatusCache { get; } = providerStatusCache; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } + + private sealed class CommandProbeScope : IDisposable + { + private readonly string _rootPath; + private readonly string? _originalPath; + private bool _disposed; + + private CommandProbeScope(string rootPath, string? originalPath) + { + _rootPath = rootPath; + _originalPath = originalPath; + } + + public static CommandProbeScope Create() + { + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var rootPath = Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(AgentProviderStatusCacheTests), + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(rootPath); + Environment.SetEnvironmentVariable("PATH", rootPath); + return new CommandProbeScope(rootPath, originalPath); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + Environment.SetEnvironmentVariable("PATH", _originalPath); + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, recursive: true); + } + + _disposed = true; + } + + public void WriteVersionCommand(string commandName, string output) + { + var commandPath = OperatingSystem.IsWindows() + ? Path.Combine(_rootPath, commandName + ".cmd") + : Path.Combine(_rootPath, commandName); + + var commandBody = OperatingSystem.IsWindows() + ? $"@echo off{Environment.NewLine}echo {output}{Environment.NewLine}" + : $"#!/bin/sh{Environment.NewLine}echo \"{output}\"{Environment.NewLine}"; + + File.WriteAllText(commandPath, commandBody); + + if (OperatingSystem.IsWindows()) + { + return; + } + + File.SetUnixFileMode( + commandPath, + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherExecute); + } + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentSessionLoggingTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentSessionLoggingTests.cs new file mode 100644 index 0000000..58af593 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentSessionLoggingTests.cs @@ -0,0 +1,126 @@ +using System.Collections.Concurrent; +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class AgentSessionLoggingTests +{ + [Test] + public async Task RuntimeFlowsEmitLifecycleLogsForAgentSessionAndSend() + { + var recordingProvider = new RecordingLoggerProvider(); + await using var fixture = CreateFixture(recordingProvider); + + await fixture.Service.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + + var agent = await fixture.Service.CreateAgentAsync( + new CreateAgentProfileCommand( + "Logged Agent", + AgentRoleKind.Operator, + AgentProviderKind.Debug, + "debug-echo", + "Be explicit in tests.", + ["Shell"]), + CancellationToken.None); + + var session = await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Logged session", agent.Id), + CancellationToken.None); + + await foreach (var _ in fixture.Service.SendMessageAsync( + new SendSessionMessageCommand(session.Session.Id, "hello logs"), + CancellationToken.None)) + { + } + + var messages = recordingProvider.Entries + .Select(entry => entry.Message) + .ToArray(); + + messages.Should().Contain(message => message.Contains("Created agent profile.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Created session.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Starting session send.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Completed session send.", StringComparison.Ordinal)); + messages.Should().Contain(message => message.Contains("Provider probe completed.", StringComparison.Ordinal)); + } + + private static TestFixture CreateFixture(RecordingLoggerProvider recordingProvider) + { + var services = new ServiceCollection(); + services.AddLogging(logging => + { + logging.ClearProviders(); + logging.SetMinimumLevel(LogLevel.Information); + logging.AddProvider(recordingProvider); + }); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + return new TestFixture(provider, provider.GetRequiredService()); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentSessionService service) : IAsyncDisposable + { + public IAgentSessionService Service { get; } = service; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } + + private sealed class RecordingLoggerProvider : ILoggerProvider + { + private readonly ConcurrentQueue _entries = new(); + + public IReadOnlyCollection Entries => _entries.ToArray(); + + public void Dispose() + { + } + + public ILogger CreateLogger(string categoryName) + { + return new RecordingLogger(categoryName, _entries); + } + + public sealed record LogEntry(string CategoryName, LogLevel Level, string Message); + + private sealed class RecordingLogger( + string categoryName, + ConcurrentQueue entries) + : ILogger + { + public IDisposable? BeginScope(TState state) + where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + entries.Enqueue(new LogEntry(categoryName, logLevel, formatter(state, exception))); + } + } + } +} diff --git a/DotPilot/AGENTS.md b/DotPilot/AGENTS.md index 6e76973..930d62b 100644 --- a/DotPilot/AGENTS.md +++ b/DotPilot/AGENTS.md @@ -25,12 +25,13 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - Keep this project focused on app composition, presentation, routing, and platform startup concerns. - Keep feature/domain/runtime code out of this project; reference it through slice-owned contracts and application services from separate DLLs. +- Keep the Uno UI as a thin representation layer: background orchestration, long-running commands, and durable session updates should come from Orleans/runtime services instead of page-owned workflows. - Build the visible product around a desktop chat shell: session list, active transcript, terminal-like activity pane, agent/profile controls, and provider settings are the primary surfaces. - Do not use workbench, issue-center, domain-browser, or other backlog-driven IA labels as the product shell. - Do not preserve legacy prototype pages or controls once the replacement chat/session surface is underway; remove obsolete UI paths instead of carrying both shells. - Prefer declarative `Uno.Extensions.Navigation` in XAML via `uen:Navigation.Request` over page code-behind navigation calls. - Keep business logic, persistence, networking workflows, and non-UI orchestration out of page code-behind. -- Build presentation with `MVVM`-friendly view models and separate reusable XAML components instead of large monolithic pages. +- Build presentation with projection-only `MVVM`/`MVUX`-friendly models and separate reusable XAML components instead of large monolithic pages; runtime coordination, provider probes, session-loading pipelines, and other orchestration must stay outside the UI layer. - Organize non-UI work by feature-aligned vertical slices so each slice can evolve and ship without creating a shared dump of cross-cutting services in the app project. - Replace scaffold sample data with real runtime-backed state as product features arrive; the shell should converge on the real chat/session workflow instead of preserving prototype-only concepts. - Reuse shared resources and small XAML components instead of duplicating large visual sections across pages. @@ -65,5 +66,6 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - `App.xaml` and `Styles/*` are shared styling roots; careless edits can regress the whole app. - `Presentation/*Page.xaml` files can grow quickly; split repeated sections before they violate maintainability limits. - This project is currently the visible product surface, so every visual change should preserve desktop responsiveness and accessibility-minded structure. +- Screen switches, tab changes, and menu navigation in this project must reuse already-available in-memory projections; avoid view-model constructors or activation hooks that trigger cold runtime work during ordinary navigation. - `DotPilot.csproj` keeps `GenerateDocumentationFile=true` with `CS1591` suppressed so Roslyn `IDE0005` stays active in CI across desktop, core, and browserwasm targets; do not remove that exception unless full XML documentation becomes part of the enforced quality bar. - Product wording and navigation here set the real user expectation; avoid leaking architecture slice names, issue numbers, or backlog jargon into the visible shell. diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index 77a90e8..2597f97 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using DotPilot.Runtime.Features.AgentSessions; #if !__WASM__ @@ -16,6 +17,7 @@ public partial class App : Application private const string BuilderCreatedMarker = "Uno host builder created."; private const string NavigateStartedMarker = "Navigating to shell."; private const string NavigateCompletedMarker = "Shell navigation completed."; + private const string DotPilotCategoryName = "DotPilot"; #if !__WASM__ private const string CenterMethodName = "Center"; private const string WindowStartupLocationPropertyName = "WindowStartupLocation"; @@ -67,6 +69,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) context.HostingEnvironment.IsDevelopment() ? LogLevel.Information : LogLevel.Warning) + .AddFilter(DotPilotCategoryName, LogLevel.Information) // Default filters for core Uno Platform namespaces .CoreLogLevel(LogLevel.Warning); @@ -133,6 +136,10 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) WriteStartupMarker(NavigateStartedMarker); Host = await builder.NavigateAsync(); WriteStartupMarker(NavigateCompletedMarker); + var appLogger = Host.Services.GetRequiredService>(); + AppLog.StartupMarker( + appLogger, + NavigateCompletedMarker); #if !__WASM__ CenterDesktopWindow(MainWindow); #endif diff --git a/DotPilot/Presentation/AgentBuilderModels.cs b/DotPilot/Presentation/AgentBuilderModels.cs index a74eff4..d34f69a 100644 --- a/DotPilot/Presentation/AgentBuilderModels.cs +++ b/DotPilot/Presentation/AgentBuilderModels.cs @@ -1,8 +1,10 @@ using DotPilot.Core.Features.AgentSessions; using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.UI.Xaml.Data; namespace DotPilot.Presentation; +[Bindable] public sealed partial record AgentProviderOption( AgentProviderKind Kind, string DisplayName, @@ -11,6 +13,7 @@ public sealed partial record AgentProviderOption( string? InstalledVersion, bool CanCreateAgents); +[Bindable] public sealed class CapabilityOption( string name, string description, @@ -29,6 +32,7 @@ public bool IsEnabled } } +[Bindable] public sealed class RoleOption( string label, AgentRoleKind role, diff --git a/DotPilot/Presentation/AsyncCommand.cs b/DotPilot/Presentation/AsyncCommand.cs index 830c242..33db901 100644 --- a/DotPilot/Presentation/AsyncCommand.cs +++ b/DotPilot/Presentation/AsyncCommand.cs @@ -1,3 +1,5 @@ +using Microsoft.UI.Dispatching; + namespace DotPilot.Presentation; public sealed class AsyncCommand( @@ -5,6 +7,7 @@ public sealed class AsyncCommand( Func? canExecute = null) : ICommand { private bool _isExecuting; + private readonly DispatcherQueue? _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); public AsyncCommand(Func executeAsync, Func? canExecute = null) : this( @@ -43,6 +46,12 @@ public async void Execute(object? parameter) public void RaiseCanExecuteChanged() { - CanExecuteChanged?.Invoke(this, EventArgs.Empty); + if (_dispatcherQueue is null || _dispatcherQueue.HasThreadAccess) + { + CanExecuteChanged?.Invoke(this, EventArgs.Empty); + return; + } + + _dispatcherQueue.TryEnqueue(() => CanExecuteChanged?.Invoke(this, EventArgs.Empty)); } } diff --git a/DotPilot/Presentation/ChatDesignModels.cs b/DotPilot/Presentation/ChatDesignModels.cs index e6f2159..d1363cd 100644 --- a/DotPilot/Presentation/ChatDesignModels.cs +++ b/DotPilot/Presentation/ChatDesignModels.cs @@ -1,8 +1,10 @@ using DotPilot.Core.Features.AgentSessions; using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.UI.Xaml.Data; namespace DotPilot.Presentation; +[Bindable] public sealed class SessionSidebarItem(SessionId id, string title, string preview) : ObservableObject { private string _preview = preview; @@ -18,6 +20,7 @@ public string Preview } } +[Bindable] public sealed partial record ChatTimelineItem( string Id, SessionStreamEntryKind Kind, @@ -29,6 +32,7 @@ public sealed partial record ChatTimelineItem( bool IsCurrentUser, string? AccentLabel = null); +[Bindable] public sealed partial record ParticipantItem( string Name, string SecondaryText, @@ -37,6 +41,7 @@ public sealed partial record ParticipantItem( string? BadgeText = null, Brush? BadgeBrush = null); +[Bindable] public sealed partial record ProviderStatusItem( AgentProviderKind Kind, string DisplayName, @@ -48,6 +53,7 @@ public sealed partial record ProviderStatusItem( string InstallCommand, IReadOnlyList Actions); +[Bindable] public sealed partial record ProviderActionItem( string Label, string Summary, diff --git a/DotPilot/Presentation/MainViewModel.cs b/DotPilot/Presentation/MainViewModel.cs index 4b082c2..7092e9f 100644 --- a/DotPilot/Presentation/MainViewModel.cs +++ b/DotPilot/Presentation/MainViewModel.cs @@ -2,9 +2,14 @@ using System.Globalization; using DotPilot.Core.Features.AgentSessions; using DotPilot.Core.Features.ControlPlaneDomain; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Data; namespace DotPilot.Presentation; +[Bindable] public sealed class MainViewModel : ObservableObject { private const string EmptyTitleValue = "No active session"; @@ -14,6 +19,9 @@ public sealed class MainViewModel : ObservableObject private const string LocalMemberName = "Local operator"; private const string LocalMemberSummary = "This desktop instance"; private readonly IAgentSessionService _agentSessionService; + private readonly IAgentProviderStatusCache _providerStatusCache; + private readonly ILogger _logger; + private readonly DispatcherQueue _dispatcherQueue; private readonly AsyncCommand _sendMessageCommand; private readonly AsyncCommand _startNewSessionCommand; private readonly AsyncCommand _refreshCommand; @@ -25,9 +33,16 @@ public sealed class MainViewModel : ObservableObject private string _composerText = string.Empty; private string _feedbackMessage = string.Empty; - public MainViewModel(IAgentSessionService agentSessionService) + public MainViewModel( + IAgentSessionService agentSessionService, + IAgentProviderStatusCache providerStatusCache, + ILogger logger) { _agentSessionService = agentSessionService; + _providerStatusCache = providerStatusCache; + _logger = logger; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread() ?? + throw new InvalidOperationException("MainViewModel requires a UI dispatcher queue."); RecentChats = []; Messages = []; Members = [new ParticipantItem(LocalMemberName, LocalMemberSummary, "L", DesignBrushPalette.UserAvatarBrush)]; @@ -35,7 +50,7 @@ public MainViewModel(IAgentSessionService agentSessionService) _sendMessageCommand = new AsyncCommand(SendMessageAsync, CanSendMessage); _startNewSessionCommand = new AsyncCommand(StartNewSessionAsync, CanStartNewSession); - _refreshCommand = new AsyncCommand(LoadWorkspaceAsync); + _refreshCommand = new AsyncCommand(RefreshWorkspaceAsync); _ = LoadWorkspaceAsync(); } @@ -111,33 +126,40 @@ private async Task LoadWorkspaceAsync() { try { + MainViewModelLog.LoadingWorkspace(_logger); var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); - _agents = workspace.Agents; - RebuildRecentChats(workspace.Sessions); - _startNewSessionCommand.RaiseCanExecuteChanged(); - _sendMessageCommand.RaiseCanExecuteChanged(); - - if (workspace.SelectedSessionId is { } selectedSessionId) - { - SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == selectedSessionId); - } - else if (RecentChats.Count > 0) + await RunOnUiThreadAsync(() => { - SelectedChat = RecentChats[0]; - } - else - { - ClearTimeline(); - RebuildAgentPanel([]); - Title = EmptyTitleValue; - StatusSummary = HasAgents - ? "Start a new session from the sidebar or send the first message." - : EmptyStatusValue; - } + _agents = workspace.Agents; + RebuildRecentChats(workspace.Sessions); + _startNewSessionCommand.RaiseCanExecuteChanged(); + _sendMessageCommand.RaiseCanExecuteChanged(); + + if (workspace.SelectedSessionId is { } selectedSessionId) + { + SelectedChat = RecentChats.FirstOrDefault(chat => chat.Id == selectedSessionId); + } + else if (RecentChats.Count > 0) + { + SelectedChat = RecentChats[0]; + } + else + { + ClearTimeline(); + RebuildAgentPanel([]); + Title = EmptyTitleValue; + StatusSummary = HasAgents + ? "Start a new session from the sidebar or send the first message." + : EmptyStatusValue; + } + }); + + MainViewModelLog.WorkspaceLoaded(_logger, workspace.Sessions.Count, workspace.Agents.Count); } catch (Exception exception) { - FeedbackMessage = exception.Message; + MainViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => FeedbackMessage = exception.Message); } } @@ -145,26 +167,40 @@ private async Task LoadSelectedSessionAsync() { if (_selectedChat is null) { - ClearTimeline(); - RebuildAgentPanel([]); - Title = EmptyTitleValue; - StatusSummary = HasAgents - ? "Start a new session from the sidebar or send the first message." - : EmptyStatusValue; - _sendMessageCommand.RaiseCanExecuteChanged(); + await RunOnUiThreadAsync(() => + { + ClearTimeline(); + RebuildAgentPanel([]); + Title = EmptyTitleValue; + StatusSummary = HasAgents + ? "Start a new session from the sidebar or send the first message." + : EmptyStatusValue; + _sendMessageCommand.RaiseCanExecuteChanged(); + }); return; } - var snapshot = await _agentSessionService.GetSessionAsync(_selectedChat.Id, CancellationToken.None); + var selectedChatId = _selectedChat.Id; + var snapshot = await _agentSessionService.GetSessionAsync(selectedChatId, CancellationToken.None); if (snapshot is null) { return; } - Title = snapshot.Session.Title; - StatusSummary = $"{snapshot.Session.PrimaryAgentName} · {snapshot.Session.ProviderDisplayName}"; - RebuildTimeline(snapshot.Entries); - RebuildAgentPanel(snapshot.Participants); + await RunOnUiThreadAsync(() => + { + Title = snapshot.Session.Title; + StatusSummary = $"{snapshot.Session.PrimaryAgentName} · {snapshot.Session.ProviderDisplayName}"; + RebuildTimeline(snapshot.Entries); + RebuildAgentPanel(snapshot.Participants); + }); + } + + private async Task RefreshWorkspaceAsync() + { + MainViewModelLog.RefreshRequested(_logger); + await _providerStatusCache.RefreshAsync(CancellationToken.None); + await LoadWorkspaceAsync(); } private async Task StartNewSessionAsync() @@ -175,11 +211,12 @@ private async Task StartNewSessionAsync() return; } + MainViewModelLog.StartingSession(_logger); var agent = _agents[0]; var session = await _agentSessionService.CreateSessionAsync( new CreateSessionCommand($"Session with {agent.Name}", agent.Id), CancellationToken.None); - InsertOrUpdateRecentChat(session.Session, selectSession: true); + await RunOnUiThreadAsync(() => InsertOrUpdateRecentChat(session.Session, selectSession: true)); } private async Task SendMessageAsync() @@ -205,8 +242,9 @@ private async Task SendMessageAsync() } } + var selectedChatId = SelectedChat.Id; await foreach (var entry in _agentSessionService.SendMessageAsync( - new SendSessionMessageCommand(SelectedChat.Id, message), + new SendSessionMessageCommand(selectedChatId, message), CancellationToken.None)) { if (entry.Kind is SessionStreamEntryKind.AssistantMessage && !string.IsNullOrWhiteSpace(entry.Text)) @@ -214,15 +252,19 @@ private async Task SendMessageAsync() latestPreview = entry.Text; } - ApplyTimelineEntry(entry); + await RunOnUiThreadAsync(() => ApplyTimelineEntry(entry)); } - UpdateRecentChatPreview(SelectedChat.Id, latestPreview ?? message); - FeedbackMessage = string.Empty; + await RunOnUiThreadAsync(() => + { + UpdateRecentChatPreview(selectedChatId, latestPreview ?? message); + FeedbackMessage = string.Empty; + }); } catch (Exception exception) { - FeedbackMessage = exception.Message; + MainViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => FeedbackMessage = exception.Message); } } @@ -372,4 +414,34 @@ private static string GetInitial(string value) _ => DesignBrushPalette.CodeAvatarBrush, }; } + + private Task RunOnUiThreadAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_dispatcherQueue.HasThreadAccess) + { + action(); + return Task.CompletedTask; + } + + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_dispatcherQueue.TryEnqueue(() => + { + try + { + action(); + completionSource.SetResult(); + } + catch (Exception exception) + { + completionSource.SetException(exception); + } + })) + { + completionSource.SetException(new InvalidOperationException("Unable to enqueue work to the UI dispatcher.")); + } + + return completionSource.Task; + } } diff --git a/DotPilot/Presentation/PresentationLog.cs b/DotPilot/Presentation/PresentationLog.cs new file mode 100644 index 0000000..373a2c4 --- /dev/null +++ b/DotPilot/Presentation/PresentationLog.cs @@ -0,0 +1,63 @@ +using Microsoft.Extensions.Logging; + +namespace DotPilot.Presentation; + +internal static partial class MainViewModelLog +{ + [LoggerMessage(EventId = 2000, Level = LogLevel.Information, Message = "Loading chat workspace snapshot.")] + public static partial void LoadingWorkspace(ILogger logger); + + [LoggerMessage( + EventId = 2001, + Level = LogLevel.Information, + Message = "Chat workspace snapshot loaded. Sessions={SessionCount} Agents={AgentCount}.")] + public static partial void WorkspaceLoaded(ILogger logger, int sessionCount, int agentCount); + + [LoggerMessage(EventId = 2002, Level = LogLevel.Information, Message = "Refreshing chat workspace and provider state.")] + public static partial void RefreshRequested(ILogger logger); + + [LoggerMessage(EventId = 2004, Level = LogLevel.Information, Message = "Starting new chat session from the chat shell.")] + public static partial void StartingSession(ILogger logger); + + [LoggerMessage(EventId = 2006, Level = LogLevel.Error, Message = "Chat shell operation failed.")] + public static partial void Failure(ILogger logger, Exception exception); +} + +internal static partial class SecondViewModelLog +{ + [LoggerMessage(EventId = 2100, Level = LogLevel.Information, Message = "Loading provider list for agent creation.")] + public static partial void LoadingProviders(ILogger logger); + + [LoggerMessage( + EventId = 2101, + Level = LogLevel.Information, + Message = "Loaded provider list for agent creation. Providers={ProviderCount}.")] + public static partial void ProvidersLoaded(ILogger logger, int providerCount); + + [LoggerMessage(EventId = 2102, Level = LogLevel.Error, Message = "Agent builder operation failed.")] + public static partial void Failure(ILogger logger, Exception exception); +} + +internal static partial class SettingsViewModelLog +{ + [LoggerMessage(EventId = 2200, Level = LogLevel.Information, Message = "Loading provider readiness settings.")] + public static partial void LoadingProviders(ILogger logger); + + [LoggerMessage( + EventId = 2201, + Level = LogLevel.Information, + Message = "Loaded provider readiness settings. Providers={ProviderCount}.")] + public static partial void ProvidersLoaded(ILogger logger, int providerCount); + + [LoggerMessage(EventId = 2202, Level = LogLevel.Information, Message = "Refreshing provider readiness settings.")] + public static partial void RefreshRequested(ILogger logger); + + [LoggerMessage(EventId = 2203, Level = LogLevel.Error, Message = "Provider settings operation failed.")] + public static partial void Failure(ILogger logger, Exception exception); +} + +internal static partial class AppLog +{ + [LoggerMessage(EventId = 2300, Level = LogLevel.Information, Message = "{StartupMarker}")] + public static partial void StartupMarker(ILogger logger, string startupMarker); +} diff --git a/DotPilot/Presentation/SecondViewModel.cs b/DotPilot/Presentation/SecondViewModel.cs index 681dcb2..bea648a 100644 --- a/DotPilot/Presentation/SecondViewModel.cs +++ b/DotPilot/Presentation/SecondViewModel.cs @@ -1,14 +1,20 @@ using System.Collections.ObjectModel; using DotPilot.Core.Features.AgentSessions; using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Data; namespace DotPilot.Presentation; +[Bindable] public sealed class SecondViewModel : ObservableObject { private const string AgentValidationMessage = "Enter an agent name and model before creating the profile."; private const string AgentCreationProgressMessage = "Creating agent profile..."; private readonly IAgentSessionService _agentSessionService; + private readonly ILogger _logger; + private readonly DispatcherQueue _dispatcherQueue; private readonly AsyncCommand _createAgentCommand; private readonly ObservableCollection _roleOptions; private readonly ObservableCollection _capabilityOptions; @@ -20,9 +26,14 @@ public sealed class SecondViewModel : ObservableObject private string _statusMessage = "Loading provider readiness..."; private AgentProviderOption? _selectedProvider; - public SecondViewModel(IAgentSessionService agentSessionService) + public SecondViewModel( + IAgentSessionService agentSessionService, + ILogger logger) { _agentSessionService = agentSessionService; + _logger = logger; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread() ?? + throw new InvalidOperationException("SecondViewModel requires a UI dispatcher queue."); _providerOptions = []; _roleOptions = [ @@ -122,23 +133,37 @@ public AgentProviderOption? SelectedProvider private async Task LoadProvidersAsync() { - var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); - _providerOptions.Clear(); + try + { + SecondViewModelLog.LoadingProviders(_logger); + var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); + await RunOnUiThreadAsync(() => + { + _providerOptions.Clear(); + + foreach (var provider in workspace.Providers) + { + _providerOptions.Add( + new AgentProviderOption( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.InstalledVersion, + provider.CanCreateAgents)); + } + + SelectedProvider = _providerOptions.FirstOrDefault(option => option.CanCreateAgents) ?? + _providerOptions.FirstOrDefault(); + }); - foreach (var provider in workspace.Providers) + SecondViewModelLog.ProvidersLoaded(_logger, workspace.Providers.Count); + } + catch (Exception exception) { - _providerOptions.Add( - new AgentProviderOption( - provider.Kind, - provider.DisplayName, - provider.CommandName, - provider.StatusSummary, - provider.InstalledVersion, - provider.CanCreateAgents)); + SecondViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => StatusMessage = exception.Message); } - - SelectedProvider = _providerOptions.FirstOrDefault(option => option.CanCreateAgents) ?? - _providerOptions.FirstOrDefault(); } private async Task CreateAgentAsync() @@ -178,12 +203,16 @@ private async Task CreateAgentAsync() .ToArray()), CancellationToken.None); - StatusMessage = $"Created {created.Name} using {created.ProviderDisplayName}."; - AgentName = $"{created.Name} Copy"; + await RunOnUiThreadAsync(() => + { + StatusMessage = $"Created {created.Name} using {created.ProviderDisplayName}."; + AgentName = $"{created.Name} Copy"; + }); } catch (Exception exception) { - StatusMessage = exception.Message; + SecondViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => StatusMessage = exception.Message); } } @@ -202,4 +231,34 @@ private static string ResolveDefaultModel(AgentProviderKind kind) _ => "debug-echo", }; } + + private Task RunOnUiThreadAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_dispatcherQueue.HasThreadAccess) + { + action(); + return Task.CompletedTask; + } + + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_dispatcherQueue.TryEnqueue(() => + { + try + { + action(); + completionSource.SetResult(); + } + catch (Exception exception) + { + completionSource.SetException(exception); + } + })) + { + completionSource.SetException(new InvalidOperationException("Unable to enqueue work to the UI dispatcher.")); + } + + return completionSource.Task; + } } diff --git a/DotPilot/Presentation/SettingsViewModel.cs b/DotPilot/Presentation/SettingsViewModel.cs index 8341d05..e536376 100644 --- a/DotPilot/Presentation/SettingsViewModel.cs +++ b/DotPilot/Presentation/SettingsViewModel.cs @@ -1,12 +1,21 @@ using System.Collections.ObjectModel; using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.Logging; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml.Data; using Windows.ApplicationModel.DataTransfer; namespace DotPilot.Presentation; +[Bindable] public sealed class SettingsViewModel : ObservableObject { + private const string RefreshCompletedMessage = "Provider readiness refreshed."; private readonly IAgentSessionService _agentSessionService; + private readonly IAgentProviderStatusCache _providerStatusCache; + private readonly ILogger _logger; + private readonly DispatcherQueue _dispatcherQueue; private readonly AsyncCommand _refreshCommand; private readonly AsyncCommand _toggleProviderCommand; private readonly AsyncCommand _providerActionCommand; @@ -14,11 +23,18 @@ public sealed class SettingsViewModel : ObservableObject private ProviderStatusItem? _selectedProvider; private string _statusMessage = string.Empty; - public SettingsViewModel(IAgentSessionService agentSessionService) + public SettingsViewModel( + IAgentSessionService agentSessionService, + IAgentProviderStatusCache providerStatusCache, + ILogger logger) { _agentSessionService = agentSessionService; + _providerStatusCache = providerStatusCache; + _logger = logger; + _dispatcherQueue = DispatcherQueue.GetForCurrentThread() ?? + throw new InvalidOperationException("SettingsViewModel requires a UI dispatcher queue."); _providers = []; - _refreshCommand = new AsyncCommand(LoadProvidersAsync); + _refreshCommand = new AsyncCommand(RefreshProvidersAsync); _toggleProviderCommand = new AsyncCommand(ToggleSelectedProviderAsync, CanToggleSelectedProvider); _providerActionCommand = new AsyncCommand(ExecuteProviderActionAsync, CanExecuteProviderAction); _ = LoadProvidersAsync(); @@ -77,28 +93,60 @@ public string StatusMessage private async Task LoadProvidersAsync() { - var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); - _providers.Clear(); - - foreach (var provider in workspace.Providers) + try + { + SettingsViewModelLog.LoadingProviders(_logger); + var previouslySelectedKind = SelectedProvider?.Kind; + var workspace = await _agentSessionService.GetWorkspaceAsync(CancellationToken.None); + await RunOnUiThreadAsync(() => + { + _providers.Clear(); + + foreach (var provider in workspace.Providers) + { + _providers.Add( + new ProviderStatusItem( + provider.Kind, + provider.DisplayName, + provider.CommandName, + provider.StatusSummary, + provider.InstalledVersion, + provider.IsEnabled, + provider.CanCreateAgents, + provider.Actions.Select(action => action.Command).FirstOrDefault(command => !string.IsNullOrWhiteSpace(command)) ?? string.Empty, + provider.Actions + .Select(action => new ProviderActionItem(action.Label, action.Summary, action.Command)) + .ToArray())); + } + + SelectedProvider = _providers.FirstOrDefault(provider => provider.Kind == previouslySelectedKind) ?? + _providers.FirstOrDefault(provider => provider.IsEnabled) ?? + _providers.FirstOrDefault(); + }); + + SettingsViewModelLog.ProvidersLoaded(_logger, workspace.Providers.Count); + } + catch (Exception exception) { - _providers.Add( - new ProviderStatusItem( - provider.Kind, - provider.DisplayName, - provider.CommandName, - provider.StatusSummary, - provider.InstalledVersion, - provider.IsEnabled, - provider.CanCreateAgents, - provider.Actions.Select(action => action.Command).FirstOrDefault(command => !string.IsNullOrWhiteSpace(command)) ?? string.Empty, - provider.Actions - .Select(action => new ProviderActionItem(action.Label, action.Summary, action.Command)) - .ToArray())); + SettingsViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => StatusMessage = exception.Message); } + } - SelectedProvider = _providers.FirstOrDefault(provider => provider.IsEnabled) ?? - _providers.FirstOrDefault(); + private async Task RefreshProvidersAsync() + { + try + { + SettingsViewModelLog.RefreshRequested(_logger); + await _providerStatusCache.RefreshAsync(CancellationToken.None); + await LoadProvidersAsync(); + await RunOnUiThreadAsync(() => StatusMessage = RefreshCompletedMessage); + } + catch (Exception exception) + { + SettingsViewModelLog.Failure(_logger, exception); + await RunOnUiThreadAsync(() => StatusMessage = exception.Message); + } } private async Task ToggleSelectedProviderAsync() @@ -112,7 +160,7 @@ await _agentSessionService.UpdateProviderAsync( new UpdateProviderPreferenceCommand(SelectedProvider.Kind, !SelectedProvider.IsEnabled), CancellationToken.None); await LoadProvidersAsync(); - StatusMessage = $"{SelectedProviderTitle} updated."; + await RunOnUiThreadAsync(() => StatusMessage = $"{SelectedProviderTitle} updated."); } private bool CanToggleSelectedProvider() @@ -153,4 +201,34 @@ private static bool CanExecuteProviderAction(object? parameter) { return parameter is ProviderActionItem; } + + private Task RunOnUiThreadAsync(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + if (_dispatcherQueue.HasThreadAccess) + { + action(); + return Task.CompletedTask; + } + + var completionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + if (!_dispatcherQueue.TryEnqueue(() => + { + try + { + action(); + completionSource.SetResult(); + } + catch (Exception exception) + { + completionSource.SetException(exception); + } + })) + { + completionSource.SetException(new InvalidOperationException("Unable to enqueue work to the UI dispatcher.")); + } + + return completionSource.Task; + } } diff --git a/DotPilot/Presentation/ShellViewModel.cs b/DotPilot/Presentation/ShellViewModel.cs index e11ae34..cd3927c 100644 --- a/DotPilot/Presentation/ShellViewModel.cs +++ b/DotPilot/Presentation/ShellViewModel.cs @@ -1,5 +1,8 @@ +using Microsoft.UI.Xaml.Data; + namespace DotPilot.Presentation; +[Bindable] public class ShellViewModel { } From 4922523c5bf437eb62c90da212d35dd44fe92ae9 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sat, 14 Mar 2026 17:53:09 +0100 Subject: [PATCH 06/30] Refine MVUX screens and repair agent builder --- .../AgentSessions/IAgentWorkspaceState.cs | 28 ++ .../AgentSessions/AgentSessionRuntimeLog.cs | 39 ++ ...AgentSessionServiceCollectionExtensions.cs | 2 + .../AgentSessions/AgentWorkspaceState.cs | 282 +++++++++++ .../AgentSessions/AgentWorkspaceStateTests.cs | 132 +++++ .../Features/AgentSessions/MainModelTests.cs | 68 +++ .../AgentSessions/SecondModelTests.cs | 89 ++++ .../AgentSessions/SettingsModelTests.cs | 62 +++ .../AgentSessions/GivenChatSessionsShell.cs | 62 ++- DotPilot.UITests/Harness/TestBase.cs | 222 ++++++++- DotPilot/App.xaml.cs | 13 +- DotPilot/DotPilot.csproj | 1 + DotPilot/GlobalUsings.cs | 1 + DotPilot/Presentation/AgentBuilderModels.cs | 20 +- .../Controls/AgentBasicInfoSection.xaml | 168 +++---- .../Controls/AgentPromptSection.xaml | 93 ++-- .../Controls/AgentSkillsSection.xaml | 70 +-- .../Presentation/Controls/ChatComposer.xaml | 9 +- .../Controls/ChatComposer.xaml.cs | 11 +- .../Controls/ChatConversationView.xaml | 10 +- .../Presentation/Controls/ChatInfoPanel.xaml | 8 +- .../Presentation/Controls/ChatSidebar.xaml | 5 +- .../Presentation/Controls/SettingsShell.xaml | 7 +- DotPilot/Presentation/MainViewModel.cs | 470 +++++++----------- DotPilot/Presentation/PresentationLog.cs | 38 +- .../PresentationProjectionModels.cs | 22 + ...PresentationServiceCollectionExtensions.cs | 21 + DotPilot/Presentation/SecondPage.xaml | 18 +- DotPilot/Presentation/SecondViewModel.cs | 396 ++++++++------- DotPilot/Presentation/SettingsViewModel.cs | 342 +++++++------ DotPilot/Styles/AppDesign.xaml | 39 ++ docs/Architecture.md | 5 +- 32 files changed, 1852 insertions(+), 901 deletions(-) create mode 100644 DotPilot.Core/Features/AgentSessions/IAgentWorkspaceState.cs create mode 100644 DotPilot.Runtime/Features/AgentSessions/AgentWorkspaceState.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/AgentWorkspaceStateTests.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/MainModelTests.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/SecondModelTests.cs create mode 100644 DotPilot.Tests/Features/AgentSessions/SettingsModelTests.cs create mode 100644 DotPilot/Presentation/PresentationProjectionModels.cs create mode 100644 DotPilot/Presentation/PresentationServiceCollectionExtensions.cs diff --git a/DotPilot.Core/Features/AgentSessions/IAgentWorkspaceState.cs b/DotPilot.Core/Features/AgentSessions/IAgentWorkspaceState.cs new file mode 100644 index 0000000..dba6187 --- /dev/null +++ b/DotPilot.Core/Features/AgentSessions/IAgentWorkspaceState.cs @@ -0,0 +1,28 @@ +using DotPilot.Core.Features.ControlPlaneDomain; + +namespace DotPilot.Core.Features.AgentSessions; + +public interface IAgentWorkspaceState +{ + ValueTask GetWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask RefreshWorkspaceAsync(CancellationToken cancellationToken); + + ValueTask GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken); + + ValueTask CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken); + + ValueTask CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken); + + ValueTask UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken); + + IAsyncEnumerable SendMessageAsync( + SendSessionMessageCommand command, + CancellationToken cancellationToken); +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs index a4a11f4..516be71 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionRuntimeLog.cs @@ -222,3 +222,42 @@ public static partial void SendCompleted( Message = "Session send failed. SessionId={SessionId} AgentId={AgentId}.")] public static partial void SendFailed(ILogger logger, Exception exception, SessionId sessionId, Guid agentId); } + +internal static partial class AgentWorkspaceStateLog +{ + [LoggerMessage( + EventId = 1300, + Level = LogLevel.Information, + Message = "Returning cached workspace projection. Sessions={SessionCount} Agents={AgentCount} Providers={ProviderCount}.")] + public static partial void WorkspaceCacheHit(ILogger logger, int sessionCount, int agentCount, int providerCount); + + [LoggerMessage( + EventId = 1301, + Level = LogLevel.Information, + Message = "Workspace projection refreshed. Sessions={SessionCount} Agents={AgentCount} Providers={ProviderCount}.")] + public static partial void WorkspaceRefreshed(ILogger logger, int sessionCount, int agentCount, int providerCount); + + [LoggerMessage( + EventId = 1302, + Level = LogLevel.Information, + Message = "Returning cached session projection. SessionId={SessionId} Entries={EntryCount}.")] + public static partial void SessionCacheHit(ILogger logger, SessionId sessionId, int entryCount); + + [LoggerMessage( + EventId = 1303, + Level = LogLevel.Information, + Message = "Cached workspace updated after agent creation. AgentId={AgentId} Provider={ProviderKind}.")] + public static partial void AgentCached(ILogger logger, AgentProfileId agentId, AgentProviderKind providerKind); + + [LoggerMessage( + EventId = 1304, + Level = LogLevel.Information, + Message = "Cached workspace updated after session creation. SessionId={SessionId} AgentId={AgentId}.")] + public static partial void SessionCached(ILogger logger, SessionId sessionId, AgentProfileId agentId); + + [LoggerMessage( + EventId = 1305, + Level = LogLevel.Information, + Message = "Cached workspace updated after provider preference change. Provider={ProviderKind} Enabled={IsEnabled}.")] + public static partial void ProviderCached(ILogger logger, AgentProviderKind providerKind, bool isEnabled); +} diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs index e9ec21d..0f09e8b 100644 --- a/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs +++ b/DotPilot.Runtime/Features/AgentSessions/AgentSessionServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using DotPilot.Core.Features.AgentSessions; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; @@ -19,6 +20,7 @@ public static IServiceCollection AddAgentSessions( serviceProvider.GetRequiredService()); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/DotPilot.Runtime/Features/AgentSessions/AgentWorkspaceState.cs b/DotPilot.Runtime/Features/AgentSessions/AgentWorkspaceState.cs new file mode 100644 index 0000000..09fea5d --- /dev/null +++ b/DotPilot.Runtime/Features/AgentSessions/AgentWorkspaceState.cs @@ -0,0 +1,282 @@ +using System.Collections.Immutable; +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Core.Features.ControlPlaneDomain; +using Microsoft.Extensions.Logging; + +namespace DotPilot.Runtime.Features.AgentSessions; + +internal sealed class AgentWorkspaceState( + IAgentSessionService agentSessionService, + IAgentProviderStatusCache providerStatusCache, + ILogger logger) + : IAgentWorkspaceState, IDisposable +{ + private readonly SemaphoreSlim _cacheGate = new(1, 1); + private readonly Dictionary _sessions = []; + private AgentWorkspaceSnapshot? _workspace; + + public async ValueTask GetWorkspaceAsync(CancellationToken cancellationToken) + { + if (_workspace is { } cachedWorkspace) + { + LogWorkspaceCacheHit(cachedWorkspace); + return cachedWorkspace; + } + + await _cacheGate.WaitAsync(cancellationToken); + try + { + if (_workspace is { } gatedWorkspace) + { + LogWorkspaceCacheHit(gatedWorkspace); + return gatedWorkspace; + } + + return await LoadWorkspaceAsync(forceRefresh: false, cancellationToken); + } + finally + { + _cacheGate.Release(); + } + } + + public async ValueTask RefreshWorkspaceAsync(CancellationToken cancellationToken) + { + await _cacheGate.WaitAsync(cancellationToken); + try + { + return await LoadWorkspaceAsync(forceRefresh: true, cancellationToken); + } + finally + { + _cacheGate.Release(); + } + } + + public async ValueTask GetSessionAsync(SessionId sessionId, CancellationToken cancellationToken) + { + if (_sessions.TryGetValue(sessionId, out var cachedSession)) + { + AgentWorkspaceStateLog.SessionCacheHit(logger, sessionId, cachedSession.Entries.Count); + return cachedSession; + } + + var session = await agentSessionService.GetSessionAsync(sessionId, cancellationToken); + if (session is null) + { + return null; + } + + await _cacheGate.WaitAsync(cancellationToken); + try + { + _sessions[sessionId] = session; + _workspace = MergeWorkspaceSession(_workspace, session.Session); + } + finally + { + _cacheGate.Release(); + } + + return session; + } + + public async ValueTask CreateAgentAsync( + CreateAgentProfileCommand command, + CancellationToken cancellationToken) + { + var created = await agentSessionService.CreateAgentAsync(command, cancellationToken); + + await _cacheGate.WaitAsync(cancellationToken); + try + { + if (_workspace is not null) + { + var agents = _workspace.Agents + .Append(created) + .OrderBy(agent => agent.Name, StringComparer.OrdinalIgnoreCase) + .ToImmutableArray(); + _workspace = _workspace with + { + Agents = agents, + }; + } + } + finally + { + _cacheGate.Release(); + } + + AgentWorkspaceStateLog.AgentCached(logger, created.Id, created.ProviderKind); + + return created; + } + + public async ValueTask CreateSessionAsync( + CreateSessionCommand command, + CancellationToken cancellationToken) + { + var created = await agentSessionService.CreateSessionAsync(command, cancellationToken); + + await _cacheGate.WaitAsync(cancellationToken); + try + { + _sessions[created.Session.Id] = created; + _workspace = MergeWorkspaceSession(_workspace, created.Session); + } + finally + { + _cacheGate.Release(); + } + + AgentWorkspaceStateLog.SessionCached(logger, created.Session.Id, created.Session.PrimaryAgentId); + + return created; + } + + public async ValueTask UpdateProviderAsync( + UpdateProviderPreferenceCommand command, + CancellationToken cancellationToken) + { + var provider = await agentSessionService.UpdateProviderAsync(command, cancellationToken); + + await _cacheGate.WaitAsync(cancellationToken); + try + { + if (_workspace is not null) + { + var providers = _workspace.Providers + .Select(existing => existing.Kind == provider.Kind ? provider : existing) + .ToImmutableArray(); + _workspace = _workspace with + { + Providers = providers, + }; + } + } + finally + { + _cacheGate.Release(); + } + + AgentWorkspaceStateLog.ProviderCached(logger, provider.Kind, provider.IsEnabled); + + return provider; + } + + public async IAsyncEnumerable SendMessageAsync( + SendSessionMessageCommand command, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken) + { + _ = await GetSessionAsync(command.SessionId, cancellationToken); + + await foreach (var entry in agentSessionService.SendMessageAsync(command, cancellationToken)) + { + await _cacheGate.WaitAsync(cancellationToken); + try + { + if (_sessions.TryGetValue(command.SessionId, out var cachedSession)) + { + var updatedSession = cachedSession with + { + Entries = UpsertEntries(cachedSession.Entries, entry), + }; + _sessions[command.SessionId] = updatedSession; + _workspace = MergeWorkspaceSession( + _workspace, + UpdateSessionPreview(updatedSession.Session, entry)); + } + } + finally + { + _cacheGate.Release(); + } + + yield return entry; + } + } + + private static AgentWorkspaceSnapshot? MergeWorkspaceSession( + AgentWorkspaceSnapshot? workspace, + SessionListItem session) + { + if (workspace is null) + { + return null; + } + + var sessions = workspace.Sessions + .Where(existing => existing.Id != session.Id) + .Append(session) + .OrderByDescending(existing => existing.UpdatedAt) + .ToImmutableArray(); + + return workspace with + { + Sessions = sessions, + SelectedSessionId = workspace.SelectedSessionId ?? session.Id, + }; + } + + private static ImmutableArray UpsertEntries( + IReadOnlyList entries, + SessionStreamEntry entry) + { + var updatedEntries = entries.ToList(); + var existingIndex = updatedEntries.FindIndex(existing => existing.Id == entry.Id); + if (existingIndex >= 0) + { + updatedEntries[existingIndex] = entry; + } + else + { + updatedEntries.Add(entry); + } + + return updatedEntries + .OrderBy(existing => existing.Timestamp) + .ToImmutableArray(); + } + + private static SessionListItem UpdateSessionPreview(SessionListItem session, SessionStreamEntry entry) + { + return session with + { + Preview = string.IsNullOrWhiteSpace(entry.Text) ? session.Preview : entry.Text, + UpdatedAt = entry.Timestamp, + }; + } + + public void Dispose() + { + _cacheGate.Dispose(); + } + + private async ValueTask LoadWorkspaceAsync( + bool forceRefresh, + CancellationToken cancellationToken) + { + if (forceRefresh) + { + await providerStatusCache.RefreshAsync(cancellationToken); + } + + var workspace = await agentSessionService.GetWorkspaceAsync(cancellationToken); + _workspace = workspace; + AgentWorkspaceStateLog.WorkspaceRefreshed( + logger, + workspace.Sessions.Count, + workspace.Agents.Count, + workspace.Providers.Count); + return workspace; + } + + private void LogWorkspaceCacheHit(AgentWorkspaceSnapshot workspace) + { + AgentWorkspaceStateLog.WorkspaceCacheHit( + logger, + workspace.Sessions.Count, + workspace.Agents.Count, + workspace.Providers.Count); + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/AgentWorkspaceStateTests.cs b/DotPilot.Tests/Features/AgentSessions/AgentWorkspaceStateTests.cs new file mode 100644 index 0000000..5da621e --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/AgentWorkspaceStateTests.cs @@ -0,0 +1,132 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +[NonParallelizable] +public sealed class AgentWorkspaceStateTests +{ + [Test] + public async Task ConcurrentColdWorkspaceReadsOnlyProbeProviderStatusOnce() + { + using var commandScope = CommandProbeScope.Create(); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + + await using var fixture = CreateFixture(); + + await Task.WhenAll( + Enumerable.Range(0, 4) + .Select(_ => fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None).AsTask())); + + commandScope.ReadInvocationCount("codex").Should().Be(1); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + return new TestFixture(provider, provider.GetRequiredService()); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return provider.DisposeAsync(); + } + } + + private sealed class CommandProbeScope : IDisposable + { + private readonly string _rootPath; + private readonly string? _originalPath; + private bool _disposed; + + private CommandProbeScope(string rootPath, string? originalPath) + { + _rootPath = rootPath; + _originalPath = originalPath; + } + + public static CommandProbeScope Create() + { + var originalPath = Environment.GetEnvironmentVariable("PATH"); + var rootPath = Path.Combine( + Path.GetTempPath(), + "DotPilot.Tests", + nameof(AgentWorkspaceStateTests), + Guid.NewGuid().ToString("N", System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(rootPath); + Environment.SetEnvironmentVariable("PATH", rootPath); + return new CommandProbeScope(rootPath, originalPath); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + Environment.SetEnvironmentVariable("PATH", _originalPath); + if (Directory.Exists(_rootPath)) + { + Directory.Delete(_rootPath, recursive: true); + } + + _disposed = true; + } + + public int ReadInvocationCount(string commandName) + { + var callCountPath = GetCallCountPath(commandName); + return File.Exists(callCountPath) + ? File.ReadAllLines(callCountPath).Length + : 0; + } + + public void WriteVersionCommand(string commandName, string output) + { + var commandPath = OperatingSystem.IsWindows() + ? Path.Combine(_rootPath, commandName + ".cmd") + : Path.Combine(_rootPath, commandName); + var callCountPath = GetCallCountPath(commandName); + var commandBody = OperatingSystem.IsWindows() + ? $"@echo off{Environment.NewLine}echo called>>\"{callCountPath}\"{Environment.NewLine}echo {output}{Environment.NewLine}" + : $"#!/bin/sh{Environment.NewLine}echo called >> \"{callCountPath}\"{Environment.NewLine}echo \"{output}\"{Environment.NewLine}"; + + File.WriteAllText(commandPath, commandBody); + + if (OperatingSystem.IsWindows()) + { + return; + } + + File.SetUnixFileMode( + commandPath, + UnixFileMode.UserRead | + UnixFileMode.UserWrite | + UnixFileMode.UserExecute | + UnixFileMode.GroupRead | + UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | + UnixFileMode.OtherExecute); + } + + private string GetCallCountPath(string commandName) + { + return Path.Combine(_rootPath, commandName + ".calls"); + } + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/MainModelTests.cs b/DotPilot.Tests/Features/AgentSessions/MainModelTests.cs new file mode 100644 index 0000000..d17e2e4 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/MainModelTests.cs @@ -0,0 +1,68 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Presentation; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class MainModelTests +{ + [Test] + public async Task SendMessageStreamsDebugTranscriptForAnActiveSession() + { + await using var fixture = await CreateFixtureAsync(); + await fixture.WorkspaceState.CreateAgentAsync( + new CreateAgentProfileCommand( + "Debug Agent", + AgentRoleKind.Operator, + AgentProviderKind.Debug, + "debug-echo", + "Be deterministic for automated verification.", + ["Shell"]), + CancellationToken.None); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.StartNewSession(CancellationToken.None); + await model.ComposerText.SetAsync("hello from model", CancellationToken.None); + + await model.SendMessage(CancellationToken.None); + + var activeSession = await model.ActiveSession; + activeSession.Should().NotBeNull(); + activeSession!.Messages.Should().Contain(message => + message.Content.Contains("Debug provider received: hello from model", StringComparison.Ordinal)); + activeSession.Messages.Should().Contain(message => + message.Content.Contains("Debug workflow finished", StringComparison.Ordinal)); + activeSession.StatusSummary.Should().Be("Debug Agent · Debug Provider"); + } + + private static async Task CreateFixtureAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + var workspaceState = provider.GetRequiredService(); + await workspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + return new TestFixture(provider, workspaceState); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/SecondModelTests.cs b/DotPilot.Tests/Features/AgentSessions/SecondModelTests.cs new file mode 100644 index 0000000..7b68566 --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/SecondModelTests.cs @@ -0,0 +1,89 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Presentation; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class SecondModelTests +{ + [Test] + public async Task CreateAgentUsesSuggestedDebugModelWhenModelOverrideIsBlank() + { + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var builder = (await model.Builder)!; + builder.ProviderDisplayName.Should().Be("Debug Provider"); + builder.SuggestedModelName.Should().Be("debug-echo"); + builder.CanCreateAgent.Should().BeTrue(); + (await model.ModelName).Should().BeEmpty(); + + await model.CreateAgent(CancellationToken.None); + + var workspace = await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None); + workspace.Agents.Should().ContainSingle(agent => + agent.Name == "Debug Agent" && + agent.ProviderKind == AgentProviderKind.Debug && + agent.ModelName == "debug-echo"); + (await model.OperationMessage).Should().Be("Created Debug Agent using Debug Provider."); + (await model.Builder)!.StatusMessage.Should().Be("Ready to create an agent with Debug Provider."); + } + + [Test] + public async Task BuilderProjectionReflectsSelectedProviderSuggestionAndVersion() + { + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + await model.SelectedProvider.UpdateAsync( + _ => new AgentProviderOption( + AgentProviderKind.GitHubCopilot, + "GitHub Copilot", + "copilot", + "GitHub Copilot CLI is available on PATH.", + "0.0.421", + false), + CancellationToken.None); + + var builder = (await model.Builder)!; + + builder.ProviderDisplayName.Should().Be("GitHub Copilot"); + builder.ProviderCommandName.Should().Be("copilot"); + builder.ProviderVersionLabel.Should().Be("Version 0.0.421"); + builder.HasProviderVersion.Should().BeTrue(); + builder.SuggestedModelName.Should().Be("gpt-5"); + builder.StatusMessage.Should().Be("GitHub Copilot CLI is available on PATH."); + builder.CanCreateAgent.Should().BeFalse(); + } + + private static async Task CreateFixtureAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + var workspaceState = provider.GetRequiredService(); + await workspaceState.UpdateProviderAsync( + new UpdateProviderPreferenceCommand(AgentProviderKind.Debug, true), + CancellationToken.None); + return new TestFixture(provider, workspaceState); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Features/AgentSessions/SettingsModelTests.cs b/DotPilot.Tests/Features/AgentSessions/SettingsModelTests.cs new file mode 100644 index 0000000..06bd6fa --- /dev/null +++ b/DotPilot.Tests/Features/AgentSessions/SettingsModelTests.cs @@ -0,0 +1,62 @@ +using DotPilot.Core.Features.AgentSessions; +using DotPilot.Presentation; +using DotPilot.Runtime.Features.AgentSessions; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Features.AgentSessions; + +public sealed class SettingsModelTests +{ + [Test] + public async Task ToggleSelectedProviderUpdatesProjectionToEnabledDebugProvider() + { + await using var fixture = CreateFixture(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + var providers = await model.Providers; + providers.Should().ContainSingle(provider => provider.Kind == AgentProviderKind.Debug); + (await model.SelectedProviderTitle).Should().Be("Debug Provider"); + (await model.ToggleActionLabel).Should().Be("Enable provider"); + (await model.CanToggleSelectedProvider).Should().BeTrue(); + + await model.ToggleSelectedProvider(CancellationToken.None); + + (await model.SelectedProviderTitle).Should().Be("Debug Provider"); + (await model.ToggleActionLabel).Should().Be("Disable provider"); + (await model.SelectedProvider).Should().NotBeNull(); + (await model.SelectedProvider)!.IsEnabled.Should().BeTrue(); + + var workspace = await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None); + workspace.Providers.Should().ContainSingle(provider => + provider.Kind == AgentProviderKind.Debug && + provider.IsEnabled && + provider.CanCreateAgents); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + var workspaceState = provider.GetRequiredService(); + return new TestFixture(provider, workspaceState); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs index 68d006f..44a0715 100644 --- a/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs +++ b/DotPilot.UITests/Features/AgentSessions/GivenChatSessionsShell.cs @@ -20,13 +20,18 @@ public sealed class GivenChatSessionsShell : TestBase private const string ProviderListAutomationId = "ProviderList"; private const string SelectedProviderTitleAutomationId = "SelectedProviderTitle"; private const string ToggleProviderButtonAutomationId = "ToggleProviderButton"; + private const string AgentBasicInfoSectionAutomationId = "AgentBasicInfoSection"; private const string AgentNameInputAutomationId = "AgentNameInput"; private const string AgentModelInputAutomationId = "AgentModelInput"; + private const string AgentRoleComboAutomationId = "AgentRoleCombo"; + private const string AgentPromptSectionAutomationId = "AgentPromptSection"; + private const string AgentSkillsSectionAutomationId = "AgentSkillsSection"; + private const string AgentSuggestedModelTextAutomationId = "AgentSuggestedModelText"; + private const string AgentCreateHelperMessageAutomationId = "AgentCreateHelperMessage"; private const string AgentCreateStatusMessageAutomationId = "AgentCreateStatusMessage"; private const string CreateAgentButtonAutomationId = "CreateAgentButton"; private const string ChatComposerInputAutomationId = "ChatComposerInput"; private const string ChatComposerHintAutomationId = "ChatComposerHint"; - private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton"; private const string ChatStartNewButtonAutomationId = "ChatStartNewButton"; private const string ChatTitleTextAutomationId = "ChatTitleText"; private const string ChatMessageTextAutomationId = "ChatMessageText"; @@ -53,6 +58,54 @@ public async Task WhenOpeningTheAppThenChatNavigationAndComposerAreVisible() TakeScreenshot("chat_shell_visible"); } + [Test] + public async Task WhenEnablingDebugAndOpeningAgentCreationThenBuilderSectionsAreVisible() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForTextContains(SelectedProviderTitleAutomationId, DebugProviderName, ScreenTransitionTimeout); + TapAutomationElement(ToggleProviderButtonAutomationId); + WaitForTextContains(ToggleProviderButtonAutomationId, "Disable provider", ScreenTransitionTimeout); + + TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForElement(AgentBasicInfoSectionAutomationId); + WaitForElement(AgentNameInputAutomationId); + WaitForElement(AgentRoleComboAutomationId); + WaitForElement(AgentModelInputAutomationId); + WaitForElement(AgentPromptSectionAutomationId); + WaitForElement(AgentSkillsSectionAutomationId); + WaitForTextContains(AgentSuggestedModelTextAutomationId, "debug-echo", ScreenTransitionTimeout); + WaitForTextContains(AgentCreateHelperMessageAutomationId, ReadyToCreateDebugAgentText, ScreenTransitionTimeout); + + TakeScreenshot("agent_builder_sections_visible"); + } + + [Test] + public async Task WhenEnablingDebugAndCreatingAgentThenBuilderShowsCreatedStatus() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + WaitForTextContains(SelectedProviderTitleAutomationId, DebugProviderName, ScreenTransitionTimeout); + TapAutomationElement(ToggleProviderButtonAutomationId); + WaitForTextContains(ToggleProviderButtonAutomationId, "Disable provider", ScreenTransitionTimeout); + + TapAutomationElement(SettingsSidebarAgentsButtonAutomationId); + WaitForElement(AgentBuilderScreenAutomationId); + WaitForTextContains(AgentCreateHelperMessageAutomationId, ReadyToCreateDebugAgentText, ScreenTransitionTimeout); + ReplaceTextAutomationElement(AgentNameInputAutomationId, CreatedAgentName); + TapAutomationElement(CreateAgentButtonAutomationId); + WaitForTextContains(AgentCreateStatusMessageAutomationId, "Created Debug Agent UI using Debug Provider.", ScreenTransitionTimeout); + + TakeScreenshot("agent_builder_created_status"); + } + [Test] public async Task WhenEnablingDebugCreatingAgentAndSendingMessageThenStreamedTranscriptIsVisible() { @@ -70,7 +123,7 @@ public async Task WhenEnablingDebugCreatingAgentAndSendingMessageThenStreamedTra WaitForElement(AgentBuilderScreenAutomationId); WaitForElement(AgentNameInputAutomationId); WaitForElement(AgentModelInputAutomationId); - WaitForTextContains(AgentCreateStatusMessageAutomationId, ReadyToCreateDebugAgentText, ScreenTransitionTimeout); + WaitForTextContains(AgentCreateHelperMessageAutomationId, ReadyToCreateDebugAgentText, ScreenTransitionTimeout); ReplaceTextAutomationElement(AgentNameInputAutomationId, CreatedAgentName); TapAutomationElement(CreateAgentButtonAutomationId); WaitForTextContains(AgentCreateStatusMessageAutomationId, "Created Debug Agent UI using Debug Provider.", ScreenTransitionTimeout); @@ -81,8 +134,9 @@ public async Task WhenEnablingDebugCreatingAgentAndSendingMessageThenStreamedTra WaitForElement(ChatRecentChatItemAutomationId); WaitForTextContains(ChatTitleTextAutomationId, SessionTitle, ScreenTransitionTimeout); - App.EnterText(ChatComposerInputAutomationId, UserPrompt); - TapAutomationElement(ChatComposerSendButtonAutomationId); + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + WriteBrowserAutomationDiagnostics(ChatComposerInputAutomationId); + PressEnterAutomationElement(ChatComposerInputAutomationId); WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); diff --git a/DotPilot.UITests/Harness/TestBase.cs b/DotPilot.UITests/Harness/TestBase.cs index 0380274..88e2c34 100644 --- a/DotPilot.UITests/Harness/TestBase.cs +++ b/DotPilot.UITests/Harness/TestBase.cs @@ -1,3 +1,6 @@ +using OpenQA.Selenium; +using UITestPlatform = Uno.UITest.Helpers.Queries.Platform; + namespace DotPilot.UITests.Harness; [System.Diagnostics.CodeAnalysis.SuppressMessage( @@ -16,7 +19,7 @@ public class TestBase private static readonly TimeSpan AppCleanupTimeout = TimeSpan.FromSeconds(15); private static readonly BrowserAutomationSettings? _browserAutomation = - Constants.CurrentPlatform == Platform.Browser + Constants.CurrentPlatform == UITestPlatform.Browser ? BrowserAutomationBootstrap.Resolve() : null; private static readonly bool _browserHeadless = ResolveBrowserHeadless(); @@ -24,7 +27,7 @@ public class TestBase static TestBase() { - if (Constants.CurrentPlatform == Platform.Browser) + if (Constants.CurrentPlatform == UITestPlatform.Browser) { HarnessLog.Write($"Browser test target URI is '{Constants.WebAssemblyDefaultUri}'."); HarnessLog.Write($"Browser binary path is '{_browserAutomation!.BrowserBinaryPath}'."); @@ -42,7 +45,7 @@ static TestBase() AppInitializer.TestEnvironment.CurrentPlatform = Constants.CurrentPlatform; AppInitializer.TestEnvironment.WebAssemblyBrowser = Constants.WebAssemblyBrowser; - if (Constants.CurrentPlatform != Platform.Browser) + if (Constants.CurrentPlatform != UITestPlatform.Browser) { // Start the app only once, so the tests runs don't restart it // and gain some time for the tests. @@ -64,7 +67,7 @@ private set public void SetUpTest() { HarnessLog.Write($"Starting setup for '{TestContext.CurrentContext.Test.Name}'."); - App = Constants.CurrentPlatform == Platform.Browser + App = Constants.CurrentPlatform == UITestPlatform.Browser ? StartBrowserApp(_browserAutomation!) : AppInitializer.AttachToApp(); HarnessLog.Write($"Setup completed for '{TestContext.CurrentContext.Test.Name}'."); @@ -81,7 +84,7 @@ public void TearDownTest() TakeScreenshot("teardown"); } - if (Constants.CurrentPlatform == Platform.Browser && _app is not null) + if (Constants.CurrentPlatform == UITestPlatform.Browser && _app is not null) { TryCleanup( () => _app.Dispose(), @@ -116,7 +119,7 @@ public void TearDownFixture() { TryCleanup( () => _app.Dispose(), - Constants.CurrentPlatform == Platform.Browser + Constants.CurrentPlatform == UITestPlatform.Browser ? BrowserAppCleanupOperationName : AttachedAppCleanupOperationName, cleanupFailures); @@ -124,7 +127,7 @@ public void TearDownFixture() _app = null; - if (Constants.CurrentPlatform == Platform.Browser) + if (Constants.CurrentPlatform == UITestPlatform.Browser) { TryCleanup( BrowserTestHost.Stop, @@ -182,7 +185,7 @@ public FileInfo TakeScreenshot(string stepName) protected void WriteBrowserSystemLogs(string context, int maxEntries = 50) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { return; } @@ -208,7 +211,7 @@ protected void WriteBrowserSystemLogs(string context, int maxEntries = 50) protected void WriteBrowserDomSnapshot(string context, string? automationId = null) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { return; } @@ -323,8 +326,23 @@ protected void TapAutomationElement(string automationId) HarnessLog.Write( $"Tap target '{automationId}' enabled='{target.Enabled}' rect='{target.Rect}' text='{target.Text}' label='{target.Label}'."); - if (Constants.CurrentPlatform == Platform.Browser) + if (Constants.CurrentPlatform == UITestPlatform.Browser) { + try + { + App.Tap(automationId); + return; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser element tap failed for '{automationId}': {exception.Message}"); + } + + if (TryClickBrowserAutomationElement(automationId)) + { + return; + } + App.TapCoordinates(target.Rect.CenterX, target.Rect.CenterY); return; } @@ -353,7 +371,7 @@ protected void TapAutomationElement(string automationId) HarnessLog.Write($"Tap selector diagnostics failed for '{automationId}': {diagnosticException.Message}"); } - if (Constants.CurrentPlatform == Platform.Browser) + if (Constants.CurrentPlatform == UITestPlatform.Browser) { var fallbackMatches = App.Query(automationId); if (fallbackMatches.Length > 0) @@ -377,6 +395,11 @@ protected void ReplaceTextAutomationElement(string automationId, string text) ArgumentException.ThrowIfNullOrWhiteSpace(automationId); ArgumentNullException.ThrowIfNull(text); + if (TryTypeBrowserInputValue(automationId, text)) + { + return; + } + if (TrySetBrowserInputValue(automationId, text)) { return; @@ -398,9 +421,21 @@ protected void ClickActionAutomationElement(string automationId) TapAutomationElement(automationId); } + protected void PressEnterAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (TryPressEnterBrowserInput(automationId)) + { + return; + } + + App.EnterText(automationId, Keys.Enter); + } + private bool TryClickBrowserAutomationElement(string automationId) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { return false; } @@ -488,9 +523,154 @@ private bool TryClickBrowserAutomationElement(string automationId) } } + private bool TryTypeBrowserInputValue(string automationId, string text) + { + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) + { + return false; + } + + try + { + if (_app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app) is not IWebDriver driver) + { + return false; + } + + if (!TryFocusBrowserInput(driver, automationId)) + { + return false; + } + + var activeElement = driver.SwitchTo().ActiveElement(); + if (activeElement is null) + { + return false; + } + + ClearActiveBrowserInput(activeElement); + activeElement.SendKeys(text); + + var value = activeElement.GetAttribute("value") ?? activeElement.Text ?? string.Empty; + HarnessLog.Write($"Browser input typing outcome for '{automationId}': '{value}'."); + return string.Equals(value, text, StringComparison.Ordinal); + } + catch (Exception exception) + { + HarnessLog.Write($"Browser input typing failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private bool TryPressEnterBrowserInput(string automationId) + { + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) + { + return false; + } + + try + { + if (_app + .GetType() + .GetField("_driver", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(_app) is not IWebDriver driver || + !TryFocusBrowserInput(driver, automationId)) + { + return false; + } + + driver.SwitchTo().ActiveElement().SendKeys(Keys.Enter); + HarnessLog.Write($"Browser input enter outcome for '{automationId}': pressed."); + return true; + } + catch (Exception exception) + { + HarnessLog.Write($"Browser input enter failed for '{automationId}': {exception.Message}"); + return false; + } + } + + private static void ClearActiveBrowserInput(IWebElement activeElement) + { + ArgumentNullException.ThrowIfNull(activeElement); + + try + { + activeElement.Clear(); + } + catch (InvalidElementStateException) + { + } + + var currentValue = activeElement.GetAttribute("value") ?? activeElement.Text ?? string.Empty; + if (string.IsNullOrEmpty(currentValue)) + { + return; + } + + var selectAllModifier = OperatingSystem.IsMacOS() ? Keys.Command : Keys.Control; + activeElement.SendKeys($"{selectAllModifier}a"); + activeElement.SendKeys(Keys.Backspace); + } + + private static bool TryFocusBrowserInput(IWebDriver driver, string automationId) + { + ArgumentNullException.ThrowIfNull(driver); + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (driver is not IJavaScriptExecutor javaScriptExecutor) + { + return false; + } + + var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + var outcome = javaScriptExecutor.ExecuteScript( + string.Concat( + """ + return (() => { + const automationId = ' + """, + escapedAutomationId, + """ + '; + const selector = `[xamlautomationid="${automationId}"], [aria-label="${automationId}"]`; + const host = document.querySelector(selector); + if (!host) { + return 'missing'; + } + + const element = host.matches('input, textarea, [contenteditable="true"]') + ? host + : host.querySelector('input, textarea, [contenteditable="true"]'); + if (!element) { + return 'not-an-input'; + } + + element.scrollIntoView({ block: 'center', inline: 'center' }); + element.focus(); + + if ('select' in element) { + element.select(); + } + + return 'focused'; + })(); + """)); + + HarnessLog.Write($"Browser input focus outcome for '{automationId}': {outcome}"); + return string.Equals( + Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), + "focused", + StringComparison.Ordinal); + } + private bool TrySetBrowserInputValue(string automationId, string text) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { return false; } @@ -585,7 +765,7 @@ private bool TrySetBrowserInputValue(string automationId, string text) protected void WriteBrowserAutomationDiagnostics(string automationId) { - if (Constants.CurrentPlatform != Platform.Browser || _app is null) + if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { return; } @@ -632,6 +812,13 @@ protected void WriteBrowserAutomationDiagnostics(string automationId) xamlAutomationId: element.getAttribute('xamlautomationid') ?? '', xamlType: element.getAttribute('xamltype') ?? '', text: (element.innerText ?? '').trim(), + value: 'value' in element ? element.value : '', + childInputs: Array.from(element.querySelectorAll('input, textarea, [contenteditable="true"]')).map((child, childIndex) => ({ + childIndex, + tag: child.tagName, + className: child.className, + value: 'value' in child ? child.value : child.textContent ?? '' + })), html: element.outerHTML.slice(0, 300) })); const byAria = Array.from(document.querySelectorAll(`[aria-label="${automationId}"]`)) @@ -643,6 +830,13 @@ protected void WriteBrowserAutomationDiagnostics(string automationId) xamlAutomationId: element.getAttribute('xamlautomationid') ?? '', xamlType: element.getAttribute('xamltype') ?? '', text: (element.innerText ?? '').trim(), + value: 'value' in element ? element.value : '', + childInputs: Array.from(element.querySelectorAll('input, textarea, [contenteditable="true"]')).map((child, childIndex) => ({ + childIndex, + tag: child.tagName, + className: child.className, + value: 'value' in child ? child.value : child.textContent ?? '' + })), html: element.outerHTML.slice(0, 300) })); return JSON.stringify({ byAutomation, byAria }); diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index 2597f97..bcd694e 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -74,6 +74,10 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) // Default filters for core Uno Platform namespaces .CoreLogLevel(LogLevel.Warning); +#if !__WASM__ + logBuilder.AddConsole(); +#endif + // Uno Platform namespace filter groups // Uncomment individual methods to see more detailed logging //// Generic Xaml events @@ -113,14 +117,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions .AddSingleton(services, TimeProvider.System); services.AddAgentSessions(); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); - Microsoft.Extensions.DependencyInjection.ServiceCollectionServiceExtensions - .AddTransient(services); + services.AddPresentationModels(); }) .UseNavigation(RegisterRoutes) ); diff --git a/DotPilot/DotPilot.csproj b/DotPilot/DotPilot.csproj index bfc38fc..70b0a4d 100644 --- a/DotPilot/DotPilot.csproj +++ b/DotPilot/DotPilot.csproj @@ -29,6 +29,7 @@ Toolkit; Logging; Mvvm; + MVUX; Configuration; Http; Serialization; diff --git a/DotPilot/GlobalUsings.cs b/DotPilot/GlobalUsings.cs index f7be021..b51218d 100644 --- a/DotPilot/GlobalUsings.cs +++ b/DotPilot/GlobalUsings.cs @@ -1,2 +1,3 @@ global using DotPilot.Core.Features.ApplicationShell; global using DotPilot.Presentation; +[assembly: Uno.Extensions.Reactive.Config.BindableGenerationTool(3)] diff --git a/DotPilot/Presentation/AgentBuilderModels.cs b/DotPilot/Presentation/AgentBuilderModels.cs index d34f69a..8872eeb 100644 --- a/DotPilot/Presentation/AgentBuilderModels.cs +++ b/DotPilot/Presentation/AgentBuilderModels.cs @@ -33,20 +33,6 @@ public bool IsEnabled } [Bindable] -public sealed class RoleOption( - string label, - AgentRoleKind role, - bool isSelected) : ObservableObject -{ - private bool _isSelected = isSelected; - - public string Label { get; } = label; - - public AgentRoleKind Role { get; } = role; - - public bool IsSelected - { - get => _isSelected; - set => SetProperty(ref _isSelected, value); - } -} +public sealed partial record RoleOption( + string Label, + AgentRoleKind Role); diff --git a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml index 2ff93f0..9bd9606 100644 --- a/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml +++ b/DotPilot/Presentation/Controls/AgentBasicInfoSection.xaml @@ -3,135 +3,119 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:presentation="using:DotPilot.Presentation" FontFamily="{StaticResource AppBodyFontFamily}"> - - - - - - - - - - - + + - + Text="Profile" /> + + - + - + + + + + + - + - + + Spacing="8"> + + + + + + + - + - - - - - - - - + - + Text="Model override" /> + + + + + + - - - - - - - - - - - - - - - + + + + + + + + - - - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentPromptSection.xaml b/DotPilot/Presentation/Controls/AgentPromptSection.xaml index 7c21f6f..c2372a2 100644 --- a/DotPilot/Presentation/Controls/AgentPromptSection.xaml +++ b/DotPilot/Presentation/Controls/AgentPromptSection.xaml @@ -2,72 +2,63 @@ xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" FontFamily="{StaticResource AppBodyFontFamily}"> - - - - - - - - - - - + + - + Text="Behavior" /> + + + + + + - + - + - - - - - - diff --git a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml index 1372cbb..c3a775e 100644 --- a/DotPilot/Presentation/Controls/AgentSkillsSection.xaml +++ b/DotPilot/Presentation/Controls/AgentSkillsSection.xaml @@ -3,80 +3,44 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:presentation="using:DotPilot.Presentation" FontFamily="{StaticResource AppBodyFontFamily}"> - - - - - - - - - - - + + - - - + + - - + + - - - - + + Spacing="4"> + TextWrapping="WrapWholeWords" /> - - - + diff --git a/DotPilot/Presentation/Controls/ChatComposer.xaml b/DotPilot/Presentation/Controls/ChatComposer.xaml index 769f8b5..7695ed6 100644 --- a/DotPilot/Presentation/Controls/ChatComposer.xaml +++ b/DotPilot/Presentation/Controls/ChatComposer.xaml @@ -60,7 +60,8 @@ Background="{StaticResource AppSoftSurfaceBrush}" BorderBrush="{StaticResource AppOutlineBrush}" BorderThickness="1" - Command="{Binding StartNewSessionCommand}" + Command="{Binding StartNewSession}" + IsEnabled="{Binding HasAgents}" CornerRadius="20" Padding="14,12"> - + + + - + + + + + + + + + + + + + OperatingSystem.IsBrowser(); + + public bool IsDesktopHead => !IsBrowserHead; + private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e) { - BoundCommandBridge.Execute(ProviderCombo.Tag as ICommand, ProviderCombo.SelectedItem); + if (sender is not ComboBox comboBox) + { + return; + } + + AgentProviderKind? selectedProviderKind = e.AddedItems.OfType().FirstOrDefault()?.Kind; + selectedProviderKind ??= comboBox.SelectedValue as AgentProviderKind?; + if (selectedProviderKind is null && + comboBox.SelectedItem is AgentProviderOption selectedProvider) + { + selectedProviderKind = selectedProvider.Kind; + } + + _ = comboBox.DispatcherQueue.TryEnqueue(() => + { + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Provider selection changed. Provider={selectedProviderKind?.ToString() ?? ""}."); + BoundCommandBridge.Execute(comboBox.Tag as ICommand, selectedProviderKind); + }); + } + + private void OnProviderQuickSelectButtonClick(object sender, RoutedEventArgs e) + { + if (sender is not Button button) + { + return; + } + + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Provider quick-select clicked. Provider={button.CommandParameter?.ToString() ?? ""}."); + BoundCommandBridge.Execute(button.Tag as ICommand, button.CommandParameter); + } + + private void OnModelQuickSelectButtonClick(object sender, RoutedEventArgs e) + { + if (sender is not Button button) + { + return; + } + + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Model quick-select clicked. Model={button.CommandParameter?.ToString() ?? ""}."); + BoundCommandBridge.Execute(button.Tag as ICommand, button.CommandParameter); } } diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml index a18a185..a6a233e 100644 --- a/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentCatalogSection.xaml @@ -25,7 +25,7 @@ diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs index b0d9055..e68485e 100644 --- a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptStartSection.xaml.cs @@ -9,6 +9,13 @@ public AgentPromptStartSection() private void OnGenerateAgentButtonClick(object sender, RoutedEventArgs e) { - BoundCommandBridge.Execute(GenerateAgentButton.Tag as ICommand, PromptInput.Text); + BrowserConsoleDiagnostics.Info("[DotPilot.AgentBuilder] Generate agent click received."); + BoundCommandBridge.Execute((sender as FrameworkElement)?.Tag as ICommand, PromptInput.Text); + } + + private void OnBuildManuallyButtonClick(object sender, RoutedEventArgs e) + { + BrowserConsoleDiagnostics.Info("[DotPilot.AgentBuilder] Build manually click received."); + BoundCommandBridge.Execute((sender as FrameworkElement)?.Tag as ICommand); } } diff --git a/DotPilot/Presentation/AgentBuilder/Models/AgentBuilderModels.cs b/DotPilot/Presentation/AgentBuilder/Models/AgentBuilderModels.cs index 7091ed9..ef8dcc1 100644 --- a/DotPilot/Presentation/AgentBuilder/Models/AgentBuilderModels.cs +++ b/DotPilot/Presentation/AgentBuilder/Models/AgentBuilderModels.cs @@ -19,7 +19,20 @@ public sealed partial record AgentProviderOption( string SuggestedModelName, IReadOnlyList SupportedModelNames, string? InstalledVersion, - bool CanCreateAgents); + bool CanCreateAgents) +{ + public string SelectionAutomationId => AgentBuilderAutomationIds.ForProvider(Kind); +} + +[Bindable] +public sealed partial record AgentModelOption( + string DisplayName, + string SelectionAutomationId); + +[Bindable] +public sealed partial record AgentCatalogStartChatRequest( + AgentProfileId AgentId, + string AgentName); [Bindable] public sealed partial record AgentCatalogItem( @@ -29,7 +42,10 @@ public sealed partial record AgentCatalogItem( string Description, string ProviderDisplayName, string ModelName, - bool IsDefault); + bool IsDefault, + string StartChatAutomationId, + AgentCatalogStartChatRequest StartChatRequest, + ICommand? StartChatCommand); [Bindable] public sealed partial record AgentBuilderSurface( @@ -45,3 +61,22 @@ public sealed partial record AgentBuilderSurface( public bool ShowEditor => Kind == AgentBuilderSurfaceKind.Editor; } + +internal static class AgentBuilderAutomationIds +{ + public static string ForProvider(AgentProviderKind kind) + { + return "AgentProviderOption_" + CreateAutomationIdSuffix(kind.ToString()); + } + + public static string ForModel(string modelName) + { + return "AgentModelOption_" + CreateAutomationIdSuffix(modelName); + } + + private static string CreateAutomationIdSuffix(string value) + { + var characters = value.Where(char.IsLetterOrDigit).ToArray(); + return characters.Length == 0 ? "Unknown" : new string(characters); + } +} diff --git a/DotPilot/Presentation/AgentBuilder/ViewModels/AgentBuilderModel.cs b/DotPilot/Presentation/AgentBuilder/ViewModels/AgentBuilderModel.cs index 394d152..03517a0 100644 --- a/DotPilot/Presentation/AgentBuilder/ViewModels/AgentBuilderModel.cs +++ b/DotPilot/Presentation/AgentBuilder/ViewModels/AgentBuilderModel.cs @@ -1,5 +1,6 @@ using System.Collections.Immutable; using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ControlPlaneDomain; using Microsoft.Extensions.Logging; using Microsoft.UI.Xaml.Data; @@ -9,6 +10,8 @@ namespace DotPilot.Presentation; public partial record AgentBuilderModel( IAgentWorkspaceState workspaceState, AgentPromptDraftGenerator draftGenerator, + WorkspaceProjectionNotifier workspaceProjectionNotifier, + ShellNavigationNotifier shellNavigationNotifier, ILogger logger) { private const string EmptyProviderDisplayName = "Select a provider"; @@ -35,7 +38,7 @@ public partial record AgentBuilderModel( private const string CreateActionLabel = "Create agent"; private const string SaveActionLabel = "Save agent"; private const string SessionTitlePrefix = "Session with "; - private const string StartedChatMessageFormat = "Started a session with {0}. Switch to Chat to continue."; + private const string StartedChatMessageFormat = "Started a session with {0}."; private static readonly System.Text.CompositeFormat SavedAgentCompositeFormat = System.Text.CompositeFormat.Parse(SavedAgentMessageFormat); private static readonly System.Text.CompositeFormat GeneratedDraftCompositeFormat = @@ -59,6 +62,7 @@ public partial record AgentBuilderModel( private AsyncCommand? _saveAgentCommand; private AsyncCommand? _startChatForAgentCommand; private AsyncCommand? _providerSelectionChangedCommand; + private AsyncCommand? _selectModelCommand; private readonly Signal _workspaceRefresh = new(); public IState Surface => State.Value(this, static () => CatalogSurface); @@ -111,11 +115,15 @@ public partial record AgentBuilderModel( public ICommand StartChatForAgentCommand => _startChatForAgentCommand ??= new AsyncCommand( - parameter => StartChatForAgent(parameter as AgentCatalogItem, CancellationToken.None)); + parameter => StartChatForParameter(parameter, CancellationToken.None)); public ICommand ProviderSelectionChangedCommand => _providerSelectionChangedCommand ??= new AsyncCommand( - parameter => HandleSelectedProviderChanged(parameter as AgentProviderOption, CancellationToken.None)); + parameter => HandleProviderSelectionChanged(parameter, CancellationToken.None)); + + public ICommand SelectModelCommand => + _selectModelCommand ??= new AsyncCommand( + parameter => SelectModel(parameter, CancellationToken.None)); public async ValueTask OpenCreateAgent(CancellationToken cancellationToken) { @@ -184,6 +192,37 @@ public async ValueTask HandleSelectedProviderChanged( } } + public async ValueTask HandleProviderSelectionChanged( + object? parameter, + CancellationToken cancellationToken) + { + var provider = parameter switch + { + AgentProviderOption option => option, + AgentProviderKind providerKind => FindProviderByKind(await Providers, providerKind), + _ => EmptySelectedProvider, + }; + + await HandleSelectedProviderChanged(provider, cancellationToken); + } + + public async ValueTask SelectModel(object? parameter, CancellationToken cancellationToken) + { + var modelName = parameter switch + { + AgentModelOption option => option.DisplayName, + string value => value, + _ => string.Empty, + }; + + if (string.IsNullOrWhiteSpace(modelName)) + { + return; + } + + await ModelName.UpdateAsync(_ => modelName.Trim(), cancellationToken); + } + private async ValueTask SubmitAgentDraftCore(string? promptOverride, CancellationToken cancellationToken) { try @@ -258,16 +297,18 @@ public async ValueTask SaveAgent(CancellationToken cancellationToken) return; } + var savedMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + SavedAgentCompositeFormat, + created.Name, + created.ProviderDisplayName); _workspaceRefresh.Raise(); - await OperationMessage.SetAsync( - string.Format( - System.Globalization.CultureInfo.InvariantCulture, - SavedAgentCompositeFormat, - created.Name, - created.ProviderDisplayName), - cancellationToken); + workspaceProjectionNotifier.Publish(); + await OperationMessage.SetAsync(savedMessage, cancellationToken); await Surface.UpdateAsync(_ => CatalogSurface, cancellationToken); AgentBuilderModelLog.AgentCreated(logger, created.Id.Value, created.Name, created.ProviderKind, created.ModelName); + await StartSessionAndOpenChatAsync(created.Id, created.Name, successMessage: null, cancellationToken); + await OperationMessage.SetAsync(savedMessage, cancellationToken); } catch (Exception exception) { @@ -290,24 +331,17 @@ public async ValueTask StartChatForAgent(AgentCatalogItem? agent, CancellationTo try { + var startedChatMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + StartedChatCompositeFormat, + agent.Name); AgentBuilderModelLog.ChatSessionRequested(logger, agent.Id.Value, agent.Name); - var sessionResult = await workspaceState.CreateSessionAsync( - new CreateSessionCommand(SessionTitlePrefix + agent.Name, agent.Id), - cancellationToken); - if (sessionResult.IsFailed) - { - await OperationMessage.SetAsync(sessionResult.ToOperatorMessage("Could not start a session."), cancellationToken); - return; - } - - _workspaceRefresh.Raise(); - await OperationMessage.SetAsync( - string.Format( - System.Globalization.CultureInfo.InvariantCulture, - StartedChatCompositeFormat, - agent.Name), + await StartSessionAndOpenChatAsync( + agent.Id, + agent.Name, + startedChatMessage, cancellationToken); - await Surface.UpdateAsync(_ => CatalogSurface, cancellationToken); + await OperationMessage.SetAsync(startedChatMessage, cancellationToken); } catch (Exception exception) { @@ -316,6 +350,33 @@ await OperationMessage.SetAsync( } } + private ValueTask StartChatForParameter(object? parameter, CancellationToken cancellationToken) + { + return parameter switch + { + AgentCatalogItem item => StartChatForAgent(item, cancellationToken), + AgentCatalogStartChatRequest request => StartChatForRequest(request, cancellationToken), + _ => ValueTask.CompletedTask, + }; + } + + private async ValueTask StartChatForRequest( + AgentCatalogStartChatRequest request, + CancellationToken cancellationToken) + { + var startedChatMessage = string.Format( + System.Globalization.CultureInfo.InvariantCulture, + StartedChatCompositeFormat, + request.AgentName); + AgentBuilderModelLog.ChatSessionRequested(logger, request.AgentId.Value, request.AgentName); + await StartSessionAndOpenChatAsync( + request.AgentId, + request.AgentName, + startedChatMessage, + cancellationToken); + await OperationMessage.SetAsync(startedChatMessage, cancellationToken); + } + private async ValueTask> LoadAgentsAsync(CancellationToken cancellationToken) { try @@ -331,6 +392,10 @@ private async ValueTask> LoadAgentsAsync(Cancel .Select(MapAgent) .ToImmutableArray(); } + catch (TaskCanceledException) + { + return ImmutableArray.Empty; + } catch (Exception exception) { AgentBuilderModelLog.Failure(logger, exception); @@ -361,6 +426,10 @@ private async ValueTask> LoadProvidersAsync( await EnsureSelectedProviderAsync(providers, cancellationToken); return providers; } + catch (TaskCanceledException) + { + return ImmutableArray.Empty; + } catch (Exception exception) { AgentBuilderModelLog.Failure(logger, exception); @@ -443,6 +512,7 @@ private async ValueTask EnsureSelectedProviderAsync( var resolvedProvider = IsEmptySelectedProvider(selectedProvider) ? FindProviderByKind(providers, selectedProviderKind) : FindProviderByKind(providers, selectedProvider.Kind); + if (IsEmptySelectedProvider(resolvedProvider)) { resolvedProvider = FindFirstCreatableProvider(providers); @@ -467,22 +537,22 @@ private async ValueTask ResolveSelectedProviderAsync(Cancel return selectedProvider; } - var providers = await Providers; var selectedProviderKind = await SelectedProviderKind; - var resolvedByKind = FindProviderByKind(providers, selectedProviderKind); - if (!IsEmptySelectedProvider(resolvedByKind)) - { - await HandleSelectedProviderChanged(resolvedByKind, cancellationToken); - return resolvedByKind; - } - - var resolvedProvider = FindFirstCreatableProvider(providers); + var providers = await Providers; + var resolvedProvider = FindProviderByKind(providers, selectedProviderKind); if (!IsEmptySelectedProvider(resolvedProvider)) { await HandleSelectedProviderChanged(resolvedProvider, cancellationToken); return resolvedProvider; } + var creatableProvider = FindFirstCreatableProvider(providers); + if (!IsEmptySelectedProvider(creatableProvider)) + { + await HandleSelectedProviderChanged(creatableProvider, cancellationToken); + return creatableProvider; + } + if (providers.Count > 0) { await HandleSelectedProviderChanged(providers[0], cancellationToken); @@ -492,6 +562,45 @@ private async ValueTask ResolveSelectedProviderAsync(Cancel return EmptySelectedProvider; } + private async ValueTask StartSessionAndOpenChatAsync( + AgentProfileId agentId, + string agentName, + string? successMessage, + CancellationToken cancellationToken) + { + var sessionResult = await workspaceState.CreateSessionAsync( + new CreateSessionCommand(SessionTitlePrefix + agentName, agentId), + cancellationToken); + if (sessionResult.IsFailed) + { + await OperationMessage.SetAsync(sessionResult.ToOperatorMessage("Could not start a session."), cancellationToken); + return; + } + + _workspaceRefresh.Raise(); + workspaceProjectionNotifier.Publish(); + if (!string.IsNullOrWhiteSpace(successMessage)) + { + await OperationMessage.SetAsync(successMessage, cancellationToken); + } + + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Session created. AgentId={agentId.Value} AgentName={agentName}. Requesting chat navigation."); + await TryReturnToCatalogSurfaceAsync(cancellationToken); + shellNavigationNotifier.Request(ShellRoute.Chat); + } + + private async ValueTask TryReturnToCatalogSurfaceAsync(CancellationToken cancellationToken) + { + try + { + await Surface.UpdateAsync(_ => CatalogSurface, cancellationToken); + } + catch (TaskCanceledException) + { + } + } + private async ValueTask ResolveEffectiveModelNameAsync(CancellationToken cancellationToken) { var modelName = ((await ModelName) ?? string.Empty).Trim(); @@ -504,8 +613,9 @@ private async ValueTask ResolveEffectiveModelNameAsync(CancellationToken return ResolveSuggestedModelName(selectedProvider); } - private static AgentCatalogItem MapAgent(AgentProfileSummary agent) + private AgentCatalogItem MapAgent(AgentProfileSummary agent) { + var automationIdSuffix = CreateAutomationIdSuffix(agent.Name); return new AgentCatalogItem( agent.Id, agent.Name[..1], @@ -513,7 +623,10 @@ private static AgentCatalogItem MapAgent(AgentProfileSummary agent) AgentSessionDefaults.CreateAgentDescription(agent.SystemPrompt), agent.ProviderDisplayName, agent.ModelName, - AgentSessionDefaults.IsSystemAgent(agent.Name)); + AgentSessionDefaults.IsSystemAgent(agent.Name), + "AgentCatalogStartChatButton_" + automationIdSuffix, + new AgentCatalogStartChatRequest(agent.Id, agent.Name), + StartChatForAgentCommand); } private static AgentProviderOption MapProviderOption(ProviderStatusDescriptor provider) @@ -620,4 +733,10 @@ private static bool IsEmptySelectedProvider(AgentProviderOption? provider) return provider is null || string.IsNullOrWhiteSpace(provider.DisplayName); } + private static string CreateAutomationIdSuffix(string value) + { + var characters = value.Where(char.IsLetterOrDigit).ToArray(); + return characters.Length == 0 ? "Unknown" : new string(characters); + } + } diff --git a/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml b/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml index e42cd4f..1ad4eae 100644 --- a/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml +++ b/DotPilot/Presentation/AgentBuilder/Views/AgentBuilderPage.xaml @@ -7,7 +7,8 @@ FontFamily="{StaticResource AppBodyFontFamily}"> - + diff --git a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs index 74e8a68..ed7776f 100644 --- a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs +++ b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs @@ -6,7 +6,7 @@ namespace DotPilot.Presentation.Controls; public sealed partial class ChatComposer : UserControl { private const string NewLineValue = "\n"; - private readonly ChatComposerModifierState modifierState = new(); + private readonly ChatComposerModifierState _modifierState = new(); public static readonly DependencyProperty SendBehaviorProperty = DependencyProperty.Register( nameof(SendBehavior), @@ -32,11 +32,11 @@ private void OnComposerInputKeyDown(object sender, KeyRoutedEventArgs e) return; } - modifierState.RegisterKeyDown(e.Key); + _modifierState.RegisterKeyDown(e.Key); var action = ChatComposerKeyboardPolicy.Resolve( behavior: SendBehavior, isEnterKey: e.Key is VirtualKey.Enter, - hasModifier: modifierState.HasPressedModifier); + hasModifier: _modifierState.HasPressedModifier); if (action is ChatComposerKeyboardAction.SendMessage) { ExecuteSubmitAction(textBox); @@ -55,12 +55,12 @@ private void OnComposerInputKeyDown(object sender, KeyRoutedEventArgs e) private void OnComposerInputKeyUp(object sender, KeyRoutedEventArgs e) { - modifierState.RegisterKeyUp(e.Key); + _modifierState.RegisterKeyUp(e.Key); } private void OnComposerInputLostFocus(object sender, RoutedEventArgs e) { - modifierState.Reset(); + _modifierState.Reset(); } private void OnSendButtonClick(object sender, RoutedEventArgs e) diff --git a/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs b/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs index f99ef33..7e88112 100644 --- a/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs +++ b/DotPilot/Presentation/Configuration/PresentationServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static IServiceCollection AddPresentationModels(this IServiceCollection s ArgumentNullException.ThrowIfNull(services); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs b/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs index bc355b8..fb5230a 100644 --- a/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs +++ b/DotPilot/Presentation/Shared/Models/PresentationProjectionModels.cs @@ -23,4 +23,9 @@ public sealed partial record AgentBuilderView( bool HasSupportedModels, string ModelHelperText, string StatusMessage, - bool CanCreateAgent); + bool CanCreateAgent) +{ + public IReadOnlyList SupportedModels => + [.. SupportedModelNames.Select(modelName => + new AgentModelOption(modelName, AgentBuilderAutomationIds.ForModel(modelName)))]; +} diff --git a/DotPilot/Presentation/Shared/Notifications/ShellNavigationNotifier.cs b/DotPilot/Presentation/Shared/Notifications/ShellNavigationNotifier.cs new file mode 100644 index 0000000..0d5c9f1 --- /dev/null +++ b/DotPilot/Presentation/Shared/Notifications/ShellNavigationNotifier.cs @@ -0,0 +1,23 @@ +namespace DotPilot.Presentation; + +public enum ShellRoute +{ + Chat, + Agents, + Settings, +} + +public sealed class ShellNavigationNotifier +{ + public event EventHandler? Requested; + + public void Request(ShellRoute route) + { + Requested?.Invoke(this, new ShellNavigationRequestedEventArgs(route)); + } +} + +public sealed class ShellNavigationRequestedEventArgs(ShellRoute route) : EventArgs +{ + public ShellRoute Route { get; } = route; +} diff --git a/DotPilot/Presentation/Shell/Views/Shell.xaml.cs b/DotPilot/Presentation/Shell/Views/Shell.xaml.cs index eda38e1..b0ad34b 100644 --- a/DotPilot/Presentation/Shell/Views/Shell.xaml.cs +++ b/DotPilot/Presentation/Shell/Views/Shell.xaml.cs @@ -1,14 +1,14 @@ +using Microsoft.Extensions.DependencyInjection; + namespace DotPilot.Presentation; public sealed partial class Shell : Page, IContentControlProvider { private const string SidebarButtonStyleKey = "SidebarButtonStyle"; private const string SidebarButtonSelectedStyleKey = "SidebarButtonSelectedStyle"; - private const string ChatRoute = "Chat"; - private const string AgentsRoute = "Agents"; - private const string SettingsRoute = "Settings"; private const string UnknownContentTypeName = ""; - private string _currentRoute = ChatRoute; + private ShellNavigationNotifier? _shellNavigationNotifier; + private string _currentRoute = ResolveRouteName(ShellRoute.Chat); public Shell() { @@ -16,8 +16,10 @@ public Shell() { BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor started."); InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; RegisterContentHostObserver(); - UpdateNavigationSelection(ChatRoute); + UpdateNavigationSelection(ResolveRouteName(ShellRoute.Chat)); UpdateNavigationSelectionFromContent(); BrowserConsoleDiagnostics.Info("[DotPilot.Startup] Shell constructor completed."); } @@ -32,34 +34,96 @@ public Shell() private void OnChatNavButtonClick(object sender, RoutedEventArgs e) { - _ = NavigateToRouteAsync(ChatRoute); + _ = NavigateToRouteAsync(ShellRoute.Chat); } private void OnAgentsNavButtonClick(object sender, RoutedEventArgs e) { - _ = NavigateToRouteAsync(AgentsRoute); + _ = NavigateToRouteAsync(ShellRoute.Agents); } private void OnProvidersNavButtonClick(object sender, RoutedEventArgs e) { - _ = NavigateToRouteAsync(SettingsRoute); + _ = NavigateToRouteAsync(ShellRoute.Settings); } - private async Task NavigateToRouteAsync(string route) + private void OnLoaded(object sender, RoutedEventArgs e) { - ArgumentException.ThrowIfNullOrWhiteSpace(route); + if (Application.Current is App app) + { + app.ServicesReady -= OnAppServicesReady; + app.ServicesReady += OnAppServicesReady; + } - UpdateNavigationSelection(route); + TryRegisterNavigationNotifier(); + } + + private void TryRegisterNavigationNotifier() + { + if (_shellNavigationNotifier is not null) + { + return; + } + + if (Application.Current is not App { Services: { } services }) + { + BrowserConsoleDiagnostics.Info("[DotPilot.Navigation] Shell navigation notifier is waiting for app services."); + return; + } + + _shellNavigationNotifier = services.GetRequiredService(); + _shellNavigationNotifier.Requested += OnShellNavigationRequested; + BrowserConsoleDiagnostics.Info("[DotPilot.Navigation] Shell navigation notifier registered."); + } + + private void OnAppServicesReady(object? sender, EventArgs e) + { + TryRegisterNavigationNotifier(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + if (Application.Current is App app) + { + app.ServicesReady -= OnAppServicesReady; + } + + if (_shellNavigationNotifier is null) + { + return; + } + + _shellNavigationNotifier.Requested -= OnShellNavigationRequested; + _shellNavigationNotifier = null; + } + + private void OnShellNavigationRequested(object? sender, ShellNavigationRequestedEventArgs e) + { + BrowserConsoleDiagnostics.Info($"[DotPilot.Navigation] Shell navigation requested for route '{ResolveRouteName(e.Route)}'."); + if (DispatcherQueue.HasThreadAccess) + { + _ = NavigateToRouteAsync(e.Route); + return; + } + + _ = DispatcherQueue.TryEnqueue(() => _ = NavigateToRouteAsync(e.Route)); + } + + private async Task NavigateToRouteAsync(ShellRoute route) + { + var routeName = ResolveRouteName(route); + + UpdateNavigationSelection(routeName); var navigator = ContentHost.Navigator() ?? this.Navigator(); if (navigator is null) { - BrowserConsoleDiagnostics.Error($"[DotPilot.Navigation] Missing navigator for route '{route}'."); + BrowserConsoleDiagnostics.Error($"[DotPilot.Navigation] Missing navigator for route '{routeName}'."); return; } - var response = await navigator.NavigateRouteAsync(ContentHost, route); + var response = await navigator.NavigateRouteAsync(ContentHost, routeName); var success = response?.Success ?? false; - BrowserConsoleDiagnostics.Info($"[DotPilot.Navigation] Route '{route}' success={success}."); + BrowserConsoleDiagnostics.Info($"[DotPilot.Navigation] Route '{routeName}' success={success}."); } private void RegisterContentHostObserver() @@ -73,9 +137,9 @@ private void UpdateNavigationSelectionFromContent() { var route = ContentHost.Content switch { - ChatPage => ChatRoute, - AgentBuilderPage => AgentsRoute, - SettingsPage => SettingsRoute, + ChatPage => ResolveRouteName(ShellRoute.Chat), + AgentBuilderPage => ResolveRouteName(ShellRoute.Agents), + SettingsPage => ResolveRouteName(ShellRoute.Settings), _ => string.Empty, }; @@ -95,17 +159,28 @@ private void UpdateNavigationSelection(string route) var selectedStyle = ResolveStyle(SidebarButtonSelectedStyleKey); var normalStyle = ResolveStyle(SidebarButtonStyleKey); - ChatNavButton.Style = string.Equals(_currentRoute, ChatRoute, StringComparison.Ordinal) + ChatNavButton.Style = string.Equals(_currentRoute, ResolveRouteName(ShellRoute.Chat), StringComparison.Ordinal) ? selectedStyle : normalStyle; - AgentsNavButton.Style = string.Equals(_currentRoute, AgentsRoute, StringComparison.Ordinal) + AgentsNavButton.Style = string.Equals(_currentRoute, ResolveRouteName(ShellRoute.Agents), StringComparison.Ordinal) ? selectedStyle : normalStyle; - ProvidersNavButton.Style = string.Equals(_currentRoute, SettingsRoute, StringComparison.Ordinal) + ProvidersNavButton.Style = string.Equals(_currentRoute, ResolveRouteName(ShellRoute.Settings), StringComparison.Ordinal) ? selectedStyle : normalStyle; } + private static string ResolveRouteName(ShellRoute route) + { + return route switch + { + ShellRoute.Chat => "Chat", + ShellRoute.Agents => "Agents", + ShellRoute.Settings => "Settings", + _ => throw new ArgumentOutOfRangeException(nameof(route), route, "Unknown shell route."), + }; + } + private static Style ResolveStyle(string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); From 4aa34424c02389759289d4c8280b846ca4c76a9e Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Mon, 16 Mar 2026 02:30:26 +0100 Subject: [PATCH 18/30] Fix agent shell startup and chat flows --- AGENTS.md | 2 + DotPilot.Core/AGENTS.md | 1 + ...AgentSessionServiceCollectionExtensions.cs | 1 + .../Diagnostics/AgentSessionRuntimeLog.cs | 21 ++ .../Execution/AgentSessionService.cs | 16 +- .../Interfaces/IAgentProviderStatusReader.cs | 4 + .../Services/AgentProviderStatusReader.cs | 92 ++++++- .../Diagnostics/WorkspaceRuntimeLog.cs | 24 ++ .../Interfaces/IStartupWorkspaceHydration.cs | 12 + .../Services/StartupWorkspaceHydration.cs | 115 ++++++++ .../ViewModels/AgentBuilderModelTests.cs | 86 +++--- .../ChatComposerModifierStateTests.cs | 31 +++ .../Chat/ViewModels/ChatModelTests.cs | 32 +++ .../Execution/AgentSessionServiceTests.cs | 42 +++ .../AgentProviderStatusReaderTests.cs | 67 +++++ .../Settings/ViewModels/SettingsModelTests.cs | 19 ++ .../Shell/ViewModels/ShellViewModelTests.cs | 58 ++++ .../Services/AgentWorkspaceStateTests.cs | 12 +- .../StartupWorkspaceHydrationTests.cs | 65 +++++ .../Flows/GivenChatSessionsShell.cs | 78 +++++- DotPilot.UITests/Harness/TestBase.cs | 195 ++++++++++++-- DotPilot/AGENTS.md | 3 + DotPilot/App.xaml.cs | 8 +- .../Controls/AgentBasicInfoSection.xaml | 37 ++- .../Controls/AgentBasicInfoSection.xaml.cs | 71 ++--- .../Controls/AgentPromptSection.xaml | 2 +- .../ViewModels/AgentBuilderModel.cs | 248 ++++++++++++++---- .../ChatComposerModifierState.cs | 32 +++ .../Chat/Controls/ChatComposer.xaml.cs | 22 +- .../Chat/Controls/ChatConversationView.xaml | 47 +++- .../Controls/ChatConversationView.xaml.cs | 96 +++++++ .../Presentation/Chat/ViewModels/ChatModel.cs | 13 + .../Settings/Controls/SettingsShell.xaml | 13 +- .../Settings/Controls/SettingsShell.xaml.cs | 59 +++++ .../Settings/ViewModels/SettingsModel.cs | 6 + .../Shell/ViewModels/ShellViewModel.cs | 37 ++- DotPilot/Presentation/Shell/Views/Shell.xaml | 35 +++ DotPilot/Styles/ChatWorkspace.xaml | 2 +- docs/Architecture.md | 10 + 39 files changed, 1490 insertions(+), 224 deletions(-) create mode 100644 DotPilot.Core/Workspace/Diagnostics/WorkspaceRuntimeLog.cs create mode 100644 DotPilot.Core/Workspace/Interfaces/IStartupWorkspaceHydration.cs create mode 100644 DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs create mode 100644 DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs create mode 100644 DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs diff --git a/AGENTS.md b/AGENTS.md index 7bb489e..34a8714 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -186,6 +186,7 @@ For this app: - When agent conversations must survive restarts, persist the full `AgentSession` plus chat history through an Agent Framework history/storage provider backed by a local desktop folder; do not reduce durable conversation state to transcript text rows only - Do not add cache layers for provider CLIs, model catalogs, workspace projections, or similar environment state unless the user explicitly asks for caching; prefer direct reads from the current source of truth - Current repository policy is stricter than the default: do not keep provider-status caches, workspace/session in-memory mirrors, or app-preference caches at all unless the user explicitly asks to bring a specific cache back +- The current explicit exception is startup readiness hydration: the app may show a splash/loading state at launch, probe installed provider CLIs and related metadata once during that startup window, and then reuse that startup snapshot until an explicit refresh or provider-setting change invalidates it - Provider CLI probing must not rerun as a side effect of ordinary screen binding or MVUX state re-evaluation; normal shell loads should share one in-flight probe and only reprobe on explicit refresh or provider-setting changes - Expected cancellation from state re-evaluation or navigation must not be logged as a product failure; reserve failure logs for real errors, not superseded async loads - Runtime and orchestration flows must emit structured `ILogger` logs for provider readiness, agent creation, session creation, send execution, and failure paths; ad hoc console-only startup traces are not enough to debug the product @@ -391,6 +392,7 @@ Ask first: - Follow the canonical MCAF tutorial when bootstrapping or upgrading the agent workflow. - Commit cohesive code-change batches promptly while debugging, especially before switching focus or starting long verification runs, so the branch state stays inspectable and pushable. - After opening or updating a PR, create a fresh working branch before continuing with the next slice of work so follow-up changes do not pile onto the already-reviewed branch. +- When one requested slice is complete and verified, commit it before switching to the next GitHub issue so each backlog step stays isolated and reviewable. - Keep `DotPilot` feeling like a fast desktop control plane: startup, navigation, and visible UI reactions should be prompt, and agents should remove unnecessary waits instead of normalizing slow web-style loading behavior. - Keep the root `AGENTS.md` at the repository root. - Keep the repo-local agent skill directory limited to current `mcaf-*` skills. diff --git a/DotPilot.Core/AGENTS.md b/DotPilot.Core/AGENTS.md index 687e7f3..987b72f 100644 --- a/DotPilot.Core/AGENTS.md +++ b/DotPilot.Core/AGENTS.md @@ -42,6 +42,7 @@ Stack: `.NET 10`, class library, non-UI contracts, orchestration, persistence, a - keep this structure SOLID at the folder and project level too: cohesive feature slices stay together, but once a slice becomes too large or too independent, it should graduate into its own project instead of turning `DotPilot.Core` into mud - Keep provider-independent testing seams real and deterministic so CI can validate core flows without external CLIs. - Keep provider readiness probing explicit and coalesced: ordinary workspace reads may share one in-flight CLI probe, but normal navigation must not fan out into repeated PATH/version probing loops. +- The approved caching exception in this project is startup readiness hydration: Core may keep one startup-owned provider/CLI snapshot after the initial splash-time probe, but it must invalidate that snapshot on explicit refresh or provider preference changes instead of drifting into a long-lived opaque cache layer. - Treat superseded async loads as cancellation, not failure; Core services should not emit error-level noise for expected state invalidation or navigation churn. ## Local Commands diff --git a/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs index 1b87f4d..f6e7389 100644 --- a/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs +++ b/DotPilot.Core/ChatSessions/Configuration/AgentSessionServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ public static IServiceCollection AddAgentSessions( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } diff --git a/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs index 3bcd929..2e616c1 100644 --- a/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs +++ b/DotPilot.Core/ChatSessions/Diagnostics/AgentSessionRuntimeLog.cs @@ -268,3 +268,24 @@ public static partial void SendCompleted( Message = "Session send failed. SessionId={SessionId} AgentId={AgentId}.")] public static partial void SendFailed(ILogger logger, Exception exception, SessionId sessionId, Guid agentId); } + +internal static partial class StartupWorkspaceHydrationLog +{ + [LoggerMessage( + EventId = 1300, + Level = LogLevel.Information, + Message = "Starting startup workspace hydration.")] + public static partial void HydrationStarted(ILogger logger); + + [LoggerMessage( + EventId = 1301, + Level = LogLevel.Information, + Message = "Startup workspace hydration completed.")] + public static partial void HydrationCompleted(ILogger logger); + + [LoggerMessage( + EventId = 1302, + Level = LogLevel.Error, + Message = "Startup workspace hydration failed.")] + public static partial void HydrationFailed(ILogger logger, Exception exception); +} diff --git a/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs index 394cfcb..d110467 100644 --- a/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs +++ b/DotPilot.Core/ChatSessions/Execution/AgentSessionService.cs @@ -79,6 +79,10 @@ private async ValueTask> LoadWorkspaceAsync( providers, sessionItems.Length > 0 ? sessionItems[0].Id : null)); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception exception) { AgentSessionServiceLog.WorkspaceLoadFailed(logger, exception); @@ -124,6 +128,10 @@ public async ValueTask> GetSessionAsync(Sessio return Result.Succeed(snapshot); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception exception) { AgentSessionServiceLog.SessionLoadFailed(logger, exception, sessionId); @@ -791,12 +799,14 @@ private async ValueTask> GetProviderStat bool forceRefresh, CancellationToken cancellationToken) { - _ = forceRefresh; - return await providerStatusReader.ReadAsync(cancellationToken); + return forceRefresh + ? await providerStatusReader.RefreshAsync(cancellationToken) + : await providerStatusReader.ReadAsync(cancellationToken); } - private static void InvalidateProviderStatusSnapshot() + private void InvalidateProviderStatusSnapshot() { + providerStatusReader.Invalidate(); } private async Task NormalizeLegacyAgentProfilesAsync( diff --git a/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs index 4bc8520..25ae3e4 100644 --- a/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs +++ b/DotPilot.Core/Providers/Interfaces/IAgentProviderStatusReader.cs @@ -3,4 +3,8 @@ namespace DotPilot.Core.Providers.Interfaces; public interface IAgentProviderStatusReader { ValueTask> ReadAsync(CancellationToken cancellationToken); + + ValueTask> RefreshAsync(CancellationToken cancellationToken); + + void Invalidate(); } diff --git a/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs index 014136a..c3e5c94 100644 --- a/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs +++ b/DotPilot.Core/Providers/Services/AgentProviderStatusReader.cs @@ -12,13 +12,16 @@ internal sealed class AgentProviderStatusReader( { private const string MissingValue = ""; private readonly object activeReadSync = new(); + private IReadOnlyList? cachedSnapshot; private Task>? activeReadTask; + private long activeReadGeneration = -1; + private long snapshotGeneration; public async ValueTask> ReadAsync(CancellationToken cancellationToken) { try { - var readTask = GetOrStartActiveRead(); + var readTask = GetOrStartActiveRead(forceRefresh: false); return await readTask.WaitAsync(cancellationToken); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -30,33 +33,100 @@ public async ValueTask> ReadAsync(Cancel AgentProviderStatusReaderLog.ReadFailed(logger, exception); throw; } - finally + } + + public async ValueTask> RefreshAsync(CancellationToken cancellationToken) + { + try { - ClearCompletedActiveRead(); + var readTask = GetOrStartActiveRead(forceRefresh: true); + return await readTask.WaitAsync(cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + AgentProviderStatusReaderLog.ReadFailed(logger, exception); + throw; } } - private Task> GetOrStartActiveRead() + public void Invalidate() { lock (activeReadSync) { - if (activeReadTask is { IsCompleted: false }) + snapshotGeneration++; + cachedSnapshot = null; + } + } + + private Task> GetOrStartActiveRead(bool forceRefresh) + { + Task>? activeTask; + long generation; + + lock (activeReadSync) + { + if (!forceRefresh && cachedSnapshot is { } snapshot) + { + return Task.FromResult(snapshot); + } + + if (!forceRefresh && + activeReadTask is { IsCompleted: false } && + activeReadGeneration == snapshotGeneration) { return activeReadTask; } - activeReadTask = ReadFromCurrentSourcesAsync(); - return activeReadTask; + if (forceRefresh) + { + snapshotGeneration++; + cachedSnapshot = null; + } + + generation = snapshotGeneration; + activeReadGeneration = generation; + activeReadTask = CreateReadTask(generation); + activeTask = activeReadTask; } + + return activeTask; } - private void ClearCompletedActiveRead() + private Task> CreateReadTask(long generation) { - lock (activeReadSync) + Task>? readTask = null; + readTask = ReadAndCacheAsync(); + return readTask; + + async Task> ReadAndCacheAsync() { - if (activeReadTask is { IsCompleted: true }) + try + { + var snapshot = await ReadFromCurrentSourcesAsync(); + lock (activeReadSync) + { + if (generation == snapshotGeneration) + { + cachedSnapshot = snapshot; + } + } + + return snapshot; + } + finally { - activeReadTask = null; + lock (activeReadSync) + { + if (ReferenceEquals(activeReadTask, readTask)) + { + activeReadTask = null; + activeReadGeneration = -1; + } + } } } } diff --git a/DotPilot.Core/Workspace/Diagnostics/WorkspaceRuntimeLog.cs b/DotPilot.Core/Workspace/Diagnostics/WorkspaceRuntimeLog.cs new file mode 100644 index 0000000..a54aca2 --- /dev/null +++ b/DotPilot.Core/Workspace/Diagnostics/WorkspaceRuntimeLog.cs @@ -0,0 +1,24 @@ +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.Workspace; + +internal static partial class StartupWorkspaceHydrationLog +{ + [LoggerMessage( + EventId = 1500, + Level = LogLevel.Information, + Message = "Starting startup workspace hydration.")] + public static partial void HydrationStarted(ILogger logger); + + [LoggerMessage( + EventId = 1501, + Level = LogLevel.Information, + Message = "Completed startup workspace hydration.")] + public static partial void HydrationCompleted(ILogger logger); + + [LoggerMessage( + EventId = 1502, + Level = LogLevel.Error, + Message = "Startup workspace hydration failed.")] + public static partial void HydrationFailed(ILogger logger, Exception exception); +} diff --git a/DotPilot.Core/Workspace/Interfaces/IStartupWorkspaceHydration.cs b/DotPilot.Core/Workspace/Interfaces/IStartupWorkspaceHydration.cs new file mode 100644 index 0000000..73e0612 --- /dev/null +++ b/DotPilot.Core/Workspace/Interfaces/IStartupWorkspaceHydration.cs @@ -0,0 +1,12 @@ +namespace DotPilot.Core.Workspace.Interfaces; + +public interface IStartupWorkspaceHydration +{ + bool IsHydrating { get; } + + bool IsReady { get; } + + event EventHandler? StateChanged; + + ValueTask EnsureHydratedAsync(CancellationToken cancellationToken); +} diff --git a/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs b/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs new file mode 100644 index 0000000..6492f1d --- /dev/null +++ b/DotPilot.Core/Workspace/Services/StartupWorkspaceHydration.cs @@ -0,0 +1,115 @@ +using Microsoft.Extensions.Logging; + +namespace DotPilot.Core.Workspace; + +internal sealed class StartupWorkspaceHydration( + IAgentWorkspaceState workspaceState, + ILogger logger) + : IStartupWorkspaceHydration, IDisposable +{ + private readonly SemaphoreSlim hydrationGate = new(1, 1); + private readonly object stateSync = new(); + private bool isHydrating; + private bool isReady; + + public bool IsHydrating + { + get + { + lock (stateSync) + { + return isHydrating; + } + } + } + + public bool IsReady + { + get + { + lock (stateSync) + { + return isReady; + } + } + } + + public event EventHandler? StateChanged; + + public void Dispose() + { + hydrationGate.Dispose(); + } + + public async ValueTask EnsureHydratedAsync(CancellationToken cancellationToken) + { + if (IsReady) + { + return; + } + + await hydrationGate.WaitAsync(cancellationToken); + try + { + if (IsReady) + { + return; + } + + UpdateState(isHydrating: true, isReady: false); + StartupWorkspaceHydrationLog.HydrationStarted(logger); + + try + { + var workspace = await workspaceState.GetWorkspaceAsync(cancellationToken); + if (workspace.IsFailed) + { + StartupWorkspaceHydrationLog.HydrationFailed( + logger, + new InvalidOperationException("Startup workspace hydration failed.")); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + StartupWorkspaceHydrationLog.HydrationFailed(logger, exception); + } + finally + { + UpdateState(isHydrating: false, isReady: true); + StartupWorkspaceHydrationLog.HydrationCompleted(logger); + } + } + finally + { + hydrationGate.Release(); + } + } + + private void UpdateState(bool isHydrating, bool isReady) + { + var changed = false; + lock (stateSync) + { + if (this.isHydrating != isHydrating) + { + this.isHydrating = isHydrating; + changed = true; + } + + if (this.isReady != isReady) + { + this.isReady = isReady; + changed = true; + } + } + + if (changed) + { + StateChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs b/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs index 5115291..80bce9c 100644 --- a/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs +++ b/DotPilot.Tests/AgentBuilder/ViewModels/AgentBuilderModelTests.cs @@ -1,5 +1,5 @@ -using DotPilot.Core.ChatSessions; using DotPilot.Core.AgentBuilder; +using DotPilot.Core.ChatSessions; using DotPilot.Tests.Providers; using Microsoft.Extensions.DependencyInjection; @@ -25,10 +25,9 @@ public async Task GenerateDraftAndSaveAgentUsesEnabledProviderModelWhenModelOver await model.AgentRequest.SetAsync("Create a repository reviewer", CancellationToken.None); await model.GenerateAgentDraft(CancellationToken.None); - var builder = (await model.Builder)!; - builder.ProviderDisplayName.Should().Be("Codex"); - builder.SuggestedModelName.Should().Be("gpt-5.4"); - builder.CanCreateAgent.Should().BeTrue(); + (await model.BuilderProviderDisplayName).Should().Be("Codex"); + (await model.BuilderSuggestedModelName).Should().Be("gpt-5.4"); + (await model.BuilderCanCreateAgent).Should().BeTrue(); (await model.ModelName).Should().Be("gpt-5.4"); (await model.AgentName).Should().Be("Repository Reviewer Agent"); @@ -42,7 +41,7 @@ public async Task GenerateDraftAndSaveAgentUsesEnabledProviderModelWhenModelOver workspace.Sessions.Should().Contain(session => session.Title == "Session with Repository Reviewer Agent"); workspace.SelectedSessionId.Should().NotBeNull(); fixture.RequestedRoutes.Should().Contain(ShellRoute.Chat); - (await model.Builder)!.StatusMessage.Should().Contain("ready for local desktop execution"); + (await model.BuilderStatusMessage).Should().Contain("ready for local desktop execution"); } [Test] @@ -63,17 +62,15 @@ await model.HandleSelectedProviderChanged( false), CancellationToken.None); - var builder = (await model.Builder)!; - - builder.ProviderDisplayName.Should().Be("GitHub Copilot"); - builder.ProviderCommandName.Should().Be("copilot"); - builder.ProviderVersionLabel.Should().Be("Version 0.0.421"); - builder.HasProviderVersion.Should().BeTrue(); - builder.SuggestedModelName.Should().Be("gpt-5"); - builder.SupportedModelNames.Should().ContainInOrder("gpt-5", "claude-opus-4.6"); - builder.HasSupportedModels.Should().BeTrue(); - builder.StatusMessage.Should().Be("GitHub Copilot CLI is available on PATH."); - builder.CanCreateAgent.Should().BeFalse(); + (await model.BuilderProviderDisplayName).Should().Be("GitHub Copilot"); + (await model.BuilderProviderCommandName).Should().Be("copilot"); + (await model.BuilderProviderVersionLabel).Should().Be("Version 0.0.421"); + (await model.BuilderHasProviderVersion).Should().BeTrue(); + (await model.BuilderSuggestedModelName).Should().Be("gpt-5"); + (await model.BuilderSupportedModelNames).Should().ContainInOrder("gpt-5", "claude-opus-4.6"); + (await model.BuilderHasSupportedModels).Should().BeTrue(); + (await model.BuilderStatusMessage).Should().Be("GitHub Copilot CLI is available on PATH."); + (await model.BuilderCanCreateAgent).Should().BeFalse(); } [Test] @@ -95,8 +92,8 @@ public async Task BuildManuallyUsesEnabledProviderDefaults() var surface = await model.Surface; surface!.ShowEditor.Should().BeTrue(); (await model.AgentName).Should().Be("New agent"); - (await model.Builder)!.ProviderDisplayName.Should().Be("Codex"); - (await model.Builder)!.SuggestedModelName.Should().Be("gpt-5.4"); + (await model.BuilderProviderDisplayName).Should().Be("Codex"); + (await model.BuilderSuggestedModelName).Should().Be("gpt-5.4"); (await model.OperationMessage).Should().Be("Manual draft ready. Adjust the profile before saving."); } @@ -110,11 +107,10 @@ public async Task BuildManuallyWithoutEnabledRealProviderFallsBackToTheFirstProv await model.OpenCreateAgent(CancellationToken.None); await model.BuildManually(CancellationToken.None); - var builder = (await model.Builder)!; - builder.ProviderDisplayName.Should().Be("Codex"); - builder.SuggestedModelName.Should().Be("gpt-5"); - builder.ModelHelperText.Should().Be("Choose one of the supported models for this provider. Suggested: gpt-5."); - builder.CanCreateAgent.Should().BeFalse(); + (await model.BuilderProviderDisplayName).Should().Be("Codex"); + (await model.BuilderSuggestedModelName).Should().Be("gpt-5"); + (await model.BuilderModelHelperText).Should().Be("Choose one of the supported models for this provider. Suggested: gpt-5."); + (await model.BuilderCanCreateAgent).Should().BeFalse(); (await model.ModelName).Should().Be("gpt-5"); } @@ -193,7 +189,7 @@ await model.HandleSelectedProviderChanged( } [Test] - public async Task BuilderProjectionUsesSelectedProviderKindWhenThePreviousProviderStateIsStale() + public async Task HandleProviderSelectionChangedUsesProviderKindParameterWhenNoProviderOptionIsProvided() { using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); @@ -212,31 +208,16 @@ public async Task BuilderProjectionUsesSelectedProviderKindWhenThePreviousProvid var model = ActivatorUtilities.CreateInstance(fixture.Provider); await model.BuildManually(CancellationToken.None); - await model.SelectedProvider.UpdateAsync( - _ => new AgentProviderOption( - AgentProviderKind.Debug, - string.Empty, - string.Empty, - string.Empty, - string.Empty, - [], - null, - false), - CancellationToken.None); - await model.SelectedProviderKind.SetAsync(AgentProviderKind.ClaudeCode, CancellationToken.None); - - var builder = await model.Builder; - - builder.Should().NotBeNull(); + await model.HandleProviderSelectionChanged(AgentProviderKind.ClaudeCode, CancellationToken.None); - builder!.ProviderDisplayName.Should().Be("Claude Code"); - builder.SuggestedModelName.Should().Be("claude-opus-4-6"); + (await model.BuilderProviderDisplayName).Should().Be("Claude Code"); + (await model.BuilderSuggestedModelName).Should().Be("claude-opus-4-6"); (await model.SelectedProvider).Should().NotBeNull(); (await model.SelectedProvider)!.Kind.Should().Be(AgentProviderKind.ClaudeCode); } [Test] - public async Task BuilderProjectionPrefersTheSelectedProviderWhenTheProviderKindStateIsStale() + public async Task HandleProviderSelectionChangedUsesTheProvidedProviderWhenThePreviousProviderRemainsPopulated() { using var commandScope = CodexCliTestScope.Create(nameof(AgentBuilderModelTests)); commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); @@ -255,8 +236,10 @@ public async Task BuilderProjectionPrefersTheSelectedProviderWhenTheProviderKind var model = ActivatorUtilities.CreateInstance(fixture.Provider); await model.BuildManually(CancellationToken.None); - await model.SelectedProvider.UpdateAsync( - _ => new AgentProviderOption( + (await model.BuilderProviderDisplayName).Should().Be("Codex"); + + await model.HandleProviderSelectionChanged( + new AgentProviderOption( AgentProviderKind.ClaudeCode, "Claude Code", "claude", @@ -266,13 +249,14 @@ await model.SelectedProvider.UpdateAsync( "2.0.75", true), CancellationToken.None); - await model.SelectedProviderKind.SetAsync(AgentProviderKind.Codex, CancellationToken.None); - var builder = await model.Builder; + (await model.SelectedProvider).Should().NotBeNull(); + (await model.SelectedProvider)!.Kind.Should().Be(AgentProviderKind.ClaudeCode); + (await model.SelectedProviderKind).Should().Be(AgentProviderKind.ClaudeCode); + (await model.ModelName).Should().Be("claude-opus-4-6"); - builder.Should().NotBeNull(); - builder!.ProviderDisplayName.Should().Be("Claude Code"); - builder.SuggestedModelName.Should().Be("claude-opus-4-6"); + (await model.BuilderProviderDisplayName).Should().Be("Claude Code"); + (await model.BuilderSuggestedModelName).Should().Be("claude-opus-4-6"); } [Test] diff --git a/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs b/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs index 181fa38..6fd16d2 100644 --- a/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs +++ b/DotPilot.Tests/Chat/Configuration/ChatComposerModifierStateTests.cs @@ -68,4 +68,35 @@ public void ResetClearsTrackedModifiers() state.HasPressedModifier.Should().BeFalse(); } + + [Test] + public void HasPressedModifierOrCurrentStateUsesTrackedStateBeforeFallbackProbe() + { + var state = new ChatComposerModifierState(); + state.RegisterKeyDown(VirtualKey.LeftShift); + + var hasModifier = state.HasPressedModifierOrCurrentState(_ => false); + + hasModifier.Should().BeTrue(); + } + + [Test] + public void HasPressedModifierOrCurrentStateUsesCurrentKeyboardProbeWhenNothingIsTracked() + { + var state = new ChatComposerModifierState(); + + var hasModifier = state.HasPressedModifierOrCurrentState(key => key is VirtualKey.Control); + + hasModifier.Should().BeTrue(); + } + + [Test] + public void HasPressedModifierOrCurrentStateReturnsFalseWhenTrackedStateAndProbeAreEmpty() + { + var state = new ChatComposerModifierState(); + + var hasModifier = state.HasPressedModifierOrCurrentState(_ => false); + + hasModifier.Should().BeFalse(); + } } diff --git a/DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs b/DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs index 619e757..9e25516 100644 --- a/DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs +++ b/DotPilot.Tests/Chat/ViewModels/ChatModelTests.cs @@ -1,5 +1,6 @@ using DotPilot.Core.AgentBuilder; using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; using Microsoft.Extensions.DependencyInjection; namespace DotPilot.Tests.Chat; @@ -44,6 +45,18 @@ public async Task SendMessageStreamsDebugTranscriptForAnActiveSession() message.Content.Contains("Debug provider received: hello from model", StringComparison.Ordinal)); activeSession.Messages.Should().Contain(message => message.Content.Contains("Debug workflow finished", StringComparison.Ordinal)); + activeSession.Messages.Should().Contain(message => + message.Kind == SessionStreamEntryKind.ToolStarted && + string.Equals(message.AccentLabel, "tool", StringComparison.Ordinal) && + message.Content.Contains("Preparing local debug workflow", StringComparison.Ordinal)); + activeSession.Messages.Should().Contain(message => + message.Kind == SessionStreamEntryKind.ToolCompleted && + string.Equals(message.AccentLabel, "tool", StringComparison.Ordinal) && + message.Content.Contains("Debug workflow finished", StringComparison.Ordinal)); + activeSession.Messages.Should().Contain(message => + message.Kind == SessionStreamEntryKind.Status && + string.Equals(message.AccentLabel, "status", StringComparison.Ordinal) && + message.Content.Contains("Running Debug Agent with Debug Provider", StringComparison.Ordinal)); activeSession.StatusSummary.Should().Be("Debug Agent · Debug Provider"); } @@ -68,6 +81,25 @@ public async Task StartNewSessionUsesNewestCustomAgentWhenCustomNameSortsAfterSy activeSession.StatusSummary.Should().Be("Repository Reviewer Agent · Debug Provider"); } + [Test] + public async Task RefreshIgnoresCancellationDuringWorkspaceProbe() + { + using var commandScope = CodexCliTestScope.Create(nameof(ChatModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + await using var fixture = await CreateFixtureAsync(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + _ = await model.RecentChats; + + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300); + using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await model.Refresh(cancellationSource.Token); + + (await model.FeedbackMessage).Should().BeEmpty(); + } + private static async Task CreateFixtureAsync() { var services = new ServiceCollection(); diff --git a/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs b/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs index dffc261..ae1e4be 100644 --- a/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs +++ b/DotPilot.Tests/ChatSessions/Execution/AgentSessionServiceTests.cs @@ -310,6 +310,48 @@ await SeedLegacyAgentAsync( StringComparison.Ordinal)); } + [Test] + public async Task GetWorkspaceAsyncReusesCachedProviderSnapshotAfterWarmRead() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentSessionServiceTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + await using var fixture = CreateFixture(); + + var initialWorkspace = (await fixture.Service.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + initialWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + + commandScope.WriteCountingVersionCommand("codex", "codex version 2.0.0", delayMilliseconds: 300); + commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); + using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + var cachedWorkspace = (await fixture.Service.GetWorkspaceAsync(cancellationSource.Token)).ShouldSucceed(); + cachedWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + } + + [Test] + public async Task GetSessionAsyncPropagatesCallerCancellation() + { + await using var fixture = CreateFixture(); + var agent = await EnableDebugAndCreateAgentAsync(fixture.Service, "Cancellation Agent"); + var session = (await fixture.Service.CreateSessionAsync( + new CreateSessionCommand("Cancellation session", agent.Id), + CancellationToken.None)).ShouldSucceed(); + using var cancellationSource = new CancellationTokenSource(); + await cancellationSource.CancelAsync(); + + _ = Assert.ThrowsAsync(async () => + _ = await fixture.Service.GetSessionAsync(session.Session.Id, cancellationSource.Token)); + } + private static async Task EnableDebugAndCreateAgentAsync( IAgentSessionService service, string name) diff --git a/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs b/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs index f0d2ec7..b2f36f7 100644 --- a/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs +++ b/DotPilot.Tests/Providers/Services/AgentProviderStatusReaderTests.cs @@ -36,6 +36,73 @@ public async Task RefreshWorkspaceAsyncReadsProviderStatusFromCurrentSourceOfTru .Be("2.0.0"); } + [Test] + public async Task ReadAsyncReusesTheCachedSnapshotUntilItIsInvalidated() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + + await using var fixture = CreateFixture(); + var reader = fixture.Provider.GetRequiredService(); + + var initialSnapshot = await reader.ReadAsync(CancellationToken.None); + initialSnapshot + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + commandScope.ReadInvocationCount("codex").Should().Be(1); + + commandScope.WriteCountingVersionCommand("codex", "codex version 2.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); + + var cachedSnapshot = await reader.ReadAsync(CancellationToken.None); + cachedSnapshot + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + commandScope.ReadInvocationCount("codex").Should().Be(1); + + reader.Invalidate(); + + var refreshedSnapshot = await reader.ReadAsync(CancellationToken.None); + refreshedSnapshot + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("2.0.0"); + } + + [Test] + public async Task InvalidateDuringAnActiveProbeForcesTheNextReadToStartANewProbe() + { + using var commandScope = CodexCliTestScope.Create(nameof(AgentProviderStatusReaderTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + + await using var fixture = CreateFixture(); + var reader = fixture.Provider.GetRequiredService(); + + var initialReadTask = reader.ReadAsync(CancellationToken.None).AsTask(); + await Task.Delay(75); + + reader.Invalidate(); + commandScope.WriteCountingVersionCommand("codex", "codex version 2.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); + + var refreshedSnapshot = await reader.ReadAsync(CancellationToken.None); + refreshedSnapshot + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("2.0.0"); + + var initialSnapshot = await initialReadTask; + initialSnapshot.Should().NotBeEmpty(); + } + [Test] public async Task EnabledCodexProviderReportsReadyRuntimeAndCliMetadata() { diff --git a/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs b/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs index 551ff4e..45eb461 100644 --- a/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs +++ b/DotPilot.Tests/Settings/ViewModels/SettingsModelTests.cs @@ -115,6 +115,25 @@ public async Task SelectComposerSendBehaviorUpdatesProjectionAndPreferenceStore( .Should().Be(ComposerSendBehavior.EnterInsertsNewLine); } + [Test] + public async Task RefreshIgnoresCancellationDuringProviderProbe() + { + using var commandScope = CodexCliTestScope.Create(nameof(SettingsModelTests)); + commandScope.WriteVersionCommand("codex", "codex version 1.0.0"); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + await using var fixture = CreateFixture(); + var model = ActivatorUtilities.CreateInstance(fixture.Provider); + + _ = await model.Providers; + + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300); + using var cancellationSource = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await model.Refresh(cancellationSource.Token); + + (await model.StatusMessage).Should().BeOneOf(string.Empty, "Provider readiness refreshed."); + } + private static TestFixture CreateFixture() { var services = new ServiceCollection(); diff --git a/DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs b/DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs new file mode 100644 index 0000000..9879313 --- /dev/null +++ b/DotPilot.Tests/Shell/ViewModels/ShellViewModelTests.cs @@ -0,0 +1,58 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.UI.Xaml; + +namespace DotPilot.Tests.Shell.ViewModels; + +[NonParallelizable] +public sealed class ShellViewModelTests +{ + [Test] + public async Task StartupOverlayRemainsVisibleUntilWorkspaceHydrationCompletes() + { + using var commandScope = CodexCliTestScope.Create(nameof(ShellViewModelTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 300); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + + await using var fixture = CreateFixture(); + var viewModel = fixture.Provider.GetRequiredService(); + var hydration = fixture.Provider.GetRequiredService(); + + viewModel.StartupOverlayVisibility.Should().Be(Visibility.Visible); + + var hydrationTask = hydration.EnsureHydratedAsync(CancellationToken.None).AsTask(); + + viewModel.StartupOverlayVisibility.Should().Be(Visibility.Visible); + + await hydrationTask; + + viewModel.StartupOverlayVisibility.Should().Be(Visibility.Collapsed); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + services.AddSingleton(); + + var provider = services.BuildServiceProvider(); + return new TestFixture(provider); + } + + private sealed class TestFixture(ServiceProvider provider) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs b/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs index 602c93b..aa28b02 100644 --- a/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs +++ b/DotPilot.Tests/Workspace/Services/AgentWorkspaceStateTests.cs @@ -8,7 +8,7 @@ namespace DotPilot.Tests.Workspace; public sealed class AgentWorkspaceStateTests { [Test] - public async Task RepeatedWorkspaceReadsSeeUpdatedProviderStatusWithoutManualRefresh() + public async Task RepeatedWorkspaceReadsReuseTheHydratedProviderSnapshotUntilManualRefresh() { using var commandScope = CodexCliTestScope.Create(nameof(AgentWorkspaceStateTests)); commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 0); @@ -26,7 +26,15 @@ public async Task RepeatedWorkspaceReadsSeeUpdatedProviderStatusWithoutManualRef commandScope.WriteCountingVersionCommand("codex", "codex version 2.0.0", delayMilliseconds: 0); commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); - var refreshedWorkspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + var cachedWorkspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + cachedWorkspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + commandScope.ReadInvocationCount("codex").Should().Be(1); + + var refreshedWorkspace = (await fixture.WorkspaceState.RefreshWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); refreshedWorkspace.Providers .Single(provider => provider.Kind == AgentProviderKind.Codex) .InstalledVersion diff --git a/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs b/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs new file mode 100644 index 0000000..01e0d12 --- /dev/null +++ b/DotPilot.Tests/Workspace/Services/StartupWorkspaceHydrationTests.cs @@ -0,0 +1,65 @@ +using DotPilot.Core.ChatSessions; +using DotPilot.Tests.Providers; +using Microsoft.Extensions.DependencyInjection; + +namespace DotPilot.Tests.Workspace; + +[NonParallelizable] +public sealed class StartupWorkspaceHydrationTests +{ + [Test] + public async Task EnsureHydratedAsyncWarmsProviderStatusForSubsequentWorkspaceReads() + { + using var commandScope = CodexCliTestScope.Create(nameof(StartupWorkspaceHydrationTests)); + commandScope.WriteCountingVersionCommand("codex", "codex version 1.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.4", "gpt-5.4"); + + await using var fixture = CreateFixture(); + var hydration = fixture.Provider.GetRequiredService(); + + await hydration.EnsureHydratedAsync(CancellationToken.None); + + commandScope.ReadInvocationCount("codex").Should().Be(1); + + commandScope.WriteCountingVersionCommand("codex", "codex version 2.0.0", delayMilliseconds: 0); + commandScope.WriteCodexMetadata("gpt-5.1", "gpt-5.1"); + + var workspace = (await fixture.WorkspaceState.GetWorkspaceAsync(CancellationToken.None)).ShouldSucceed(); + + workspace.Providers + .Single(provider => provider.Kind == AgentProviderKind.Codex) + .InstalledVersion + .Should() + .Be("1.0.0"); + commandScope.ReadInvocationCount("codex").Should().Be(1); + } + + private static TestFixture CreateFixture() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddAgentSessions(new AgentSessionStorageOptions + { + UseInMemoryDatabase = true, + InMemoryDatabaseName = Guid.NewGuid().ToString("N"), + }); + + var provider = services.BuildServiceProvider(); + return new TestFixture( + provider, + provider.GetRequiredService()); + } + + private sealed class TestFixture(ServiceProvider provider, IAgentWorkspaceState workspaceState) : IAsyncDisposable + { + public ServiceProvider Provider { get; } = provider; + + public IAgentWorkspaceState WorkspaceState { get; } = workspaceState; + + public ValueTask DisposeAsync() + { + return Provider.DisposeAsync(); + } + } +} diff --git a/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs b/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs index 7a481c1..9256280 100644 --- a/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs +++ b/DotPilot.UITests/ChatSessions/Flows/GivenChatSessionsShell.cs @@ -37,7 +37,6 @@ public sealed class GivenChatSessionsShell : TestBase private const string AgentProviderClaudeCodeOptionAutomationId = "AgentProviderOption_ClaudeCode"; private const string AgentProviderCodexOptionAutomationId = "AgentProviderOption_Codex"; private const string AgentSelectedProviderTextAutomationId = "AgentSelectedProviderText"; - private const string AgentModelGpt5OptionAutomationId = "AgentModelOption_gpt5"; private const string ChatComposerInputAutomationId = "ChatComposerInput"; private const string ChatComposerHintAutomationId = "ChatComposerHint"; private const string ChatComposerSendButtonAutomationId = "ChatComposerSendButton"; @@ -47,6 +46,8 @@ public sealed class GivenChatSessionsShell : TestBase private const string ChatStartNewButtonAutomationId = "ChatStartNewButton"; private const string ChatTitleTextAutomationId = "ChatTitleText"; private const string ChatMessageTextAutomationId = "ChatMessageText"; + private const string ChatActivityItemAutomationId = "ChatActivityItem"; + private const string ChatActivityLabelAutomationId = "ChatActivityLabel"; private const string ChatRecentChatItemAutomationId = "ChatRecentChatItem"; private const string ToggleProviderButtonAutomationId = "ToggleProviderButton"; private const string SaveAgentButtonAutomationId = "SaveAgentButton"; @@ -100,6 +101,9 @@ public async Task WhenOpeningTheAppThenDefaultSystemAgentCanStartAChat() ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); PressEnterAutomationElement(ChatComposerInputAutomationId); + WaitForElement(ChatActivityItemAutomationId); + WaitForTextContains(ChatActivityLabelAutomationId, "status", ScreenTransitionTimeout); + WaitForTextContains(ChatActivityLabelAutomationId, "tool", ScreenTransitionTimeout); WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); @@ -174,7 +178,9 @@ public async Task WhenSelectingClaudeCodeWhileAuthoringAnAgentThenTheProviderSum WaitForElement(AgentBasicInfoSectionAutomationId); WaitForTextContains(AgentSelectedProviderTextAutomationId, "Codex", ScreenTransitionTimeout); - ClickActionAutomationElement(AgentProviderClaudeCodeOptionAutomationId); + ClickActionAutomationElement( + AgentProviderClaudeCodeOptionAutomationId, + () => HasTextContaining(AgentSelectedProviderTextAutomationId, "Claude Code")); WaitForTextContains(AgentSelectedProviderTextAutomationId, "Claude Code", ScreenTransitionTimeout); @@ -186,7 +192,7 @@ public async Task WhenSavingANewAgentThenANewChatSessionOpensForThatAgent() { await Task.CompletedTask; - EnsureProviderEnabled(CodexProviderEntryAutomationId); + EnsureProviderEnabled(CodexProviderEntryAutomationId, "Codex"); TapAutomationElement(AgentsNavButtonAutomationId); WaitForElement(AgentBuilderScreenAutomationId); @@ -196,8 +202,9 @@ public async Task WhenSavingANewAgentThenANewChatSessionOpensForThatAgent() WaitForElement(AgentBasicInfoSectionAutomationId); ReplaceTextAutomationElement("AgentNameInput", "UI Codex Agent"); - ClickActionAutomationElement(AgentProviderCodexOptionAutomationId); - ClickActionAutomationElement(AgentModelGpt5OptionAutomationId); + ClickActionAutomationElement( + AgentProviderCodexOptionAutomationId, + () => HasTextContaining(AgentSelectedProviderTextAutomationId, "Codex")); ReplaceTextAutomationElement("AgentDescriptionInput", "UI-created Codex agent."); ReplaceTextAutomationElement("AgentSystemPromptInput", "Answer briefly."); @@ -232,6 +239,36 @@ public async Task WhenChangingMessageSendBehaviorThenChatHintReflectsTheSelectio TakeScreenshot("chat_message_send_behavior"); } + [Test] + public async Task WhenEnterAddsNewLineThenModifierEnterStillSendsTheMessage() + { + await Task.CompletedTask; + + EnsureOnChatScreen(); + TapAutomationElement(ProvidersNavButtonAutomationId); + WaitForElement(SettingsScreenAutomationId); + ClickActionAutomationElement(SettingsSectionMessagesButtonAutomationId); + WaitForElement(ComposerBehaviorSectionAutomationId); + ClickActionAutomationElement(ComposerBehaviorEnterInsertsNewLineButtonAutomationId); + WaitForTextContains(ComposerBehaviorCurrentHintAutomationId, "Enter adds a new line.", ScreenTransitionTimeout); + + TapAutomationElement(ChatNavButtonAutomationId); + EnsureOnChatScreen(); + WaitForTextContains(ChatComposerHintAutomationId, "Enter adds a new line.", ScreenTransitionTimeout); + ClickActionAutomationElement(ChatStartNewButtonAutomationId); + WaitForTextContains(ChatTitleTextAutomationId, DefaultSessionTitle, ScreenTransitionTimeout); + + ReplaceTextAutomationElement(ChatComposerInputAutomationId, UserPrompt); + PressModifierEnterAutomationElement(ChatComposerInputAutomationId); + WaitForElement(ChatActivityItemAutomationId); + WaitForTextContains(ChatActivityLabelAutomationId, "status", ScreenTransitionTimeout); + WaitForTextContains(ChatActivityLabelAutomationId, "tool", ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugResponsePrefix, ScreenTransitionTimeout); + WaitForTextContains(ChatMessageTextAutomationId, DebugToolFinishedText, ScreenTransitionTimeout); + + TakeScreenshot("chat_shift_enter_send_behavior"); + } + private void EnsureOnChatScreen() { if (TryWaitForElement(ChatScreenAutomationId, InitialScreenProbeTimeout)) @@ -245,23 +282,48 @@ private void EnsureOnChatScreen() WaitForElement(ChatComposerInputAutomationId); } - private void EnsureProviderEnabled(string providerEntryAutomationId) + private void EnsureProviderEnabled(string providerEntryAutomationId, string providerDisplayName) { EnsureOnChatScreen(); TapAutomationElement(ProvidersNavButtonAutomationId); WaitForElement(SettingsScreenAutomationId); WaitForElement(ProviderListAutomationId); - TapAutomationElement(providerEntryAutomationId); + ClickActionAutomationElement( + providerEntryAutomationId, + () => HasTextContaining(SelectedProviderTitleAutomationId, providerDisplayName)); WaitForElement(ToggleProviderButtonAutomationId); var toggleText = ReadPrimaryText(ToggleProviderButtonAutomationId); if (toggleText.Contains("Enable provider", StringComparison.Ordinal)) { - ClickActionAutomationElement(ToggleProviderButtonAutomationId); + ClickActionAutomationElement( + ToggleProviderButtonAutomationId, + () => HasTextContaining(ToggleProviderButtonAutomationId, "Disable provider")); WaitForTextContains(ToggleProviderButtonAutomationId, "Disable provider", ScreenTransitionTimeout); } } + private bool HasTextContaining(string automationId, string expectedText) + { + var texts = App.Query(automationId) + .Select(result => NormalizeText(result.Text)) + .Where(text => !string.IsNullOrWhiteSpace(text)) + .ToArray(); + if (texts.Any(text => text.Contains(expectedText, StringComparison.Ordinal))) + { + return true; + } + + if (TryReadBrowserInputValue(automationId, out var inputValue) && + NormalizeText(inputValue).Contains(expectedText, StringComparison.Ordinal)) + { + return true; + } + + return TryReadBrowserAutomationTexts(automationId, out var browserTexts) && + browserTexts.Any(text => NormalizeText(text).Contains(expectedText, StringComparison.Ordinal)); + } + private string ReadPrimaryText(string automationId) { var texts = App.Query(automationId) diff --git a/DotPilot.UITests/Harness/TestBase.cs b/DotPilot.UITests/Harness/TestBase.cs index 7db3632..343e369 100644 --- a/DotPilot.UITests/Harness/TestBase.cs +++ b/DotPilot.UITests/Harness/TestBase.cs @@ -360,6 +360,11 @@ protected void TapAutomationElement(string automationId) { TryScrollBrowserAutomationElementIntoView(automationId); + if (TryActivateBrowserAutomationElement(automationId)) + { + return; + } + try { App.Tap(automationId); @@ -371,11 +376,6 @@ protected void TapAutomationElement(string automationId) HarnessLog.Write($"Uno.UITest tap failed for '{automationId}': {exception.Message}"); } - if (TryActivateBrowserAutomationElement(automationId)) - { - return; - } - if (TryClickBrowserAutomationElementAtCenter(automationId)) { return; @@ -478,6 +478,14 @@ protected void ReplaceTextAutomationElement(string automationId, string text) } protected void ClickActionAutomationElement(string automationId, bool expectElementToDisappear = false) + { + ClickActionAutomationElement(automationId, effectObserved: null, expectElementToDisappear); + } + + protected void ClickActionAutomationElement( + string automationId, + Func? effectObserved, + bool expectElementToDisappear = false) { ArgumentException.ThrowIfNullOrWhiteSpace(automationId); @@ -485,11 +493,21 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem { TryScrollBrowserAutomationElementIntoView(automationId); + if (TryActivateBrowserAutomationElement(automationId)) + { + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) + { + return; + } + + HarnessLog.Write($"Action '{automationId}' remained visible after keyboard activation; trying Uno.UITest tap."); + } + try { App.Tap(automationId); HarnessLog.Write($"Uno.UITest action tap outcome for '{automationId}': tapped"); - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) { return; } @@ -503,7 +521,7 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem if (TryPerformBrowserClickAction(automationId)) { - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) { return; } @@ -513,7 +531,7 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem if (TryClickBrowserAutomationElement(automationId)) { - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) { return; } @@ -523,7 +541,7 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem if (TryClickBrowserAutomationElementAtCenter(automationId)) { - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) { return; } @@ -531,16 +549,6 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem HarnessLog.Write($"Action '{automationId}' remained visible after center-point click; trying keyboard activation."); } - if (TryActivateBrowserAutomationElement(automationId)) - { - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) - { - return; - } - - HarnessLog.Write($"Action '{automationId}' remained visible after keyboard activation; trying coordinate tap."); - } - try { var matches = App.Query(automationId); @@ -549,7 +557,7 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem var target = matches[0]; App.TapCoordinates(target.Rect.CenterX, target.Rect.CenterY); HarnessLog.Write($"Coordinate action tap outcome for '{automationId}': tapped"); - if (!expectElementToDisappear || WaitForAutomationElementToDisappear(automationId)) + if (DidBrowserActionTakeEffect(automationId, effectObserved, expectElementToDisappear)) { return; } @@ -564,6 +572,60 @@ protected void ClickActionAutomationElement(string automationId, bool expectElem TapAutomationElement(automationId); } + private bool DidBrowserActionTakeEffect( + string automationId, + Func? effectObserved, + bool expectElementToDisappear) + { + if (effectObserved is not null) + { + return WaitForActionEffect(effectObserved); + } + + if (!expectElementToDisappear) + { + return true; + } + + return WaitForAutomationElementToDisappear(automationId); + } + + private static bool WaitForActionEffect(Func effectObserved) + { + var timeoutAt = DateTimeOffset.UtcNow.Add(PostClickTransitionProbeTimeout); + while (DateTimeOffset.UtcNow < timeoutAt) + { + try + { + if (effectObserved()) + { + return true; + } + } + catch (InvalidOperationException) + { + } + catch (StaleElementReferenceException) + { + } + + Task.Delay(QueryRetryFrequency).GetAwaiter().GetResult(); + } + + try + { + return effectObserved(); + } + catch (InvalidOperationException) + { + return false; + } + catch (StaleElementReferenceException) + { + return false; + } + } + private bool WaitForAutomationElementToDisappear(string automationId) { var timeoutAt = DateTimeOffset.UtcNow.Add(PostClickTransitionProbeTimeout); @@ -584,7 +646,7 @@ protected void PressEnterAutomationElement(string automationId) { ArgumentException.ThrowIfNullOrWhiteSpace(automationId); - if (TryPressEnterBrowserInput(automationId)) + if (TryPressEnterBrowserInput(automationId, useModifier: false)) { return; } @@ -592,6 +654,18 @@ protected void PressEnterAutomationElement(string automationId) App.EnterText(automationId, Keys.Enter); } + protected void PressModifierEnterAutomationElement(string automationId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(automationId); + + if (TryPressEnterBrowserInput(automationId, useModifier: true)) + { + return; + } + + App.EnterText(automationId, ComposeEnterSequence(useModifier: true)); + } + protected void SelectComboBoxAutomationElementOption(string automationId, string optionText) { ArgumentException.ThrowIfNullOrWhiteSpace(automationId); @@ -1530,7 +1604,7 @@ private static void PrepareBrowserInputForTyping(IWebDriver driver, IWebElement inputElement); } - private bool TryPressEnterBrowserInput(string automationId) + private bool TryPressEnterBrowserInput(string automationId, bool useModifier) { if (Constants.CurrentPlatform != UITestPlatform.Browser || _app is null) { @@ -1554,6 +1628,7 @@ private bool TryPressEnterBrowserInput(string automationId) } var escapedAutomationId = automationId.Replace("'", "\\'", StringComparison.Ordinal); + var controlModifier = useModifier ? "true" : "false"; var outcome = javaScriptExecutor.ExecuteScript( string.Concat( """ @@ -1643,20 +1718,69 @@ private bool TryPressEnterBrowserInput(string automationId) key: 'Enter', code: 'Enter', keyCode: 13, - which: 13 + which: 13, + shiftKey: false, + ctrlKey: + """, + controlModifier, + """ }; + if ( + """, + controlModifier, + """ + ) { + const modifierDownInit = { + bubbles: true, + cancelable: true, + composed: true, + key: 'Control', + code: 'ControlLeft', + keyCode: 17, + which: 17, + shiftKey: false, + ctrlKey: true + }; + + input.dispatchEvent(new KeyboardEvent('keydown', modifierDownInit)); + host.dispatchEvent(new KeyboardEvent('keydown', modifierDownInit)); + } + input.dispatchEvent(new KeyboardEvent('keydown', eventInit)); input.dispatchEvent(new KeyboardEvent('keypress', eventInit)); input.dispatchEvent(new KeyboardEvent('keyup', eventInit)); host.dispatchEvent(new KeyboardEvent('keydown', eventInit)); host.dispatchEvent(new KeyboardEvent('keypress', eventInit)); host.dispatchEvent(new KeyboardEvent('keyup', eventInit)); + + if ( + """, + controlModifier, + """ + ) { + const modifierUpInit = { + bubbles: true, + cancelable: true, + composed: true, + key: 'Control', + code: 'ControlLeft', + keyCode: 17, + which: 17, + shiftKey: false, + ctrlKey: false + }; + + input.dispatchEvent(new KeyboardEvent('keyup', modifierUpInit)); + host.dispatchEvent(new KeyboardEvent('keyup', modifierUpInit)); + } + return 'pressed'; })(); """)); - HarnessLog.Write($"Browser input enter outcome for '{automationId}': {outcome}."); + HarnessLog.Write( + $"Browser input {(useModifier ? "modifier+enter" : "enter")} outcome for '{automationId}': {outcome}."); if (!string.Equals( Convert.ToString(outcome, System.Globalization.CultureInfo.InvariantCulture), "pressed", @@ -1665,17 +1789,36 @@ private bool TryPressEnterBrowserInput(string automationId) return false; } + if (useModifier) + { + return true; + } + var inputElement = TryResolveBrowserInputElement(driver, automationId); - inputElement?.SendKeys(Keys.Enter); + if (inputElement is null) + { + return false; + } + + inputElement.SendKeys(Keys.Enter); + return true; } catch (Exception exception) { - HarnessLog.Write($"Browser input enter failed for '{automationId}': {exception.Message}"); + HarnessLog.Write( + $"Browser input {(useModifier ? "modifier+enter" : "enter")} failed for '{automationId}': {exception.Message}"); return false; } } + private static string ComposeEnterSequence(bool useModifier) + { + return useModifier + ? string.Concat(Keys.Control, Keys.Enter, Keys.Null) + : Keys.Enter; + } + private static void ClearActiveBrowserInput(IWebElement activeElement) { ArgumentNullException.ThrowIfNull(activeElement); diff --git a/DotPilot/AGENTS.md b/DotPilot/AGENTS.md index 6bf5b00..0bfd04f 100644 --- a/DotPilot/AGENTS.md +++ b/DotPilot/AGENTS.md @@ -49,6 +49,8 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - Do not hide distinct product features under one presentation umbrella directory such as `Presentation/AgentSessions`; keep `Chat`, `AgentBuilder`, `Settings`, `Shell`, and shared infrastructure in explicit feature roots. - Inside each presentation feature root, keep `Models`, `Views`, `ViewModels`, `Controls`, and `Configuration` explicit instead of mixing page, view-model, model, and policy files together at the top level. - The chat composer must expose an operator setting for send behavior with exactly two modes: `Enter` sends while `Enter` with modifiers inserts a new line, or `Enter` inserts a new line while `Enter` with modifiers sends; do not hardcode only one behavior. +- Tool calls, thinking/status updates, and other live agent activity must render inline in the main chat transcript in a compact Codex-like flow; do not split that activity into a separate side surface or force the operator to reconstruct the run from disconnected panels. +- While the active chat is streaming new transcript rows, the conversation viewport must auto-scroll to the latest activity by default so tool calls, status lines, and assistant output stay visible without manual scrolling. - Prefer declarative `Uno.Extensions.Navigation` in XAML via `uen:Navigation.Request` over page code-behind navigation calls. - Keep business logic, persistence, networking workflows, and non-UI orchestration out of page code-behind. - Do not cast `DataContext` to concrete screen models or call their methods from control/page code-behind; if a framework event needs bridging, expose a bindable command or presentation-safe abstraction instead of coupling the view to a specific view-model type. @@ -59,6 +61,7 @@ Stack: `.NET 10`, `Uno Platform`, `Uno.Extensions.Navigation`, `Uno Toolkit`, de - Treat desktop window sizing and positioning as an app-startup responsibility in `App.xaml.cs`. - For local UI debugging on this machine, run the real desktop head and prefer local `Uno` app tooling or MCP inspection over `browserwasm` reproduction unless the task is specifically about `DotPilot.UITests`. - Do not let ordinary view-model binding or section switching trigger duplicate provider CLI probes or log expected async cancellation as failures; the shell should stay quiet and reactive during normal navigation. +- App startup may use a dedicated splash/loading state to hydrate provider readiness and installed CLI metadata once before the main shell becomes interactive; after that, the presentation layer should reuse the startup snapshot and only request reprobes on explicit refresh or provider-setting changes. - Prefer `Microsoft Agent Framework` for orchestration, sessions, workflows, HITL, MCP-aware runtime features, and OpenTelemetry-based observability hooks. - Keep the prompt-to-agent interpreter outside the page layer: the Uno shell should collect the user prompt and render the generated draft, while the runtime or a dedicated system-agent orchestration service decides agent name, description, tools, providers, and policy-compliant defaults. - Persist durable chat/session/operator state outside the UI layer, using `EF Core` with `SQLite` for the local desktop store when data must survive restarts. diff --git a/DotPilot/App.xaml.cs b/DotPilot/App.xaml.cs index 9ba0f6c..d66ae1a 100644 --- a/DotPilot/App.xaml.cs +++ b/DotPilot/App.xaml.cs @@ -14,6 +14,8 @@ public partial class App : Application private const string BuilderCreatedMarker = "Uno host builder created."; private const string NavigateStartedMarker = "Navigating to shell."; private const string NavigateCompletedMarker = "Shell navigation completed."; + private const string StartupHydrationStartedMarker = "Startup workspace hydration started."; + private const string StartupHydrationCompletedMarker = "Startup workspace hydration completed."; private const string DotPilotCategoryName = "DotPilot"; #if !__WASM__ private const string CenterMethodName = "Center"; @@ -120,11 +122,15 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) WriteStartupMarker(NavigateStartedMarker); Host = await builder.NavigateAsync(); WriteStartupMarker(NavigateCompletedMarker); + WriteStartupMarker(StartupHydrationStartedMarker); + var startupHydration = Host.Services.GetRequiredService(); + await startupHydration.EnsureHydratedAsync(CancellationToken.None); + WriteStartupMarker(StartupHydrationCompletedMarker); ServicesReady?.Invoke(this, EventArgs.Empty); var appLogger = Host.Services.GetRequiredService>(); AppLog.StartupMarker( appLogger, - NavigateCompletedMarker); + StartupHydrationCompletedMarker); #if !__WASM__ CenterDesktopWindow(MainWindow); #endif diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml index 05799f7..7d07f2b 100644 --- a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml @@ -50,18 +50,17 @@ FontWeight="Medium" Foreground="{StaticResource AppSecondaryTextBrush}" Text="Provider" /> - + + SelectedItem="{Binding SelectedProvider, Mode=TwoWay}" /> - @@ -74,7 +73,6 @@ @@ -90,17 +88,17 @@ FontWeight="Medium" Foreground="{StaticResource AppSecondaryTextBrush}" Text="Model" /> - + - @@ -112,7 +110,6 @@ @@ -122,7 +119,7 @@ @@ -137,18 +134,18 @@ + Text="{Binding BuilderProviderDisplayName}" /> + Text="{Binding BuilderProviderCommandName}" /> + Text="{Binding BuilderProviderVersionLabel}" + Visibility="{Binding BuilderHasProviderVersion}" /> diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs index da1c185..35e1cc9 100644 --- a/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentBasicInfoSection.xaml.cs @@ -2,59 +2,60 @@ namespace DotPilot.Presentation.Controls; public sealed partial class AgentBasicInfoSection : UserControl { + public static readonly DependencyProperty ProviderSelectionChangedCommandProperty = + DependencyProperty.Register( + nameof(ProviderSelectionChangedCommand), + typeof(ICommand), + typeof(AgentBasicInfoSection), + new PropertyMetadata(null)); + + public static readonly DependencyProperty SelectModelCommandProperty = + DependencyProperty.Register( + nameof(SelectModelCommand), + typeof(ICommand), + typeof(AgentBasicInfoSection), + new PropertyMetadata(null)); + public AgentBasicInfoSection() { InitializeComponent(); } - public bool IsBrowserHead => OperatingSystem.IsBrowser(); + public ICommand? ProviderSelectionChangedCommand + { + get => (ICommand?)GetValue(ProviderSelectionChangedCommandProperty); + set => SetValue(ProviderSelectionChangedCommandProperty, value); + } - public bool IsDesktopHead => !IsBrowserHead; + public ICommand? SelectModelCommand + { + get => (ICommand?)GetValue(SelectModelCommandProperty); + set => SetValue(SelectModelCommandProperty, value); + } + + public bool IsBrowserHead => OperatingSystem.IsBrowser(); private void OnProviderSelectionChanged(object sender, SelectionChangedEventArgs e) { - if (sender is not ComboBox comboBox) - { - return; - } - - AgentProviderKind? selectedProviderKind = e.AddedItems.OfType().FirstOrDefault()?.Kind; - selectedProviderKind ??= comboBox.SelectedValue as AgentProviderKind?; - if (selectedProviderKind is null && - comboBox.SelectedItem is AgentProviderOption selectedProvider) - { - selectedProviderKind = selectedProvider.Kind; - } - - _ = comboBox.DispatcherQueue.TryEnqueue(() => - { - BrowserConsoleDiagnostics.Info( - $"[DotPilot.AgentBuilder] Provider selection changed. Provider={selectedProviderKind?.ToString() ?? ""}."); - BoundCommandBridge.Execute(comboBox.Tag as ICommand, selectedProviderKind); - }); + var provider = e.AddedItems.OfType().FirstOrDefault(); + BrowserConsoleDiagnostics.Info( + $"[DotPilot.AgentBuilder] Provider selection changed. Provider={provider?.Kind.ToString() ?? ""}."); + BoundCommandBridge.Execute(ProviderSelectionChangedCommand, provider); } private void OnProviderQuickSelectButtonClick(object sender, RoutedEventArgs e) { - if (sender is not Button button) - { - return; - } - + var provider = (sender as FrameworkElement)?.DataContext as AgentProviderOption; BrowserConsoleDiagnostics.Info( - $"[DotPilot.AgentBuilder] Provider quick-select clicked. Provider={button.CommandParameter?.ToString() ?? ""}."); - BoundCommandBridge.Execute(button.Tag as ICommand, button.CommandParameter); + $"[DotPilot.AgentBuilder] Provider quick-select clicked. Provider={provider?.Kind.ToString() ?? ""}."); + BoundCommandBridge.Execute(ProviderSelectionChangedCommand, provider); } private void OnModelQuickSelectButtonClick(object sender, RoutedEventArgs e) { - if (sender is not Button button) - { - return; - } - + var model = (sender as FrameworkElement)?.DataContext as AgentModelOption; BrowserConsoleDiagnostics.Info( - $"[DotPilot.AgentBuilder] Model quick-select clicked. Model={button.CommandParameter?.ToString() ?? ""}."); - BoundCommandBridge.Execute(button.Tag as ICommand, button.CommandParameter); + $"[DotPilot.AgentBuilder] Model quick-select clicked. Model={model?.DisplayName ?? ""}."); + BoundCommandBridge.Execute(SelectModelCommand, model); } } diff --git a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml index 2aecdae..d664238 100644 --- a/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml +++ b/DotPilot/Presentation/AgentBuilder/Controls/AgentPromptSection.xaml @@ -59,7 +59,7 @@ SelectedProviderKind => State.Value(this, static () => AgentProviderKind.Debug); + public IState BuilderProviderDisplayName => State.Value(this, static () => EmptyProviderDisplayName); + + public IState BuilderProviderStatusSummary => State.Value(this, static () => EmptyProviderStatusSummary); + + public IState BuilderProviderCommandName => State.Value(this, static () => EmptyProviderCommandName); + + public IState BuilderProviderVersionLabel => State.Value(this, static () => string.Empty); + + public IState BuilderHasProviderVersion => State.Value(this, static () => false); + + public IState BuilderSuggestedModelName => State.Value(this, static () => string.Empty); + + public IState> BuilderSupportedModelNames => + State.Value>(this, static () => Array.Empty()); + + public IState> BuilderSupportedModels => + State.Value>(this, static () => Array.Empty()); + + public IState BuilderHasSupportedModels => State.Value(this, static () => false); + + public IState BuilderModelHelperText => State.Value(this, static () => EmptyModelHelperText); + + public IState BuilderStatusMessage => State.Value(this, static () => EmptyProviderStatusSummary); + + public IState BuilderCanCreateAgent => State.Value(this, static () => false); + public IState CanGenerateDraft => State.Async(this, LoadCanGenerateDraftAsync); public IState CanSaveAgent => State.Async(this, LoadCanSaveAgentAsync); - public IState Builder => State.Async(this, LoadBuilderAsync); + public IState Builder => State.Value(this, static () => EmptyBuilderView); public ICommand OpenCreateAgentCommand => _openCreateAgentCommand ??= new AsyncCommand( @@ -133,6 +172,9 @@ public async ValueTask OpenCreateAgent(CancellationToken cancellationToken) await ModelName.SetAsync(string.Empty, cancellationToken); await SystemPrompt.SetAsync(string.Empty, cancellationToken); await OperationMessage.SetAsync(string.Empty, cancellationToken); + await SelectedProvider.UpdateAsync(_ => EmptySelectedProvider, cancellationToken); + await SelectedProviderKind.UpdateAsync(_ => AgentProviderKind.Debug, cancellationToken); + await ApplyBuilderViewAsync(EmptyBuilderView, cancellationToken); await Surface.UpdateAsync(_ => PromptSurface, cancellationToken); } @@ -171,25 +213,8 @@ public async ValueTask HandleSelectedProviderChanged( AgentProviderOption? provider, CancellationToken cancellationToken) { - if (provider is null || IsEmptySelectedProvider(provider)) - { - return; - } - - var previousProvider = (await SelectedProvider) ?? EmptySelectedProvider; - var currentModelName = (await ModelName) ?? string.Empty; - var previousSuggestedModel = ResolveSuggestedModelName(previousProvider); - var nextSuggestedModel = ResolveSuggestedModelName(provider); - var shouldUpdateModel = string.IsNullOrWhiteSpace(currentModelName) || - string.Equals(currentModelName, previousSuggestedModel, StringComparison.Ordinal) || - !SupportsModel(provider, currentModelName); - - await SelectedProvider.UpdateAsync(_ => provider, cancellationToken); - await SelectedProviderKind.UpdateAsync(_ => provider.Kind, cancellationToken); - if (shouldUpdateModel) - { - await ModelName.UpdateAsync(_ => nextSuggestedModel, cancellationToken); - } + await SetSelectedProviderStateAsync(provider, synchronizeModel: true, refreshBuilder: false, cancellationToken); + await RefreshBuilderAsync(provider, cancellationToken); } public async ValueTask HandleProviderSelectionChanged( @@ -221,6 +246,7 @@ public async ValueTask SelectModel(object? parameter, CancellationToken cancella } await ModelName.UpdateAsync(_ => modelName.Trim(), cancellationToken); + await RefreshBuilderAsync(cancellationToken); } private async ValueTask SubmitAgentDraftCore(string? promptOverride, CancellationToken cancellationToken) @@ -453,34 +479,6 @@ private async ValueTask LoadCanSaveAgentAsync(CancellationToken cancellati !string.IsNullOrWhiteSpace(agentName); } - private async ValueTask LoadBuilderAsync(CancellationToken cancellationToken) - { - var selectedProvider = await ResolveSelectedProviderAsync(cancellationToken); - var suggestedModelName = ResolveSuggestedModelName(selectedProvider); - var providerVersionLabel = string.IsNullOrWhiteSpace(selectedProvider.InstalledVersion) - ? string.Empty - : VersionPrefix + selectedProvider.InstalledVersion; - var modelHelperText = string.IsNullOrWhiteSpace(suggestedModelName) - ? EmptyModelHelperText - : string.Format( - System.Globalization.CultureInfo.InvariantCulture, - SuggestedModelHelperCompositeFormat, - suggestedModelName); - - return new AgentBuilderView( - IsEmptySelectedProvider(selectedProvider) ? EmptyProviderDisplayName : selectedProvider.DisplayName, - IsEmptySelectedProvider(selectedProvider) ? EmptyProviderStatusSummary : selectedProvider.StatusSummary, - IsEmptySelectedProvider(selectedProvider) ? EmptyProviderCommandName : selectedProvider.CommandName, - providerVersionLabel, - !string.IsNullOrWhiteSpace(providerVersionLabel), - suggestedModelName, - ResolveSupportedModelNames(selectedProvider), - ResolveSupportedModelNames(selectedProvider).Count > 0, - modelHelperText, - ResolveStatusMessage(selectedProvider), - await CanSaveAgent); - } - private async ValueTask ApplyDraftAsync(AgentPromptDraft draft, CancellationToken cancellationToken) { await AgentName.SetAsync(draft.Name, cancellationToken); @@ -501,6 +499,7 @@ private async ValueTask ApplyDraftAsync(AgentPromptDraft draft, CancellationToke await HandleSelectedProviderChanged(provider, cancellationToken); await ModelName.SetAsync(ResolveDraftModelName(draft, provider), cancellationToken); + await RefreshBuilderAsync(cancellationToken); } private async ValueTask EnsureSelectedProviderAsync( @@ -510,9 +509,14 @@ private async ValueTask EnsureSelectedProviderAsync( var selectedProvider = (await SelectedProvider) ?? EmptySelectedProvider; var selectedProviderKind = await SelectedProviderKind; var resolvedProvider = IsEmptySelectedProvider(selectedProvider) - ? FindProviderByKind(providers, selectedProviderKind) + ? EmptySelectedProvider : FindProviderByKind(providers, selectedProvider.Kind); + if (IsEmptySelectedProvider(resolvedProvider)) + { + resolvedProvider = FindProviderByKind(providers, selectedProviderKind); + } + if (IsEmptySelectedProvider(resolvedProvider)) { resolvedProvider = FindFirstCreatableProvider(providers); @@ -523,39 +527,171 @@ private async ValueTask EnsureSelectedProviderAsync( resolvedProvider = providers[0]; } - if (!Equals(selectedProvider, resolvedProvider)) + if (!Equals(selectedProvider, resolvedProvider) || selectedProviderKind != resolvedProvider.Kind) + { + await SetSelectedProviderStateAsync( + resolvedProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); + } + } + + private async ValueTask SetSelectedProviderStateAsync( + AgentProviderOption? provider, + bool synchronizeModel, + bool refreshBuilder, + CancellationToken cancellationToken) + { + if (provider is null || IsEmptySelectedProvider(provider)) + { + return; + } + + var previousProvider = (await SelectedProvider) ?? EmptySelectedProvider; + var currentModelName = (await ModelName) ?? string.Empty; + var previousSuggestedModel = ResolveSuggestedModelName(previousProvider); + var nextSuggestedModel = ResolveSuggestedModelName(provider); + var shouldUpdateModel = synchronizeModel && ( + string.IsNullOrWhiteSpace(currentModelName) || + string.Equals(currentModelName, previousSuggestedModel, StringComparison.Ordinal) || + !SupportsModel(provider, currentModelName)); + + await SelectedProvider.UpdateAsync(_ => provider, cancellationToken); + await SelectedProviderKind.UpdateAsync(_ => provider.Kind, cancellationToken); + if (shouldUpdateModel) + { + await ModelName.UpdateAsync(_ => nextSuggestedModel, cancellationToken); + } + + if (refreshBuilder) + { + await RefreshBuilderAsync(cancellationToken); + } + } + + private async ValueTask RefreshBuilderAsync(CancellationToken cancellationToken) + { + var builder = await CreateBuilderViewAsync(providerOverride: null, cancellationToken); + await ApplyBuilderViewAsync(builder, cancellationToken); + } + + private async ValueTask RefreshBuilderAsync( + AgentProviderOption? providerOverride, + CancellationToken cancellationToken) + { + var builder = await CreateBuilderViewAsync(providerOverride, cancellationToken); + await ApplyBuilderViewAsync(builder, cancellationToken); + } + + private async ValueTask ApplyBuilderViewAsync(AgentBuilderView builder, CancellationToken cancellationToken) + { + await BuilderProviderDisplayName.UpdateAsync(_ => builder.ProviderDisplayName, cancellationToken); + await BuilderProviderStatusSummary.UpdateAsync(_ => builder.ProviderStatusSummary, cancellationToken); + await BuilderProviderCommandName.UpdateAsync(_ => builder.ProviderCommandName, cancellationToken); + await BuilderProviderVersionLabel.UpdateAsync(_ => builder.ProviderVersionLabel, cancellationToken); + await BuilderHasProviderVersion.UpdateAsync(_ => builder.HasProviderVersion, cancellationToken); + await BuilderSuggestedModelName.UpdateAsync(_ => builder.SuggestedModelName, cancellationToken); + await BuilderSupportedModelNames.UpdateAsync(_ => builder.SupportedModelNames, cancellationToken); + await BuilderSupportedModels.UpdateAsync(_ => builder.SupportedModels, cancellationToken); + await BuilderHasSupportedModels.UpdateAsync(_ => builder.HasSupportedModels, cancellationToken); + await BuilderModelHelperText.UpdateAsync(_ => builder.ModelHelperText, cancellationToken); + await BuilderStatusMessage.UpdateAsync(_ => builder.StatusMessage, cancellationToken); + await BuilderCanCreateAgent.UpdateAsync(_ => builder.CanCreateAgent, cancellationToken); + await Builder.UpdateAsync(_ => builder, cancellationToken); + } + + private async ValueTask CreateBuilderViewAsync( + AgentProviderOption? providerOverride, + CancellationToken cancellationToken) + { + var selectedProvider = providerOverride ?? (await SelectedProvider) ?? EmptySelectedProvider; + if (IsEmptySelectedProvider(selectedProvider)) { - await HandleSelectedProviderChanged(resolvedProvider, cancellationToken); + selectedProvider = await ResolveSelectedProviderAsync(cancellationToken); } + + var suggestedModelName = ResolveSuggestedModelName(selectedProvider); + var supportedModelNames = ResolveSupportedModelNames(selectedProvider); + var providerVersionLabel = string.IsNullOrWhiteSpace(selectedProvider.InstalledVersion) + ? string.Empty + : VersionPrefix + selectedProvider.InstalledVersion; + var modelHelperText = string.IsNullOrWhiteSpace(suggestedModelName) + ? EmptyModelHelperText + : string.Format( + System.Globalization.CultureInfo.InvariantCulture, + SuggestedModelHelperCompositeFormat, + suggestedModelName); + + return new AgentBuilderView( + IsEmptySelectedProvider(selectedProvider) ? EmptyProviderDisplayName : selectedProvider.DisplayName, + IsEmptySelectedProvider(selectedProvider) ? EmptyProviderStatusSummary : selectedProvider.StatusSummary, + IsEmptySelectedProvider(selectedProvider) ? EmptyProviderCommandName : selectedProvider.CommandName, + providerVersionLabel, + !string.IsNullOrWhiteSpace(providerVersionLabel), + suggestedModelName, + supportedModelNames, + supportedModelNames.Count > 0, + modelHelperText, + ResolveStatusMessage(selectedProvider), + !IsEmptySelectedProvider(selectedProvider) && + selectedProvider.CanCreateAgents && + !string.IsNullOrWhiteSpace((await AgentName) ?? string.Empty)); } private async ValueTask ResolveSelectedProviderAsync(CancellationToken cancellationToken) { var selectedProvider = (await SelectedProvider) ?? EmptySelectedProvider; + var providers = await Providers; if (!IsEmptySelectedProvider(selectedProvider)) { - return selectedProvider; + var resolvedSelectedProvider = FindProviderByKind(providers, selectedProvider.Kind); + if (!IsEmptySelectedProvider(resolvedSelectedProvider)) + { + if (!Equals(selectedProvider, resolvedSelectedProvider) || + await SelectedProviderKind != resolvedSelectedProvider.Kind) + { + await SetSelectedProviderStateAsync( + resolvedSelectedProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); + } + + return resolvedSelectedProvider; + } } var selectedProviderKind = await SelectedProviderKind; - var providers = await Providers; var resolvedProvider = FindProviderByKind(providers, selectedProviderKind); if (!IsEmptySelectedProvider(resolvedProvider)) { - await HandleSelectedProviderChanged(resolvedProvider, cancellationToken); + await SetSelectedProviderStateAsync( + resolvedProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); return resolvedProvider; } var creatableProvider = FindFirstCreatableProvider(providers); if (!IsEmptySelectedProvider(creatableProvider)) { - await HandleSelectedProviderChanged(creatableProvider, cancellationToken); + await SetSelectedProviderStateAsync( + creatableProvider, + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); return creatableProvider; } if (providers.Count > 0) { - await HandleSelectedProviderChanged(providers[0], cancellationToken); + await SetSelectedProviderStateAsync( + providers[0], + synchronizeModel: true, + refreshBuilder: false, + cancellationToken); return providers[0]; } diff --git a/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs b/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs index bec0857..43b9d84 100644 --- a/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs +++ b/DotPilot/Presentation/Chat/Configuration/ChatComposerModifierState.cs @@ -4,10 +4,42 @@ namespace DotPilot.Presentation; public sealed class ChatComposerModifierState { + private static readonly VirtualKey[] SupportedModifierKeys = + [ + VirtualKey.Shift, + VirtualKey.Control, + VirtualKey.Menu, + VirtualKey.LeftWindows, + VirtualKey.RightWindows, + ]; + private readonly Dictionary pressedModifierKeys = []; public bool HasPressedModifier => pressedModifierKeys.Count > 0; + public bool HasPressedModifierOrCurrentState(Func? isCurrentlyPressed) + { + if (HasPressedModifier) + { + return true; + } + + if (isCurrentlyPressed is null) + { + return false; + } + + foreach (var key in SupportedModifierKeys) + { + if (isCurrentlyPressed(key)) + { + return true; + } + } + + return false; + } + public void RegisterKeyDown(VirtualKey key) { var normalizedKey = NormalizeModifierKey(key); diff --git a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs index ed7776f..a0c5c82 100644 --- a/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs +++ b/DotPilot/Presentation/Chat/Controls/ChatComposer.xaml.cs @@ -1,5 +1,7 @@ +using Microsoft.UI.Input; using Microsoft.UI.Xaml.Input; using Windows.System; +using Windows.UI.Core; namespace DotPilot.Presentation.Controls; @@ -36,7 +38,7 @@ private void OnComposerInputKeyDown(object sender, KeyRoutedEventArgs e) var action = ChatComposerKeyboardPolicy.Resolve( behavior: SendBehavior, isEnterKey: e.Key is VirtualKey.Enter, - hasModifier: _modifierState.HasPressedModifier); + hasModifier: HasEffectiveModifierPressed()); if (action is ChatComposerKeyboardAction.SendMessage) { ExecuteSubmitAction(textBox); @@ -131,4 +133,22 @@ private static void InsertNewLine(TextBox textBox) textBox.SelectionLength = 0; SynchronizeComposerText(textBox); } + + private bool HasEffectiveModifierPressed() + { + return _modifierState.HasPressedModifierOrCurrentState(IsModifierKeyPressed); + } + + private static bool IsModifierKeyPressed(VirtualKey key) + { + try + { + return (InputKeyboardSource.GetKeyStateForCurrentThread(key) & CoreVirtualKeyStates.Down) + == CoreVirtualKeyStates.Down; + } + catch + { + return false; + } + } } diff --git a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml index 2617b14..6618cb5 100644 --- a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml +++ b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml @@ -94,7 +94,48 @@ + + + + + + + + + + + + + + + + + + @@ -147,11 +188,13 @@ - + - diff --git a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs index 608cc72..65171c3 100644 --- a/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs +++ b/DotPilot/Presentation/Chat/Controls/ChatConversationView.xaml.cs @@ -2,15 +2,105 @@ namespace DotPilot.Presentation.Controls; public sealed partial class ChatConversationView : UserControl { + private long _itemsSourceCallbackToken; + private bool _isItemsSourceCallbackRegistered; + private bool _pendingAutoScroll = true; + public ChatConversationView() { InitializeComponent(); + Loaded += OnLoaded; + Unloaded += OnUnloaded; + } + + private void OnLoaded(object sender, RoutedEventArgs e) + { + if (!_isItemsSourceCallbackRegistered) + { + _itemsSourceCallbackToken = MessagesList.RegisterPropertyChangedCallback( + ItemsControl.ItemsSourceProperty, + OnMessagesSourceChanged); + _isItemsSourceCallbackRegistered = true; + } + + MessagesList.LayoutUpdated += OnMessagesLayoutUpdated; + MessagesList.SizeChanged += OnMessagesSizeChanged; + QueueAutoScroll(); + } + + private void OnUnloaded(object sender, RoutedEventArgs e) + { + MessagesList.LayoutUpdated -= OnMessagesLayoutUpdated; + MessagesList.SizeChanged -= OnMessagesSizeChanged; + if (!_isItemsSourceCallbackRegistered) + { + return; + } + + MessagesList.UnregisterPropertyChangedCallback( + ItemsControl.ItemsSourceProperty, + _itemsSourceCallbackToken); + _isItemsSourceCallbackRegistered = false; + } + + private void OnMessagesSourceChanged(DependencyObject sender, DependencyProperty dependencyProperty) + { + QueueAutoScroll(); + } + + private void OnMessagesLayoutUpdated(object? sender, object e) + { + if (!_pendingAutoScroll) + { + return; + } + + _pendingAutoScroll = false; + ScrollToLatestMessage(); + } + + private void OnMessagesSizeChanged(object sender, SizeChangedEventArgs e) + { + if (e.NewSize.Height <= e.PreviousSize.Height) + { + return; + } + + QueueAutoScroll(); + } + + private void QueueAutoScroll() + { + _pendingAutoScroll = true; + } + + private void ScrollToLatestMessage() + { + if (!IsLoaded) + { + return; + } + + _ = ConversationScrollViewer.ChangeView( + horizontalOffset: null, + verticalOffset: ConversationScrollViewer.ScrollableHeight, + zoomFactor: null, + disableAnimation: true); } } public sealed class ChatMessageTemplateSelector : DataTemplateSelector { private const string MissingTemplateMessage = "Chat message templates must be configured."; + private static readonly SessionStreamEntryKind[] _activityKinds = + [ + SessionStreamEntryKind.ToolStarted, + SessionStreamEntryKind.ToolCompleted, + SessionStreamEntryKind.Status, + SessionStreamEntryKind.Error, + ]; + + public DataTemplate? ActivityTemplate { get; set; } public DataTemplate? IncomingTemplate { get; set; } @@ -18,6 +108,12 @@ public sealed class ChatMessageTemplateSelector : DataTemplateSelector protected override DataTemplate SelectTemplateCore(object item) { + if (item is ChatTimelineItem activityItem && + _activityKinds.Contains(activityItem.Kind)) + { + return ActivityTemplate ?? IncomingTemplate ?? OutgoingTemplate ?? throw new InvalidOperationException(MissingTemplateMessage); + } + return item is ChatTimelineItem { IsCurrentUser: true } ? OutgoingTemplate ?? IncomingTemplate ?? throw new InvalidOperationException(MissingTemplateMessage) : IncomingTemplate ?? OutgoingTemplate ?? throw new InvalidOperationException(MissingTemplateMessage); diff --git a/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs b/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs index f68fd79..105d210 100644 --- a/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs +++ b/DotPilot/Presentation/Chat/ViewModels/ChatModel.cs @@ -87,6 +87,9 @@ public async ValueTask Refresh(CancellationToken cancellationToken) _sessionRefresh.Raise(); await EnsureSelectedChatAsync(cancellationToken); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } catch (Exception exception) { ChatModelLog.Failure(logger, exception); @@ -127,6 +130,9 @@ public async ValueTask StartNewSession(CancellationToken cancellationToken) _workspaceRefresh.Raise(); _sessionRefresh.Raise(); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } catch (Exception exception) { ChatModelLog.Failure(logger, exception); @@ -219,6 +225,9 @@ private async ValueTask SendMessageCore(string? messageOverride, CancellationTok await FeedbackMessage.SetAsync(string.Empty, cancellationToken); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } catch (Exception exception) { ChatModelLog.Failure(logger, exception); @@ -245,6 +254,10 @@ private async ValueTask> LoadRecentChatsAsync await EnsureSelectedChatAsync(workspace, sessions, cancellationToken); return sessions; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception exception) { ChatModelLog.Failure(logger, exception); diff --git a/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml index ff4a847..5ade550 100644 --- a/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml +++ b/DotPilot/Presentation/Settings/Controls/SettingsShell.xaml @@ -3,6 +3,9 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:presentation="using:DotPilot.Presentation" x:Name="Root" + SelectProviderCommand="{Binding SelectProviderCommand}" + ToggleSelectedProviderCommand="{Binding ToggleSelectedProviderCommand}" + ExecuteProviderActionCommand="{Binding ExecuteProviderActionCommand}" AutomationProperties.AutomationId="SettingsShell"> @@ -163,8 +166,8 @@